Laravel: Всё, что вы можете протестировать в приложении

Источник: «Everything You Can Test In Your Laravel Application»
Общая проблема при тестировании заключается не в том, как что-то протестировать, а в том, что вы можете протестировать. Я составил список всего, что мне нравиться тестировать в своих приложениях.

Все примеры тестов сосредоточены на концепциях тестирования и могут применяться ко всем средам тестирования. Мои примеры написаны с PEST.

Ищите что-то конкретное?

Тестирование статуса ответа страницы

Тестирование страницы ответа — один из самых простых для написания тестов; тем не менее, это чрезвычайно полезно.

Это гарантирует, что страница отвечает правильным кодом состояния HTTP, в первую очередь ответом 200.

it('gives back a successful response for home page', function () {
$this->get('/')->assertOk();
});

Это простой тест, но очень важно знать, что ваша домашняя страница не выдаёт ошибку. Итак, если вы хотите написать свой первый тест, начните с него.

Тестирование текста ответа страницы

Этот тест аналогичен первому тесту ответа. Мы тоже тестируем ответ, но на этот раз нас интересует его содержание.

it('lists products', function () {
// Arrange
$firstProduct = Product::factory()->create();
$secondProduct = Product::factory()->create();

// Act & Assert
$this->get('/')
->assertOk()
->assertSeeText([
$firstProduct->title,
$secondProduct->title,
]);
});

Здесь мы гарантируем, что названия продуктов будут отображаться на первой странице. Это полезно, если вы загружаете базу продуктов из базы данных и обеспечиваете их отображение.

Вы можете быть более конкретными, например, когда вы хотите показать только выпущенные продукты.

it('lists released products', function () {
// Arrange
$releasedProduct = Product::factory()
->released()
->create();

$draftProduct = Product::factory()
->create();

// Act & Assert
$this->get('/')
->assertOk()
->assertSeeText($releasedProduct->title)
->assertDontSeeText($draftProduct->title);
});

Этот пример также демонстрирует, как тестировать то, что не показывается, это также может быть полезным. Этот тест не был бы таким полезным, если бы на главной странице был только статичный текст.

Тестирования представления страницы ответа

Помимо тестирования статуса и содержимого, вы можете протестировать возвращаемое представление.

it('returns correct view', function() {
// Act & Assert
$this->get('/')
->assertOk()
->assertViewIs('home');
});

Вы можете пойти дальше и протестировать данные, которые передаются в представление.

it('returns correct view', function() {
// Act & Assert
$this->get('/')
->assertOk()
->assertViewIs('home')
->assertViewHas('products');
});

Тестирование страницы ответа JSON

Если вы хотите проверить возвращаемые JSON данные в вашем API, то вы можете JSON хелперы Laravel, такие, как метод assertJson().

it('returns all products as JSON', function () {
// Arrange
$product = Product::factory()->create();
$anotherProduct = Product::factory()->create();

// Act & Assert
$this->post('api/products')
->assertOk()
->assertJson([
[
'title' => $product->title,
'description' => $product->description,
],
[
'title' => $anotherProduct->title,
'description' => $anotherProduct->description,
],
]);
});

Тестирование базы данных

Поскольку мы храним данные в базе данных, нам нужно убедиться, что данные хранятся правильно. Здесь Laravel может помочь с некоторыми удобными хелперами утверждений.

it('stores a product', function () {
// Act
$this->actingAs(User::factory()->create())
->post('product', [
'title' => 'Product name',
'description' => 'Product description',
])->assertSuccessful();

// Assert
$this->assertDatabaseCount(Product::class, 1);
$this->assertDatabaseHas(Product::class, [
'title' => 'Product name',
'description' => 'Product description',
]);
});

Пример тестирует, что продукт создан и сохранён в базе данных.

Тестирование валидации

Валидация важная часть многих приложений Laravel. Вы хотите быть уверенны, что отправляются только валидные данные. По умолчанию Laravel отправляет пользователю ошибки валидации, которые можно проверить с помощью метода assertInvalid().

it('requires the title', function () {
// Act
$this->actingAs(User::factory()->create())
->post('product', [
'description' => 'Product description',
])->assertInvalid(['title' => 'required']);
});

it('requires the description', function () {
// Act
$this->actingAs(User::factory()->create())
->post('product', [
'title' => 'Product name',
])->assertInvalid(['description' => 'required']);
});

При работе с множеством правил валидации использование datasets может быть весьма полезным. Это может сильно очистить ваши тесты.

