Рекомендации к написанию автотестов
Перед началом следуем ознакомиться с 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 каждого из них:
- ensi/test-factories - пакет, предоставляющий базовую функциональность для создания фабрик любых структур или объектов.
- ensi/laravel-test-factories - пакет, предоставляющие базовые фабрики, от которых мы наследуем все фабрики в сервисах.
- ensi/laravel-openapi-testing - пакет, позволяющий проверять соответствие реальных ответов api и спецификацию oas
Несколько важных деталей, которые дают эти пакеты:
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);
Ensi\LaravelTestFactories\BaseApiFactory
- базовый класс для фабрик ответов/запросов api или любых других структур данныхEnsi\LaravelTestFactories\BaseModelFactory
- базовый класс для фабрик моделей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);
});
SEARCH
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-сервисы, так и совсем внешние, например логистическую или процессинговую систему).
В таком случае необходимо:
- Создать фабрики ответов, наследуйте их от
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);
}
}
- Добавить в трейт
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);
}
- Использовать в тесте. Пример теста:
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);