Перейти к основному содержимому

Рекомендации к написанию автотестов

Перед началом следуем ознакомиться с Autotests Guide. Данное руководство расширяет Autotests Guide техническими подробностями о том где располагать типичные файлы тестов и фабрик, есть примеры стандартных тестов.

Структура сервиса

Ниже представлен пример структуры сервиса, с т.з. размещения файлов, связанных с тестами. Не все эти файлы являются обязательными, подробнее о каждом из них расписано ниже

.
├── app
│ ├── Console
│ │ ├── Commands
│ │ │ └── Orders
│ │ │ └── CheckPaymentsCommand.php
│ │ └── Tests
│ │ └── OrdersComponentTest.php
│ ├── Domain
│ │ ├── Orders
│ │ │ └── Models
│ │ │ ├── Tests
│ │ │ │ ├── Factories
│ │ │ │ │ ├── DeliveryFactory.php
│ │ │ │ │ └── OrderFactory.php
│ │ │ │ └── OrderUnitTest.php
│ │ │ ├── Delivery.php
│ │ │ └── Order.php
│ │ └── Common
│ │ └── Tests
│ │ ├── Factories
│ │ │ ├── DeliveryFactory.php
│ │ │ └── OrderFactory.php
│ │ └── OrderUnitTest.php
│ └── Http
│ └── ApiV1
│ ├── Modules
│ │ └── Orders
│ │ ├── Controllers
│ │ │ └── DeliveriesController.php
│ │ ├── Queries
│ │ ├── Requests
│ │ └── Tests
│ │ ├── Factories
│ │ │ └── CreateDeliveryRequestFactory.php
│ │ └── DeliveriesComponentTest.php
│ └── Support
│ └── Tests
│ ├── Factories
│ │ └── FooFactory.php
│ ├── ApiV1ComponentTestCase.php
│ └── NotFoundPathComponentTest.php
└── tests
├── ComponentTestCase.php
├── IntegrationTestCase.php
├── MockServicesApi.php
├── Pest.php
├── TestCase.php
└── UnitTestCase.php

Общие правила описания любых фабрик и тестов

Существует несколько пакетов, которые помогают при написании фабрик и тестов. Следует ознакомиться с readme каждого из них:

  1. ensi/test-factories - пакет, предоставляющий базовую функциональность для создания фабрик любых структур или объектов.
  2. ensi/laravel-test-factories - пакет, предоставляющие базовые фабрики, от которых мы наследуем все фабрики в сервисах.
  3. ensi/laravel-openapi-testing - пакет, позволяющий проверять соответствие реальных ответов api и спецификацию oas

Несколько важных деталей, которые дают эти пакеты:

  1. Ensi\TestFactories\FakerProvider - провайдер для генератора фейковых значений, в котором добавлены удобные кастомные методы. Актуальный список следует смотреть в самом классе, но вот несколько из них:
  • modelId - alias для numberBetween(1). Полезен, когда нам необходимо сгенерировать идентификатор (любое числовое значение больше 1)
  • randomList - генератор массивов. Полезен, когда необходимо сгенерировать массив любых значений (строк/чисел/объектов). Принимает callback, который возвращает элемент массива
  • nullable - надстройка над optional. Тут остановимся подробнее. Часто нам приходится сталкиваться с полями, значения которых могут быть nullable. Если довериться методу optional, то мы будем иметь ситуацию, что при одном запуске теста значение будет заполнено, а в другой запуск - пустое. В таком случае, если в коде допущена ошибка проверки на null, то наш тест будет в 50% случаев успешным, а в 50% провальным. Чтобы не допускать такую ситуацию следует запускать несколько тест-кейсов на один и тот же код: с полностью заполненными необязательными полями, с полностью пустыми, со случайной заполненностью. Именно для этого и существует nullable, он умеет через глобальную статическую переменную переключать заполняемость, так что нам достаточно написать только одну фабрику и один тест, принимающий режим работы nullable. Пример теста:
test('...', function (?bool $always) {
FakerProvider::$optionalAlways = $always;

// ...

})->with(FakerProvider::$optionalDataset);
  1. Ensi\LaravelTestFactories\BaseApiFactory - базовый класс для фабрик ответов/запросов api или любых других структур данных
  2. Ensi\LaravelTestFactories\BaseModelFactory - базовый класс для фабрик моделей
  3. Ensi\TestFactories\Factory - базовый класс для всех остальных сущностей (например *Data.php и *Dto.php)