it('requires title and description tested with a dataset', function($data, $error) {
// Act
$this->actingAs(User::factory()->create())
->post('product', $data)->assertInvalid($error);
})->with([
'title required' => [['description' => 'text'], ['title' => 'required']],
'description required' => [['title' => 'Title'], ['description' => 'required']],
]);

Тестирование Моделей / Отношений

Во-первых, мне нравиться тестировать каждое отношение модели. Чтобы быть точным, мы не будем проверять функциональность отношения; это то, что Laravel уже делает. Мы хотим убедиться, что отношения определены.

it('has products', function () {
// Arrange
$user = User::factory()
->has(Product::factory())
->create();

// Act
$products = $user->products;

// Assert
expect($products)
->toBeInstanceOf(Collection::class)
->first()->toBeInstanceOf(Product::class);
});

У разработчиков разные мнения о том, какая логика должна обрабатываться в модели, и это нормально. Но если вы добавите её, обязательно протестируйте.

it('only returns released courses for query scope', function () {
// Arrange
Course::factory()->released()->create();
Course::factory()->create();

// Act && Assert
expect(Course::released()->get())
->toHaveCount(1)
->first()->id->toEqual(1);
});

Другим примером может быть метод доступа к модели, например:

protected function firstName(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucfirst($value),
);
}

И тест будет выглядеть так:

it('capitalizes the first character of the first name', function () {
// Arrange
$user = User::factory()->create(['first_name' => 'christoph'])

// Act && Assert
expect($user->first_name)
->toBe('Christoph');
});

Тестирование отправки электронной почты

Laravel предоставляет множество хелперов для тестирования, особенно при использовании Фасадов

class PublishPodcastController extends Controller
{
public function __invoke(Podcast $podcast)
{
// publish podcast

Mail::to($podcast->author)->send(new PodcastPublishedMail());
}
}

В конце этого контроллера мы отправляем электронное письмо. В нашем тесте мы можем подключить контроллер через конечную точку и убедиться, что письмо будет отправлено.

it('sends email to podcast author', function() {
// Arrange
Mail::fake();
$podcast = Podcast::factory()->create();

// Act
$this->post(route('publish-podcast', $podcast));

// Assert
Mail::assertSent(PodcastPublishedMail::class);
});

Всегда запускайте метод Mail::fake() в начале тестов при тестировании электронных писем. Это гарантирует, что фактическое электронное письмо не будет отправлено пользователю.

Большинство хелперов, таких assertSent, также принимают обратный вызов в качестве второго аргумента. В нашем случае он получает объект mailable. Он содержит все данные электронной почты, например адрес электронной почты, на который его необходимо отправить.

Это позволяет использовать ещё больше утверждений, например об адресе получателя электронной почты.

Mail::assertSent(PodcastPublishedMail::class, function(PodcastPublishedMail $mail) use ($podcast) {
return $mail->hasTo($podcast->author->email);
});

Тестирование содержимого письма

Также имеет смысл проверить содержимое электронного письма. Это полезно, когда в вашем приложении рассылается много электронных писем. Убедиться, что содержимое корректно.

it('contains the product title', function () {
// Arrange
$product = Product::factory()->make();

// Act
$mail = new PaymentSuccessfulMail($product);

// Assert
expect($mail)
->assertHasSubject('Your payment was successful')
->assertSeeInHtml($product->title);
});

Тестирование Заданий и Очереди

Мне нравиться тестировать задания и очереди отдельно, начиная снаружи. Это означает, что я проверяю, что задание помещается в очередь.

it('dispatches an import products job', function () {
// Arrange
Queue::fake();

// Act
$this->post('import');

// Assert
Queue::assertPushed(ImportProductsJob::class);
});

Это гарантирует, что моё задание будет помещено в очередь для определённого триггера, например, для достижения конечной точки. Опять, Queue::fake() заботиться о том, чтобы не вытолкнуть задание. На данный момент мы не хотим запускать задание.

Но нам всё ещё нужно протестировать задание, верно? Конечно. Этот фрагмент содержит ключевую логику этой функции:

it('imports products', function() {

// Act
(new ImportProductsJob)->handle();

// Assert
$this->assertDatabaseCount(Product::class, 50);

// Make more assertions about the imported data
})

Этот новый тест концентрируется на задание и на том, что оно должно делать. Мы запускаем задание напрямую, вызывая handle, который есть у каждого задания.

Тестирование Action

Action — это просты классы, выполняющие одну конкретную задачу. Это отличный способ организовать код и отделить логику от контроллеров, чтобы они оставались чистыми. Но как их проверить?

