Рекомендации к написанию автотестов
Перед началом следуем ознакомиться с 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);
missing- надстройка надnullable, используемая для имитации отсутствующих полей. При использованииnullableполе присутствует, но имеет значение null. В свою очередьmissingпозволяет полностью исключить поле из фабрики.
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);
    }
}
Фабрика должна нормально генерировать полное тело запроса без необходимости вызова дополнительных методов
👍  # Хорошо
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(),
        ];
    }
}
💩 # Плохо
class OrderFactory extends BaseModelFactory
{
    protected $model = Order::class;
    public function definition()
    {
        return [
            'customer_id' => $this->faker->modelId(),
            'customer_email' => $this->faker->email(),
        ];
    }
    public function withOrderSource(): self
    {
        return $this->state([
            'source_id' => OrderSource::factory(),
        ]);
    }
}
Далее рассмотрим стандартные тесты, на 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);