Описание типичных файлов

app/Domain/Orders/Models

Для каждой модели должна существовать фабрика, позволяющая сгенерировать в БД запись. Фабрики моделей хранятся рядом с моделями, в директории Tests/Factories.

Пример фабрики:

<?php

namespace App\Domain\Orders\Models\Tests\Factories;

use App\Domain\Orders\Models\Order;
use App\Http\ApiV1\OpenApiGenerated\Enums\PaymentStatusEnum;
use Ensi\LaravelTestFactories\BaseModelFactory;

class OrderFactory extends BaseModelFactory
{
protected $model = Order::class;

public function definition()
{
return [
'source_id' => OrderSource::factory(),
'customer_id' => $this->faker->modelId(),
'customer_email' => $this->faker->email(),
'number' => $this->faker->unique()->numerify('######'),
'status' => $this->faker->randomElement(PaymentStatusEnum::cases()),
'is_problem' => $this->faker->boolean(),
'problem_comment' => $this->faker->nullable()->text(50),
];
}

public function withProblem(): self
{
return $this->state([
'is_problem' => true,
'problem_comment' => $this->faker->text(50),
]);
}
}

При этом в классе модели должен быть статический метод, для получения фабрики этой модели:

<?php

class Order extends Model
{
// ....

public static function factory(): OrderFactory
{
return OrderFactory::new();
}

// ....
}

Иногда в классе модели может быть логика, которую необходимо покрыть отдельным Unit или Integration тестом. Такой тест можно расположить самой директории Tests.

app/Http/ApiV1/Support/Tests

В этой директории можно располагать любые вспомогательные файлы для тестирования Http модулей. Это могут быть тест-кейсы, например "с авторизацей" и "без авторизации", или фабрики для ваших базовых глобальных сущностей

app/Http/ApiV1/Modules/Orders

Каждый эндпоинт должен быть покрыт компонентным тестом. Сами тесты в этом случае располагаются в директории Tests, лежащей в том же Http модуле. Если эндпоинт принимает тело, то необходимо описать фабрику, генерирующую тело запроса. Такие фабрики наследуются от базового класса \Ensi\LaravelTestFactories\BaseApiFactory.

Пример фабрики:

<?php

namespace App\Http\ApiV1\Modules\Orders\Tests\Factories;

use App\Domain\Orders\Data\Timeslot;
use App\Http\ApiV1\OpenApiGenerated\Enums\DeliveryStatusEnum;
use Ensi\LaravelTestFactories\BaseApiFactory;

class CreateDeliveryRequestFactory extends BaseApiFactory
{
protected function definition(): array
{
return [
'date' => $this->faker->date(),
'status' => $this->faker->randomEnum(DeliveryStatusEnum::cases()),
];
}

public function make(array $extra = []): array
{
return $this->makeArray($extra);
}
}

Далее рассмотрим стандартные тесты, на crud операции

GET

test('GET /api/v1/orders/orders/{id} 200', function (?bool $always) {
FakerProvider::$optionalAlways = $always;

/** @var Order $model */
$model = Order::factory()->create();

getJson("/api/v1/orders/orders/{$model->id}")
->assertJsonPath('data.status', $model->status)
->assertStatus(200);
})->with(FakerProvider::$optionalDataset);

test('GET /api/v1/orders/orders/{id} 404', function () {
$undefinedId = 1;
getJson("/api/v1/orders/orders/{$undefinedId}")
->assertStatus(404);
});

CREATE

test('POST /api/v1/orders/orders 200', function (?bool $always) {
FakerProvider::$optionalAlways = $always;

$request = CreateOrderRequestFactory::new()->make();

postJson('/api/v1/orders/orders', $request)
->assertStatus(201);

assertDatabaseHas((new Order())->getTable(), [
'name' => $request['name'],
]);
})->with(FakerProvider::$optionalDataset);

PATCH

test('PATCH /api/v1/orders/orders/{id} 200', function (?bool $always) {
FakerProvider::$optionalAlways = $always;

/** @var Order $model */
$model = Order::factory()->create();

$request = PatchOrderRequestFactory::new()->make();

patchJson("/api/v1/orders/orders/{$model->id}", $request)
->assertJsonPath('data.name', $request['name'])
->assertStatus(200);

assertDatabaseHas((new Order())->getTable(), [
'id' => $model->id,
'name' => $request['name'],
]);
})->with(FakerProvider::$optionalDataset);

