Laravel: Как начать тестировать приложение

Источник: «Learn how to start Testing in Laravel with Simple Examples using PHPUnit and PEST»
Из этой статьи вы узнаете, как легко начать автоматизированное тестирование в Laravel.

Когда говорят об автоматизированных тестах или модульных тестах на любом языке программирования, люди делятся на две группы:

Итак, в этой статье я попытаюсь свой бывший лагерь взглянуть на другую сторону и увидеть преимущества, и как легко начать с автоматизированного тестирования в Laravel.

Сначала поговори о зачем, а затем я покажу несколько простых примеров как.

Зачем нужны автоматизированные тесты

Автоматизированные тесты не сложны: они просто запускают части вашего кода и сообщают обо всех ошибках. Это самый простой способ описать их. Представьте, что вы запускаете новый функционал в своём приложении, а затем личный робот-помощник приедет и вручную протестирует её для вас, а также проверит, не нарушает новый код что-либо из старых функций.

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

С каждой новой функцией вашего приложения автоматизированные тесты окупаются всё больше и больше.

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

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


Первые автоматизированные тесты

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

Можно попробовать установить laravel проект и сразу запустить первые тесты:

laravel new project
cd project
php artisan test

Такой результат вы увидите в консоли:

 PASS  Tests\Unit\ExampleTest
✓ that true is true

PASS Tests\Feature\ExampleTest
✓ the application returns a successful response

Tests: 2 passed
Time: 0.10s

Если посмотреть папку в проекте Laravel /tests по умолчанию, в ней будет два файла.

tests/Feature/ExampleTest.php:

class ExampleTest extends TestCase
{
public function test_the_application_returns_a_successful_response()
{
$response = $this->get('/');

$response->assertStatus(200);
}
}

Не нужно знать какой-либо синтаксис, чтобы понять, что здесь происходит: загрузка домашней страницы и проверка, равен ли код состояния HTTP — 200 OK.

Обратите внимание, как имя метода test_the_application_returns_a_successful_response() становится читаемым текстом при просмотре результатов теста, просто заменяя символы подчёркивания пробелом.

tests/Unit/ExampleTest.php:

class ExampleTest extends TestCase
{
public function test_that_true_is_true()
{
$this->assertTrue(true);
}
}

Это выглядит немного бессмысленно, проверять, что true это true? Конкретно о модульных тестах мы поговорим чуть позже. А пока вам нужно понять, что обычно происходит в каждом тесте.

Структурно это всё, что нужно знать, остальное зависит от того, что именно вы хотите протестировать.

Для генерации пустого тестового класса, просто запустите команду artisan:

php artisan make:test HomepageTest

Вы создадите файл tests/Feature/HomepageTest.php:

class HomepageTest extends TestCase
{
// Замените этот метод на свой
public function test_example()
{
$response = $this->get('/');

$response->assertStatus(200);
}
}

Что делать если тесты провалены?

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

Давайте отредактируем примеры тестов следующим образом:

class ExampleTest extends TestCase
{
public function test_the_application_returns_a_successful_response()
{
$response = $this->get('/non-existing-url');

$response->assertStatus(200);
}
}


class ExampleTest extends TestCase
{
public function test_that_true_is_false()
{
$this->assertTrue(false);
}
}

И если запустим php artisan test снова:

 FAIL  Tests\Unit\ExampleTest
⨯ that true is true

FAIL Tests\Feature\ExampleTest
⨯ the application returns a successful response

---

• Tests\Unit\ExampleTest > that true is true
Failed asserting that false is true.

at tests/Unit/ExampleTest.php:16
12▕ * @return void
13▕ */
14▕ public function test_that_true_is_true()
15{
16$this->assertTrue(false);
17}
18}
19

• Tests\Feature\ExampleTest > the application returns a successful response
Expected response status code [200] but received 404.
Failed asserting that 200 is identical to 404.

at tests/Feature/ExampleTest.php:19
15▕ public function test_the_application_returns_a_successful_response()
16{
17$response = $this->get('/non-existing-url');
18
19$response->assertStatus(200);
20}
21}
22


