Слово тестирование испортили и изуродовали. Потому что часто под этим подразумевают ручную проверку, что задача, которую делал разработчик, работает как задумано после изменений. Это не то, что нужно разработчику — нам нужен контроль качества.
Многие до сих пор думают, что тестированием занимается кто-то другой: тестировщик, QA-инженер, автоматизатор или великий господин начальник. Это не так.
Ты — разработчик, и именно ты становишься первым тестировщиком своего кода. Ты запускаешь приложение, проверяешь, что оно работает, затем вносишь изменения и снова убеждаешься, что всё работает как прежде. А тест — это первый клиент твоего кода.
В мире тестирования есть много терминов: интеграционные, e2e, smoke, UI, acceptance, snapshot, regression… Становится страшно даже начинать перечислять. Но непосредственно на качество кода влияет только один вид тестов — юнит-тесты.
Почему? Потому что они:
- Проверяют маленькие части кода, например функции, методы, классы.
- Работают быстро.
- Легко читаются и поддерживаются.
- Заставляют твой код быть тестируемым, а значит — аккуратным и логичным.
О чём я говорю? Как это связано с разработкой?
Допустим, у нас есть endpoint, который должен вернуть фазу Луны на определённую дату.
Мы можем написать feature-тест, который проверит, что функция, вычисляющая фазу Луны, работает корректно.
В котором мы обратимся по url-адресу /api/moon?date=2025-06-01, получим ответ и проверим, что он соответствует ожидаемому
значению.
public function test_returns_moon_phase_data(): void
{
$response = $this->get('/api/moon', [
'date' => '2025-06-01',
])
->assertOk()
->assertJsonStructure([
'age',
'phase',
'distance',
'nextNewMoon'
])
->json();
[$age, $phase] = $response;
$this->assertEquals(13.8, round($age, 1));
$this->assertEqualsWithDelta(0.47, $phase, 0.01);
}Это хороший тест, который проверяет, что API возвращает правильные данные для известной даты. Он проверяет, что ответ содержит нужные поля и что значения в них соответствуют ожидаемым.
Но как именно работает функция, вычисляющая фазу Луны? Как она получает данные о Луне? Как она обрабатывает дату? И вроде бы всё хорошо, но это обманка. Такой тест ничего не говорит о логике внутри. Он — витрина. Он проверяет фасад, но не фундамент. Он проверяет только конечный результат, это должно быть как вишенка на торте, а не основа.
Мы можем написать прямо в контроллере и добавить туда с десяток функций, которые будут вызывать другие функции, и в итоге получим правильный ответ. Но это не лучший подход.
Вместо этого лучше сосредоточиться на написании как можно большего числа тестов, которые проверяют поведение отдельных компонентов в изоляции.
А для этого вам потребуется использовать объекты:
// Хорошо [✓]
public function test_moon_phase_for_known_date(): void
{
$date = new DateTimeImmutable('2025-06-01');
$moon = new MoonPhase($date);
// Проверяем округлённые значения
$this->assertEquals(13.8, round($moon->age, 1));
$this->assertEqualsWithDelta(0.47, $moon->phase, 0.01);
}Такой подход заставляет вас писать код, который легко проверить и переиспользовать.
Вы отделяете логику расчёта (в классе MoonPhase) от внешних интерфейсов (контроллеров, команд, CLI, API), и это делает
код переносимым и модульным.
И самое важное: теперь никто не сможет просто так "вставить" бизнес-логику в контроллер — просто потому что она уже вынесена в объект, и её поведение зафиксировано тестами.
Тесты — это не только проверка правильности работы кода, но и инструмент, который помогает поддерживать симметрию и согласованность между компонентами, особенно когда они тесно связаны.
Например, в сервисе погоды могут быть два класса — экспортёр и импортёр исторических данных:
$exporter = new WeatherHistoryExporter();
$exporter->export('/tmp/weather.zip');
$importer = new WeatherHistoryImporter();
$importer->import([
'devices' => '/tmp/weather/devices.xml',
'locations' => '/tmp/weather/locations.xml',
'readings' => '/tmp/weather/readings.xml',
]);В обычном коде вызовы этих классов часто разбросаны по разным частям приложения, и никто не замечает, что результат экспорта не подходит для импорта.
Если же написать тест, объединяющий эти сценарии в единый процесс, сразу становится очевидно, что экспорт и импорт — два конца одного процесса, и между ними должна быть полная совместимость.
Такой тест заставляет думать не только о том, что делает каждый отдельный компонент, но и о том, как классы на разных точках входа взаимодействуют, какой контракт между ними.
Это помогает сделать архитектуру цельной, логичной и поддерживаемой.
Тесты перестают быть просто проверкой — они становятся инструментом поддержания архитектурной дисциплины. Когда это понимаешь, начинаешь писать код иначе — так, чтобы все части системы были симметричны и идеально подходили друг к другу.
Разделяйте тест на три логических фазы:
- Arrange. Подготовьте данные, объекты и окружение.
- Act. Выполните единственное действие — метод, который тестируете.
- Assert. Убедитесь, что результат совпадает с ожиданием (одно утверждение = одно тестовое поведение).
public function test_something(): void
{
// Arrange: подготовка данных
$obj = new MyClass(...);
// Act: выполнение действия
$result = $obj->doWork();
// Assert: проверка результата
$this->assertTrue($result->isSuccessful());
}Если с выполнением действия и проверкой результата всё понятно, то с подготовкой данных могут быть нюансы. Подготовка данных — это главная часть теста, и она должна быть максимально простой и понятной.
Например, если вы тестируете метод, который работает с базой данных, то вам нужно создать необходимые записи в базе. Но не нужно создавать всю базу целиком, достаточно только тех записей, которые нужны для теста.
Есть несколько способов, как организовать подготовку данных. Например, определить заранее записи в базе данных, которые бы записывались перед исполнением теста.
users:
- id: 1
name: Иван Иванов
email: ivan.ivanov@example.com
password: '$2y$10$e0NRDUE8...'
created_at: 2024-05-01 10:00:00
updated_at: 2024-05-01 10:00:00
- id: 2
name: Мария Петрова
email: maria.petrova@example.com
password: '$2y$10$Fjs98JDk...'
created_at: 2024-05-02 12:30:00
updated_at: 2024-05-02 12:30:00Это заставляет нас каждый раз возвращаться к этому файлу и обновлять его, когда мы добавляем новые поля в модель. При написании теста нам нужно сначала создать эти записи, а потом уже использовать их в тестах. Мы не знаем, а точно ли используется пользователь #2 в проекте или мы просто забыли удалить его из этого файла.
Тогда в тесте будет выглядеть примерно так:
// Плохо [✗]
public function test_something(): void
{
$user = User::find(2);
}Кроме того, мы не видим никаких подробностей пользователя, которого мы используем в тесте. Мы не знаем, что это за пользователь, какие у него данные и зачем он нужен.
Лучше всего, чтобы подготовка была максимально близка к тестируемому коду. Например, если мы тестируем метод, который работает с пользователем, то лучше всего создать пользователя прямо в тесте:
// Хорошо [✓]
public function test_something(): void
{
$user = User::factory()
->withPassword('password123')
->create();
}Тесты должны зеркалировать структуру вашего приложения. Это поможет вам быстро находить нужные тесты и понимать, что они проверяют.
tests/
├─ Unit/
│ └─ MoonPhaseTest.php
└─ Feature/
└─ MoonPhaseTest.phpПри работе с тестами иногда возникает неприятная ситуация: один тест проходит только в том случае, если он выполняется сразу после другого. Если поменять порядок запуска — тест ломается. Это явный признак того, что тесты зависят друг от друга.
Надёжные тесты должны быть независимы — их результат не должен зависеть ни от порядка выполнения, ни от состояния, оставленного другими тестами. Другими словами, каждый тест должен запускаться «с чистого листа», независимо от остальных.
Отличный способ обнаружить скрытые зависимости — запускать тесты в случайном порядке. Если при таком запуске какой-то тест начинает падать, значит, он опирается на предыдущие тесты, и с этим необходимо разобраться.
PHPUnit и Laravel поддерживают специальный флаг для случайного порядка --order-by=random
# Для Laravel
php artisan test --order-by=random
# Для Laravel Dusk
php artisan dusk --order-by=random
# Для PHPUnit
vendor/bin/phpunit --order-by=randomПопробуйте запустить свои тесты в случайном порядке и посмотрите, есть ли у вас зависимые тесты.
Ещё лучше добавьте атрибут executionOrder в конфигурационный файл, чтобы запуск тестов в случайном порядке был по
умолчанию.
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
executionOrder="random"
>Тесты могут успешно выполняться и радовать зелёным цветом. Но есть важная деталь — ничего не забыть. Допустим, в классе есть метод с условием:
if ($user->isPremium()) {
// ...
}На него уже написан тест, и он проходит. Но что, если isPremium() в тесте всегда возвращает false?
Код внутри условия никогда не выполняется — и вы об этом даже не узнаете.
Чтобы понять, какие строки действительно исполнялись, нужна статистика покрытия. Обычно её можно получить с помощью тех же инструментов, например:
php vendor/bin/phpunit --coverage-html coverage/Открыв coverage/index.html, видно, какие строки проекта покрыты тестами.
- Зелёные — хорошо, код выполнен.
- Красные — плохо, код вообще не исполнялся.
- Жёлтые — частично, например, сработала только одна ветка
if.
Покрытие не говорит, насколько хороши тесты, но сразу показывает, где их точно нет.
Использование sleep() в тестах — это признак отсутствия контроля над поведением системы.
Вместо того чтобы управлять процессом и делать тесты предсказуемыми, ты просто надеешься, что всё «само как-нибудь
успеет».
Это не разработка, а угадайка.
Рассмотрим пример:
// Плохо [✗]
public function test_email_is_sent(): void
{
$this->dispatch(new SendEmailJob($user));
sleep(3); // надеемся, что задача обработается
$this->assertDatabaseHas('emails', [
'user_id' => $user->id
]);
}Что здесь происходит? Вместо того чтобы изолировать логику, использовать моки или запустить код синхронно, тест просто делает паузу и надеется на удачу. Это создаёт ложное ощущение стабильности, а на деле скрывает нестабильность и делает тесты хрупкими.
Такие тесты зависят от внешних факторов: загрузки системы, скорости обработки очередей, состояния базы данных.
Результат становится непредсказуемым — сегодня тест успешен, завтра падает без очевидной причины.
Кроме того, sleep() замедляет весь процесс автоматической проверки, увеличивая время запуска тестов.
Гораздо лучше — замокать очередь (или шину команд) и проверить, что нужная задача была отправлена:
// Лучше [✓]
public function test_email_is_dispatched(): void
{
Bus::fake();
$this->dispatch(new SendEmailJob($user));
Bus::assertDispatched(
SendEmailJob::class,
fn($job) => $job->user->id === $user->id
);
}Такой мок не только для очередей работает. Аналогично можно мокать HTTP-запросы, внешние сервисы, события — везде, где важно проверить, что вызов произошёл.
Если обязательно нужно проверить побочный эффект, не жди фиксированное время — жди по условию. Например, для асинхронного обновления:
// Плохо [✗]
public function test_user_status_updated(): void
{
$this->externalApi()->newUser($user);
sleep(5); // надеемся, что данные обработаются за это время
$this->assertEquals(
'processed',
$this->externalApi()->status($user->id)
);
}Гораздо лучше реализовать проверку с повторным опросом, которая ждёт изменения состояния в течение заданного таймаута:
// Лучше [✓]
public function test_user_status_updated(): void
{
$this->externalApi()->newUser($user);
$this->waitUntil(function () {
return $this->externalApi()
->status($user->id) === 'processed';
}, 10);
}Где waitUntil — метод, который опрашивает условие с интервалом, пока оно не станет истинным или не выйдет таймаут.