test('PATCH /api/v1/orders/orders/{id} 404', function () {
$request = PatchOrderRequestFactory::new()->make();

$undefinedId = 1;
patchJson("/api/v1/orders/orders/{$undefinedId}", $request)
->assertStatus(404);
});

DELETE

test('DELETE /api/v1/orders/orders/{id} 200', function (?bool $always) {
FakerProvider::$optionalAlways = $always;

/** @var Order $model */
$model = Order::factory()->create();

deleteJson("/api/v1/orders/orders/{$model->id}")
->assertStatus(200);

assertModelMissing($model);
})->with(FakerProvider::$optionalDataset);

test('DELETE /api/v1/orders/orders/{id} 404', function () {
$undefinedId = 1;
deleteJson("/api/v1/orders/orders/{$undefinedId}")
->assertStatus(404);
});
test('POST /api/v1/orders/orders:search 200', function (?bool $always) {
FakerProvider::$optionalAlways = $always;

$models = Order::factory()
->count(10)
->sequence(
['status' => OrderStatusEnum::CREATED],
['status' => OrderStatusEnum::CANCELLED],
)
->create();

postJson('/api/v1/orders/orders:search', [
"filter" => ["status" => OrderStatusEnum::CANCELLED],
"sort" => ["-id"],
])
->assertStatus(200)
->assertJsonCount(5, 'data')
->assertJsonPath('data.0.id', $models->last()->id)
->assertJsonPath('data.0.status', OrderStatusEnum::CANCELLED);
})->with(FakerProvider::$optionalDataset);

test("POST /api/v1/orders/orders:search filter success", function (
string $fieldKey,
$value,
?string $filterKey,
$filterValue,
?bool $always,
) {
FakerProvider::$optionalAlways = $always;

/** @var Order $model */
$model = Order::factory()->create($value ? [$fieldKey => $value] : []);
Order::factory()->create();

postJson("/api/v1/orders/orders:search", ["filter" => [
($filterKey ?: $fieldKey) => ($filterValue ?: $model->{$fieldKey}),
], 'sort' => ['id'], 'pagination' => ['type' => PaginationTypeEnum::OFFSET, 'limit' => 1]])
->assertStatus(200)
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $model->id);
})->with([
['status', OrderStatusEnum::CREATE, null, null],
['archive', false, null, null],
['name', 'order_my', 'name_like', '_my'],
['created_at', '2022-04-20T01:32:08.000000Z', 'created_at_from', '2022-04-19T01:32:08.000000Z'],
], FakerProvider::$optionalDataset);

test("POST /api/v1/orders/orders:search sort success", function (string $sort, ?bool $always) {
FakerProvider::$optionalAlways = $always;

Order::factory()->create();
postJson("/api/v1/orders/orders:search", ["sort" => [$sort]])->assertStatus(200);
})->with([
'id',
'name',
'status',
'updated_at',
'created_at',
], FakerProvider::$optionalDataset);

test("POST /api/v1/orders/orders:search include success", function (?bool $always) {
FakerProvider::$optionalAlways = $always;

/** @var Order $model */
$model = Order::factory()->create();
$deliveries = Deliveries::factory()->for($order)->count(3)->create();

postJson("/api/v1/orders/orders:search", ["include" => [
'deliveries',
]])
->assertStatus(200)
->assertJsonCount($deliveries->count(), 'data.0.deliveries');
})->with(FakerProvider::$optionalDataset);

app/Console

Для консольных команд тоже необходимо писать тесты. Например, в файле app/Console/Tests/OrdersComponentTest.php описываются тесты для консольных команд соответствующего домена (в данном случае для команд из директории app/Console/Commands/Orders). Если для одной команды получает много тестов, их можно выделить в отдельный файл.

Пример теста:

<?php

uses(ComponentTestCase::class);
uses()->group('component');

test("Command CheckPaymentsCommand success", function (?bool $always) {
FakerProvider::$optionalAlways = $always;

/** @var ComponentTestCase $this */

/** @var Order $model */
$model = Order::factory()->create();

artisan(CheckPaymentsCommand::class);

assertDatabaseHas((new Order())->getTable(), [
'id' => $model->id,
'status' => OrderStatusEnum::CANCELED,
]);
})->with(FakerProvider::$optionalDataset);

Тестирование работы с файлами