Tests: 2 failed
Time: 0.11s

Как видите, есть два утверждения, помеченных как FAIL с пояснениями и стрелками, указывающими на точную тестовую строку, которая не прошла утверждение. Вот так и отображаются ошибки. Удобно, не так ли?

Простой реальный пример: Регистрационная форма

Давайте перейдём к практике и рассмотрим реальный пример. Представьте, что у вас есть форма, и вам нужно протестировать различные случаи: проверить, не сработает ли она при заполнении неверными данными, проверить будет ли она успешной при правильном вводе и т.д.

Знаете ли вы, что официальный стартовый набор Laravel Breeze поставляется с функциональными тестами? Итак, давайте взглянем на несколько примеров оттуда:

tests/Feature/RegistrationTest.php:

use App\Providers\RouteServiceProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class RegistrationTest extends TestCase
{
use RefreshDatabase;

public function test_registration_screen_can_be_rendered()
{
$response = $this->get('/register');

$response->assertStatus(200);
}

public function test_new_users_can_register()
{
$response = $this->post('/register', [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
]);

$this->assertAuthenticated();
$response->assertRedirect(RouteServiceProvider::HOME);
}
}

У нас два теста в одном классе, так как они оба связаны с регистрационной формой: один проверяет правильность загрузки формы, а другой проверяет, хорошо ли работает отправка.

Два новых утверждения проверки результата: $this->assertAuthenticated() и $response->assertRedirect(). Вы можете посмотреть доступные утверждения в документации PHPUnit и Laravel Response. Имейте в виду, что некоторые общие утверждения выполняются для объекта $this, в то время как другие проверяют конкретный ответ $response из вызова маршрута.

Ещё одна важная вещь выражение трейта use RefreshDatabase;, включённое в начале класса. Оно необходимо, когда ваши тестовые действия могут повлиять на базу данных, как в этом примере, регистрация добавляет новую запись в таблицу users базы данных. Для этого вам потребуется создать отдельную тестовую базу данных, которая будет обновляться с помощью команды php artisan migrate:fresh каждый раз при выполнении тестов.

У вас есть два варианта: физически создать отдельную базу данных или использовать базу данных SQLite в памяти. Оба они настроены в файле phpunit.xml, который по умолчанию поставляется с Laravel. В частности, вам нужна эта часть:

<php>
<env name="APP_ENV" value="testing"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_DRIVER" value="array"/>
<!-- <env name="DB_CONNECTION" value="sqlite"/> -->
<!-- <env name="DB_DATABASE" value=":memory:"/> -->
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
</php>

Видите закомментированные DB_CONNECTION и DB_DATABASE? Если на вашем сервере есть SQLite, самое простое действие — просто раскомментировать эти строки, и ваши тесты будут выполняться в этой базе данных в памяти.

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

В дополнение к этому коду:

$this->assertAuthenticated();
$response->assertRedirect(RouteServiceProvider::HOME);

Мы также можем использовать утверждения Тестирования Базы Данных и сделать что-то вроде этого:

$this->assertDatabaseCount('users', 1);

// Или...
$this->assertDatabaseHas('users', [
'email' => 'test@example.com',
]);

Другой реальный пример: Форма входа/логина

Давайте посмотрим на ещё один тест Laravel Breeze.

tests/Feature/AuthenticationTest.php:

class AuthenticationTest extends TestCase
{
use RefreshDatabase;

public function test_login_screen_can_be_rendered()
{
$response = $this->get('/login');

$response->assertStatus(200);
}

public function test_users_can_authenticate_using_the_login_screen()
{
$user = User::factory()->create();

$response = $this->post('/login', [
'email' => $user->email,
'password' => 'password',
]);

$this->assertAuthenticated();
$response->assertRedirect(RouteServiceProvider::HOME);
}

public function test_users_can_not_authenticate_with_invalid_password()
{
$user = User::factory()->create();

$this->post('/login', [
'email' => $user->email,
'password' => 'wrong-password',
]);

$this->assertGuest();
}
}