Снова начнём снаружи. Во-первых, мы хотим проверить, вызывается ли наш action при достижении определённой конечной точки.

it('calls add-product-to-user action', function () {
// Assert
$this->mock(AddProductToUserAction::class)
->shouldReceive('handle')
->atLeast()->once();

// Arrange
$product = Product::factory()->create();
$user = User::factory()->create();

// Act
$this->post("purchase/$user->id/$product->i");
});

Мы можем сделать это смоделировав наш action класс и ожидая, что будет вызван метод handle. Но, опять, нас не интересует, что делает наш action; мы хотим убедиться, что он вызывается, когда мы вызываем контроллер покупки.

Чтобы это работало, мы должны убедиться, что контейнер разрешает наш action.

class PurchaseController extends Controller
{
public function __invoke(User $user, Product $product): void
{
app(AddProductToUserAction::class->handle($user, $product);

// Send purchase success email, etc.
}
}

Затем мы можем протестировать сам action. Как и в случае с заданием, мы вызываем метод handle для запуска action.

it('adds product to user', function () {
// Arrange
$product = Product::factory()->create();
$user = User::factory()->create();

// Act
(new AddProductToUserAction())->handle($user, $product);

// Assert
expect($user->products)
->toHaveCount(1)
->first()->id->toEqual($product->id);
});

Тестирование исключений

Иногда это хорошо, когда выбрасывается исключение, потому что мы намеренно хотим остановить выполнение нашего кода. Это мы тоже можем проверить.

it('stops if at least one account not found', function () {
// Act
$this->artisan(MergeAccountsCommand::class, [
'userId' => 1,
'userToBeMergedId' => 2,
]);
})->throws(ModelNotFoundException::class);

Мы можем связать метод throw с нашим тестом в PEST. Это гарантирует, что исключение будет выброшено.

Модульные тесты

Модульные тесты отлично подходят для тестирования небольших фрагментов кода, например, одного метода. Никакие другие зависимости не задействованы. Это делает их очень быстрыми и лёгкими для написания.

Наш пример касается объекта данных. Он содержит метод, который создаёт новый экземпляр из полезной нагрузки веб-хука.

class UserData
{
public function __construct(
public string $email,
public string $name,
public string $country,
)
{}

public static function fromWebhookPayload(array $webhookCallData): UserData
{
return new self(
$webhookCallData['client_email'],
$webhookCallData['client_name'],
$webhookCallData['client_country'],
);
}
}

В соответствующем тесте мы можем проверить, что метод только возвращает.

it('creates UserData object from paddle webhook call', function () {
// Arrange
$payload = [
'client_email' => 'test@test.at',
'client_name' => 'Christoph Rumpel',
'client_country' => 'AT',
];

// Act
$userData = UserData::fromWebhookPayload($payload);

// Assert
expect($userData)
->email->toBe('test@test.at')
->name->toBe('Christoph Rumpel')
->country->toBe('AT');
});

Имитация зависимостей

При работе с кодом с зависимостями полезно использовать моки/имитации. Это позволит сосредоточиться на логике вашего кода, а не на зависимостях. Это также означает, что моки/имитации могут быть полезны для любых тестов.

Мы уже делали это при тестировании action классов, но это работает с любой зависимостью. В следующем примере у нас есть контроллер с двумя зависимостями: платёжный провайдер и почтовая программа.

class PaymentController extends Controller
{
public function __invoke(PaymentProvider $paymentProvider, Mailer $mailer)
{
$paymentProvider->handle();

$mailer->to(auth()->user())->send(new PaymentSuccessfulMail);
}
}

В нашем тесте мы хотим сосредоточиться на проверке того, что отправляется правильное электронное письмо. Вот почему мы можем имитировать платёжного провайдера и ожидать вызова метода handle. В результате наш тест не завершиться ошибкой, даже если фактический платёжный провайдер никогда не вызывается.

it('sends payment successful mail', function () {
// Arrange
Mail::fake();

// Expect
$this->mock(PaymentProvider::class)
->shouldReceive('handle')
->once();

// Act
$this->post('payment');

// Assert
Mail::assertSent(PaymentSuccessfulMail::class);
});

Заключение

Очень важно составить список всего, что вы хотите протестировать в своём приложении. Но, конечно же, при изучении новых концепций кодирования вы также должны изучать новые методы тестирования. Так что ваш список будет расти.

Дополнительные материалы

Предыдущая Статья

Laravel: Поговорим о запросах формы / Form Request

Следующая Статья

CSS: Что такое color-mix() и как смешивать цвета