Пример теста с добавлением нового файла

test('POST /api/v1/orders/orders/{id}:attach-file 201', function (?bool $always) {
FakerProvider::$optionalAlways = $always;

$diskName = resolve(EnsiFilesystemManager::class)->protectedDiskName();
Storage::fake($diskName);

/** @var Order $model */
$model = Order::factory()->create();

$requestBody = ['file' => UploadedFile::fake()->create("file.jpg", 100)];

$response = post("/api/v1/orders/orders/{$model->id}:attach-file", $requestBody, ['Content-Type' => "multipart/form-data"])
->assertStatus(201);

$responseFile = $response->decodeResponseJson()['data']['file'];

/** @var \Illuminate\Filesystem\FilesystemAdapter */
$disk = Storage::disk($diskName);
$disk->assertExists($responseFile['path']);
assertDatabaseHas((new OrderFile())->getTable(), [
'order_id' => $model->id,
'path' => $responseFile['path'],
]);
})->with(FakerProvider::$optionalDataset);

Пример теста с удалением файла

test('DELETE /api/v1/orders/orders/{id}:delete-files success', function (?bool $always) {
FakerProvider::$optionalAlways = $always;

$filePath = "order_files/file.jpg";
$diskName = resolve(EnsiFilesystemManager::class)->protectedDiskName();
Storage::fake($diskName);
Storage::disk($diskName)->put($filePath, 'some content');

/** @var Order $model */
$model = Order::factory()->create();
/** @var OrderFile $modelFile */
$modelFile = OrderFile::factory()->for($model)->withPath($filePath)->create();

deleteJson("/api/v1/orders/orders/{$model->id}:delete-files", ['file_ids' => [$modelFile->id]])
->assertStatus(200)
->assertJsonPath('data', null);

/** @var \Illuminate\Filesystem\FilesystemAdapter */
$disk = Storage::disk($diskName);
$disk->assertMissing($filePath);
assertDatabaseMissing((new OrderFile())->getTable(), [
'order_id' => $model->id,
]);
})->with(FakerProvider::$optionalDataset);

Тестирование работы с внешними системами

Часто необходимо писать интеграционные тесты для кода, который делает запрос во внешние системы (как другие ensi-сервисы, так и совсем внешние, например логистическую или процессинговую систему).

В таком случае необходимо:

  1. Создать фабрики ответов, наследуйте их от Ensi\LaravelTestFactories\BaseApiFactory, расположить следует в директории app/Domain/{DomainName}/Tests/Factories, где DomainName наиболее подходящий по логике домен, либо Common, если подходящего нет
<?php

namespace App\Domain\Common\Tests\Factories;

// ...
use Ensi\LaravelTestFactories\BaseApiFactory;

class ProductFactory extends BaseApiFactory
{
protected function definition(): array
{
return [
'id' => $this->faker->modelId(),

// ...

'created_at' => $this->faker->dateTime(),
'updated_at' => $this->faker->dateTime(),
];
}

public function make(array $extra = []): Product
{
return new Product($this->makeArray($extra));
}

public function makeResponse(array $extra = []): ProductResponse
{
return new ProductResponse(['data' => $this->make($extra)]);
}

public function makeResponseSearch(array $extras = [], int $count = 1, mixed $pagination = null): SearchProductsResponse
{
return $this->generateResponseSearch(SearchProductsResponse::class, $extras, $count, $pagination);
}
}
  1. Добавить в трейт tests/MockServicesApi.php метод для создания mock:
public function mock{ServiceName}{ApiName}(): MockInterface|{ApiName}
{
return $this->mock({ApiName}::class);
}

Например, для сервиса pim + ProductsApi это будет:

public function mockPimProductsApi(): MockInterface|ProductsApi
{
return $this->mock(ProductsApi::class);
}
  1. Использовать в тесте. Пример теста:
test('POST /api/v1/products/products:search 200', function (?bool $always) {
FakerProvider::$optionalAlways = $always;

/** @var ApiV1ComponentTestCase $this */
$productId = 1;

$this->mockPimProductsApi()->allows([
'searchProducts' => ProductFactory::new()->makeResponseSearch([['id' => $productId]]),
]);

postJson("/api/v1/products/products:search", ['pagination' => PaginationFactory::new()->makeRequestOffset()])
->assertStatus(200)
->assertJsonPath('data.0.id', $productId);
})->with(FakerProvider::$optionalDataset);