Речь идёт о форме входа/логина. Логика аналогична регистрации, верно? Но три метода вместо двух, так что этот пример тестирования как для удачных, так и не удачных сценариев входа. Итак, общая логика заключается в том, что вы должны тестировать оба случая: когда всё идёт хорошо, и когда нет.

Кроме того, в этом тесте вы видите использование Фабрик Баз Данных: Laravel создаёт фейкового пользователя (опять в вашей обновлённой тестовой базе данных), а затем пытается войти в систему с правильными и неправильными учётными данными.

Опять, Laravel из коробки генерирует фабрику по умолчанию с фейковыми данными для модели User.

database/factories/UserFactory.php:

class UserFactory extends Factory
{
public function definition()
{
return [
'name' => $this->faker->name(),
'email' => $this->faker->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
'remember_token' => Str::random(10),
];
}
}

Видите сколько вещей подготавливает Laravel, чтобы нам было легко начать тестирование?

Итак, если мы запустим php artisan test после установки Laravel Breeze, мы должны увидеть что-то вроде этого:

PASS  Tests\Unit\ExampleTest
✓ that true is true

PASS Tests\Feature\Auth\AuthenticationTest
✓ login screen can be rendered
users can authenticate using the login screen
users can not authenticate with invalid password

PASS Tests\Feature\Auth\EmailVerificationTest
✓ email verification screen can be rendered
✓ email can be verified
✓ email is not verified with invalid hash

PASS Tests\Feature\Auth\PasswordConfirmationTest
✓ confirm password screen can be rendered
✓ password can be confirmed
✓ password is not confirmed with invalid password

PASS Tests\Feature\Auth\PasswordResetTest
✓ reset password link screen can be rendered
✓ reset password link can be requested
✓ reset password screen can be rendered
✓ password can be reset with valid token

PASS Tests\Feature\Auth\RegistrationTest
✓ registration screen can be rendered
✓ new users can register

PASS Tests\Feature\ExampleTest
✓ the application returns a successful response

Tests: 17 passed
Time: 0.61s

Функциональные тесты vs Модульные тесты vs Другие тесты

Вы видели подкаталоги tests/Feature и tests/Unit. В чём разница между ними? Ответ немного философский.

Глобально, за пределами экосистемы Laravel/PHP существуют различные виды автоматических тестов. Вы можете встретить такие термины как:

Звучит сложно, и фактически различия между этими тестами иногда размыты. Вот почему Laravel упростил все эти запутанные термины и сгруппировал их в две группы: модульные и функциональные тесты.

Проще говоря, функциональные тесты пытаются запустить реальные функции вашего приложения: получить URL-адрес, вызвать API, имитировать точное поведение, например заполнение формы. Функциональные тесты обычно выполняют то же самое или подобное тому, что любой пользователь проекта делал бы вручную в реальных условиях.

Модульные тесты имеют два значения. Как правило, вы можете обнаружить, что любые автоматизированные тесты называются модульными тестами, а весь процесс можно назвать модельным тестированием. Но в контексте функционала и модуля этот процесс заключается в тестировании конкретной непубличной единицы вашего кода изолированно. Например, у вас есть какой-то Laravel класс с методом, который что-то вычисляет, например, общую стоимость заказа с параметрами. Таким образом, ваш модульный тест будет утверждать возвращаются ли правильные результаты этим методом (модулем кода) с другими параметрами.

Для генерации модельного теста нужно добавить флаг:

php artisan make:test OrderPriceTest --unit

Сгенерированный код точно такой же, как и модульный тест от Laravel по умолчанию:

class OrderPriceTest extends TestCase
{
public function test_example()
{
$this->assertTrue(true);
}
}

Как видите, в этом случае нет RefreshDatabase, и это одно из самых распространённых определений юнит-теста: он не касается базы данных, он работает как чёрный ящик, изолировано от приложения.

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

app/Services/OrderPriceService.php:

