Рекомендации к написанию автотестов
Перед началом следуем ознакомиться с 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
│ └── Http
│ └── ApiV1
│ ├── Modules
│ │ └── Orders
│ │ ├── Controllers
│ │ │ └── DeliveriesController.php
│ │ ├── Queries
│ │ ├── Requests
│ │ └── Tests
│ │ ├── Factories
│ │ │ └── CreateDeliveryRequestFactory.php
│ │ └── DeliveriesComponentTest.php
│ └── Support
│ └── Tests
│ ├── Factories
│ │ ├── BaseApiFactory.php
│ │ └── PaginationFactory.php
│ ├── ApiV1ComponentTestCase.php
│ └── NotFoundPathComponentTest.php
└── tests
├── ComponentTestCase.php
├── IntegrationTestCase.php
├── Pest.php
├── TestCase.php
└── UnitTestCase.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 Illuminate\Database\Eloquent\Factories\Factory;
class OrderFactory extends Factory
{
protected $model = Order::class;
public function definition()
{
return [
'customer_id' => $this->faker->numberBetween(1),
'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->optional()->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/Modules/Orders
Каждый эндпоинт должен быть покрыт компонентным тестом. Сами тесты в этом случае располагаются в директории Tests
, лежащей в том же Http модуле. Если эндпоинт принимает тело, то необходимо описать фабрику, генерирующую тело запроса. Такие фабрики наследуются от базового класса \App\Http\ApiV1\Support\Tests\Factories\BaseApiFactory
.
Пример фабрики:
<?php
namespace App\Http\ApiV1\Modules\Orders\Tests\Factories;
use App\Domain\Orders\Data\Timeslot;
use App\Http\ApiV1\OpenApiGenerated\Enums\DeliveryStatusEnum;
use App\Http\ApiV1\Support\Tests\Factories\BaseApiFactory;
class CreateDeliveryRequestFactory extends BaseApiFactory
{
protected function definition(): array
{
return [
'date' => $this->faker->date(),
'status' => $this->faker->randomElement(array_column(DeliveryStatusEnum::cases(), 'value')),
];
}
public function make(array $extra = []): array
{
return $this->makeArray($extra);
}
}
Далее рассмотрим стандартные тесты, на crud операции
GET
test('GET /api/v1/orders/orders/{id} 200', function () {
/** @var Order $order */
$order = Order::factory()->create();
getJson("/api/v1/orders/orders/{$order->id}")
->assertJsonPath('data.status', $order->status)
->assertStatus(200);
});
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 () {
$request = CreateOrderRequestFactory::new()->make();
postJson('/api/v1/orders/orders', $request)
->assertStatus(201);
assertDatabaseHas((new Order())->getTable(), [
'name' => $request['name'],
]);
});
PATCH
test('PATCH /api/v1/orders/orders/{id} 200', function () {
/** @var Order $order */
$order = Order::factory()->create();
$request = PatchOrderRequestFactory::new()->make();
patchJson("/api/v1/orders/orders/{$order->id}", $request)
->assertJsonPath('data.name', $request['name'])
->assertStatus(200);
assertDatabaseHas((new Order())->getTable(), [
'id' => $order->id,
'name' => $request['name'],
]);
});
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 () {
/** @var Order $order */
$order = Order::factory()->create();
deleteJson("/api/v1/orders/orders/{$order->id}")
->assertStatus(200);
assertModelMissing($order);
});
test('DELETE /api/v1/orders/orders/{id} 404', function () {
$undefinedId = 1;
deleteJson("/api/v1/orders/orders/{$undefinedId}", $request)
->assertStatus(404);
});
SEARCH
test('POST /api/v1/orders/orders:search 200', function () {
$orders = 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', $orders->last()->id)
->assertJsonPath('data.0.status', OrderStatusEnum::CANCELLED);
});
test("POST /api/v1/orders/orders:search filter success", function (string $fieldKey, $value = null, ?string $filterKey = null, $filterValue = null) {
/** @var Order $order */
$order = Order::factory()->create($value ? [$fieldKey => $value] : []);
Order::factory()->create();
postJson("/api/v1/orders/orders:search", ["filter" => [
($filterKey ?: $fieldKey) => ($filterValue ?: $order->{$fieldKey}),
], 'sort' => ['id'], 'pagination' => ['type' => PaginationTypeEnum::OFFSET, 'limit' => 1]])
->assertStatus(200)
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $order->id);
})->with([
['status', OrderStatusEnum::CREATED],
['archive', false],
['name', 'order_my', 'name_like', '_my'],
['created_at', '2022-04-20T01:32:08.000000Z', 'created_at_from', '2022-04-19T01:32:08.000000Z'],
]);
test("POST /api/v1/orders/orders:search sort success", function (string $sort) {
Order::factory()->create();
postJson("/api/v1/orders/orders:search", ["sort" => [$sort]])->assertStatus(200);
})->with([
'id',
'name',
'status',
'updated_at',
'created_at',
]);
test("POST /api/v1/orders/orders:search include success", function () {
/** @var Order $order */
$order = 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.deliveries');
});
app/Console
Для консольных команд тоже необходимо писать тесты. Например, в файле app/Console/Tests/OrdersComponentTest.php
описываются тесты для консольных команд соответствующего домена (в данном случае для команд из директории app/Console/Commands/Orders
). Если для одной команды получает много тестов, их можно выделить в отдельный файл.
Пример теста:
<?php
uses(ComponentTestCase::class);
uses()->group('component');
test("Command CheckPaymentsCommand success", function () {
/** @var ComponentTestCase $this */
/** @var Order $order */
$order = Order::factory()->create();
artisan(CheckPaymentsCommand::class);
assertDatabaseHas((new Order())->getTable(), [
'id' => $orderCancel->id,
'status' => OrderStatusEnum::CANCELED,
]);
});
Тестирование работы с файлами
Пример теста с добавлением нового файла
test('POST /api/v1/orders/orders/{id}:attach-file 201', function () {
$diskName = resolve(EnsiFilesystemManager::class)->protectedDiskName();
Storage::fake($diskName);
/** @var Order $order */
$order = Order::factory()->create();
$requestBody = ['file' => UploadedFile::fake()->create("file.jpg", 100)];
$response = post("/api/v1/orders/orders/{$order->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' => $order->id,
'path' => $responseFile['path'],
]);
});
Пример теста с удалением файла
test('DELETE /api/v1/orders/orders/{id}:delete-files success', function () {
$filePath = "order_files/file.jpg";
$diskName = resolve(EnsiFilesystemManager::class)->protectedDiskName();
Storage::fake($diskName);
Storage::disk($diskName)->put($filePath, 'some content');
/** @var Order $order */
$order = Order::factory()->create();
/** @var OrderFile $orderFile */
$orderFile = OrderFile::factory()->for($order)->withPath($filePath)->create();
deleteJson("/api/v1/orders/orders/{$order->id}:delete-files", ['file_ids' => [$orderFile->id]])
->assertStatus(200)
->assertJsonPath('data', null);
/** @var \Illuminate\Filesystem\FilesystemAdapter */
$disk = Storage::disk($diskName);
$disk->assertMissing($filePath);
assertDatabaseMissing((new OrderFile())->getTable(), [
'order_id' => $order->id,
]);
});