class OrderPriceService
{
public function calculatePrice($productId, $quantity, $tax = 0.0)
{
// Какая-то логика вычислений
}
}

Тогда модульный тест может выглядеть примерно так:

class OrderPriceTest extends TestCase
{
public function test_single_product_no_taxes()
{
$product = Product::factory()->create(); // создаём фейковый продукт
$price = (new OrderPriceService())->calculatePrice($product->id, 1);
$this->assertEquals(1, $price);
}

public function test_single_product_with_taxes()
{
$price = (new OrderPriceService())->calculatePrice($product->id, 1, 20);
$this->assertEquals(1.2, $price);
}

// Больше случаев с большим количеством параметров
}

По моему опыту работы с Laravel проектами, абсолютное большинство тестов — это функциональные тесты, а не модульные (юнит) тесты. Во-первых, вам нужно проверить, работает ли ваше приложение, как его будет использовать реальные люди.

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

Иногда для написания тесто требуется изменение самого кода и его рефакторинг, чтобы сделать его более тестируемым: разделение модулей на специальные классы или методы.

Когда/как запускать тесты?

Каково фактическое использование этого php artisan test, когда нужно его запускать?

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

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

Если мы сделаем ещё один шаг, можно автоматизировать многие вещи. С помощью различных инструментов CI/CD вы можете указать, что ваши тесты должны выполняться каждый раз, когда кто-то отправляет изменения в конкретную ветку Git или перед слиянием кода в рабочую ветку. Самый простой способ — использовать Github Actions, у меня есть отдельное видео, демонстрирующее это.

Что нужно тестировать?

Существуют разные мнения на сколько большим должно быть так называемое тестовое покрытие: следует ли тестировать каждую операцию и каждый возможный случай на каждой странице или просто ограничить свою работу наиболее важными частями.

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

Другими словами, вам нужно расставить приоритеты в своих усилиях по тестированию с вопросом Что произойдёт, если этот код не сработает? Если в вашей платёжной системе есть ошибки, это напрямую повлияет на бизнес. Затем, если функции ролей/разрешений нарушены, это огромная проблема безопасности.

Мне нравиться, как Мэтт Штауффер сформулировал это на одной из конференций: Сначала нужно протестировать те вещи, которые, если они не пройдут, то приведут к увольнению с работы. Конечно, это преувеличение, но вы сейчас поняли: сначала проверьте важные вещи. А потом и другой функционал, если у вас есть на это время.

PEST: Новая альтернатива PHPUnit

Все приведённые выше примеры основаны на стандартном инструменте тестирования Laravel: PHPUnit. Но с годами в экосистеме появлялись и другие инструменты, и одним из последних популярных является PEST. Созданный официальным сотрудником Laravel Нуно Мадуро, его задача упростить синтаксис, чтобы ещё быстрее писать код для тестов.

Под капотом он работает поверх PHPUnit в качестве дополнительного уровня, просто пытаясь минимизировать некоторые повторяющиеся части кода PHPUnit по умолчанию.

Давайте посмотрим на пример. Помните тестовый класс Feature по умолчанию в Laravel? Я напомню вам:

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ExampleTest extends TestCase
{
public function test_the_application_returns_a_successful_response()
{
$response = $this->get('/');

$response->assertStatus(200);
}
}

Знаете, как выглядит тот же тест в PEST?

test('the application returns a successful response')->get('/')->assertStatus(200);

Да, ОДНА строка кода, и всё. Итак, цель PEST — убрать лишнее:

Для генерации PEST теста в Laravel нужно указать дополнительный флаг:

php artisan make:test HomepageTest --pest

На момент написания этой статьи PEST был довольно популярен среди разработчиков Laravel, но вам решать, использовать ли этот дополнительный инструмент и изучать его синтаксис в дополнение к хорошо известному PHPUnit.


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

Более подробную информацию можно получить в официальной документации Laravel по тестированию.

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

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

Laravel: Применяем принципы SOLID

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

Laravel: Использование логов для отладки API