Сегодня поговорим о Test Driven Development, то есть о разработке, движимой тестами. О том, с чего вообще она начинается, как начать писать код, и как продолжить. Для примеров будем использовать Laravel, в этом фреймворке многое заточено под тесты, примеры наглядные и лаконичные. Поехали.
В литературе по тестированию и TDD в частности очень любят приводить в пример тест, что функция sum(a, b)
успешно складывает два числа. Сложно представить что-то более вредное для демонстрации методики. Этот пример:
- скучный — поскольку по сути ничего не проверяет и ничего не демонстрирует;
- практически бесполезный — поскольку изначально не нуждается в тестировании*;
- не представляет собой важную функцию в нашем проекте*.
* если только мы не пишем реализацию математической библиотеки для какого-либо устройства, но там тесты будут совсем другие
Попробуем сделать пример получше. Итак, чтобы удачно стартануть в проекте с TDD, нужно совершить очень важный первый шаг. Выбрать первую фичу, которую мы хотим реализовать, и написать
Первый тест
Возьмем для примера сайт skidkabudet.ru, который я упоминал недавно. Да, он не особенно сложный, но я его тоже делал по TDD, потому что мне так удобно и потому что я не хочу вручную всё постоянно проверять. В двух словах, это сайт, на который одни пользователи добавляют промоакции на товары определенной тематики. А другие — находят их и используют промокоды для покупок.
Итак, что я взял за самый первый тест. Я взял таблицу промоакций. Почему? Потому что это центральная фича сайта. Мы делаем сайт промоакций и нам нужна таблица, в которой пользователи видят все промоакции. Допустим, для каждой промоакции пользователи видят заголовок и промокод. Тогда первая фича, которую мы хотим реализовать — вывод списка промоакций.
Для этого нам понадобится модель с данными (+миграция), контроллер и вью. TDD говорит, что прежде чем написать код, надо написать тест. Значит, видимо надо написать юнит-тесты для модели, контроллера и вью, да? Наверно, это будет весело…
Нет! Первое что нужно сделать — это начать иначе думать о задаче. Мы должны научиться в данный момент не писать код, думать не о реализации, а о том, что мы хотим получить. Поэтому мы не пишем тесты модели, контроллера и вью. Более того, мы пока не знаем, что у нас будет модель, контроллер и вью. Может быть их у нас не будет. Или будут, но не все и не сейчас. Мы не знаем никаких деталей реализации, пока об их необходимости не сообщит нам тест.
Как я когда-то писал, методике TDD немного не повезло с переводом названия на русский. «Разработка через тестирование» — звучит как тесты ради тестов. Тогда как на самом деле тесты здесь — инструмент проектирования кода.
Наша разработка движима тестированием, поэтому нам нужно написать самый первый тест. Не на контроллер, который мы еще не знаем, что нужен. Не на модель данных, которая еще неизвестно какая будет. А на ту функцию сайта, на ту фичу, которую мы хотим создать.
Мы говорили о выводе списка промоакций? Сформулируем первый тест:
Пользователь может зайти на главную страницу и увидеть там список промоакций.
Код теста тогда будет выглядеть вот так (файл tests/Feature/PromoTableTest.php):
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PromoTableTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_visit_front_page_and_see_list_of_promos(): void
{
// создадим 3 промоакции, 2 с заданным заголовком и кодом, 1 с рандомным
$promos = [
Promo::factory()->create(['title' => 'Summer sale', 'code' => 'SUMMER100']),
Promo::factory()->create(['title' => 'Hello discount', 'code' => 'HELLOHELLO']),
Promo::factory()->create(), // ниже объясню почему
];
// зайдем на главную страницу
$response = $this->get('/');
// проверим код ответа
$response->assertStatus(200);
// для каждой созданной нами акции проверим что мы видим на странице заголовок и код
foreach ($promos as $promo) {
$response->assertSeeText($promo->title);
$response->assertSeeText($promo->code);
}
}
}
Обратите внимание, что у нас еще нет ни модели Promo, ни фабрики для нее, нигде не описан путь до главной страницы и какой контроллер за нее отвечает (ну, если не брать в расчет то что в laravel starter kit сгенерилось). А тест уже есть.
Код теста, как можно видеть, разделен пустыми строками на три части. Это не я придумал, это есть такая рекомендация — разделить тест на подготовку данных (мы создали промоакции), действие, которое мы проверяем (открываем главную страницу) и набор собственно проверок. Так тест легче читать и понимать.
Заметьте, что этот тест немножко интереснее чем «давайте проверим, что функция sum(a,b) возвращает a+b». Он предполагает, что у нас есть промоакции с заголовком и промокодом, что есть главная страница, и на ней промоакции, которые у нас есть — выводятся. Мы описали по сути юзер-стори: как пользователь я захожу на главную страницу и вижу список существующих на сайте промоакций.
Мы готовы теперь, так сказать, ответить за свой тестовый базар и совершить
Запуск первого теста
У нас вообще ничего не написано, чтобы он прошёл. Но нет, мы не пишем пока код под наш тест. Да, наша задача в конечном итоге — написать этот код, причем наиболее простым способом. И для этого мы запускаем тест, чтобы он нам помог, сказал, что нужно делать:
/var/www# phpunit
PHPUnit 11.5.21 by Sebastian Bergmann and contributors.
Runtime: PHP 8.3.21
Configuration: /var/www/phpunit.xml
E 1 / 1 (100%)
Time: 00:00.337, Memory: 38.50 MB
There was 1 error:
1) Tests\Feature\PromoTableTest::test_user_can_visit_front_page_and_see_list_of_promos
Error: Class "Tests\Feature\Promo" not found
/var/www/tests/Feature/PromoTableTest.php:16
Появилась ошибка, что класс Promo
не найден (на строке где мы создаем промоакции через фабрику). Вот теперь мы пишем код! Который исправляет эту ошибку и только её. Создадим модель Promo (просто пустую, без ничего) с помощью php artisan make:model Promo
и добавим в тест use App\Models\Promo
.
Запустим код еще раз: следующая ошибка скажет нам, что фабрика не найдена.
1) Tests\Feature\PromoTableTest::test_user_can_visit_front_page_and_see_list_of_promos
BadMethodCallException: Call to undefined method App\Models\Promo::factory()
Создадим фабрику, тоже пустую: php artisan make:factory PromoFactory
и добавим в класс модели (app/Models/Promo.php) использование трейта use HasFactory;
.
Теперь тест сообщит нам, что не найдена таблица promos
в нашей базе данных:
1) Tests\Feature\PromoTableTest::test_user_can_visit_front_page_and_see_list_of_promos
Illuminate\Database\QueryException: SQLSTATE[42S02]: Base table or view not found: 1146 Table 'testing.promos' doesn't exist (Connection: mysql, SQL: insert into `promos` (`title`, `code`, `updated_at`, `created_at`) values (Summer sale, SUMMER100, 2025-08-02 10:40:53, 2025-08-02 10:40:53))
/var/www/tests/Feature/PromoTableTest.php:16
Если вы заранее не настроили немножко тестовое окружение, тест сообщит вам, что база данных вообще не найдена. В phpunit.xml нужно прописать, какую СУБД использовать и какую базу данных. У меня это mysql и отдельная база данных testing. Её нужно заранее создать. Также вы можете использовать sqlite в memory mode, на запусках разовых тестов это может работать немножко быстрее, но в будущем есть вероятность столкнуться с несовместимостями. Так или иначе, пока вы не решили, какую СУБД использовать в живом проекте, просто настройте phpunit на какую-нибудь. Потом поменяете.
Создадим пустую миграцию php artisan make:migration create_promos_table
и запустим тест вновь:
1) Tests\Feature\PromoTableTest::test_user_can_visit_front_page_and_see_list_of_promos
Illuminate\Database\QueryException: SQLSTATE[42S22]: Column not found: 1054 Unknown column 'title' in 'field list' (Connection: mysql, SQL: insert into `promos` (`title`, `code`, `updated_at`, `created_at`) values (Summer sale, SUMMER100, 2025-08-02 10:48:31, 2025-08-02 10:48:31))
/var/www/tests/Feature/PromoTableTest.php:16
Тоже ошибка БД, но теперь не найдена колонка title
потому что мы в миграцию не добавили ничего. Откроем файл миграции и добавим title. Добавим и code тоже, чего уж:
Schema::create('promos', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('code');
$table->timestamps();
});
Обратите внимание, мы не делаем здесь никаких лишних движений типа задания дефолтных значений, или объявления заголовка и кода как nullable. Почему? Потому что мы пока не знаем, хотим мы их nullable или required. Мы не должны делать преждевременных предположений и писать код, который никак не проверяется нашим тестом. Мы разберемся с этим, когда дойдем до формы создания модели и её валидации. Почему не нужно писать лишний код — немного обсудим в конце статьи.
Следующий запуск:
1) Tests\Feature\PromoTableTest::test_user_can_visit_front_page_and_see_list_of_promos
Illuminate\Database\QueryException: SQLSTATE[HY000]: General error: 1364 Field 'title' doesn't have a default value (Connection: mysql, SQL: insert into `promos` (`updated_at`, `created_at`) values (2025-08-02 10:59:05, 2025-08-02 10:59:05))
У поля title нет значения по умолчанию. Мы пытаемся создать промоакцию, но не передаем title и code (это третья промоакция в тесте, без дефолтных значений). Для этого она нам и понадобилась: чтобы добавить генератор случайных значений в фабрику и знать, что он работает. Откроем database/factories/PromoFactory.php и добавим генерацию:
public function definition(): array
{
return [
'title' => $this->faker->sentence(),
'code' => $this->faker->regexify('[A-Za-z0-9]{8}'), // генерирует 8-значный случайный набор букв и цифр
];
}
Теперь мы не обязаны всегда указывать заголовок и код при создании Promo в тестах с помощью фабрики. Если не укажем, фабрика сгенерирует случайные значения.
Тут кстати есть нюанс: когда мы используем фабрику для генерации сразу нескольких моделей, например $promos = Promo::factory(5)->create();
то всё что генерирует фабрика — будет генерироваться отдельно для каждой модели. То есть все 5 промоакций будут иметь разные случайные заголовки и коды. Если же мы напишем:
$promos = Promo::factory(5)->create([
'code' => random_bytes(5),
]);
то вызов random_bytes(5)
произойдет лишь один раз, и все 5 акций получат один и тот же случайный код. В эту ловушку часто попадают генеративные нейросети, когда мы просим их писать тесты за нас.
Запускаем тест снова:
1) Tests\Feature\PromoTableTest::test_user_can_visit_front_page_and_see_list_of_promos
Expected response status code [200] but received 404.
Failed asserting that 404 is identical to 200.
/var/www/vendor/laravel/framework/src/Illuminate/Testing/TestResponseAssert.php:45
/var/www/vendor/laravel/framework/src/Illuminate/Testing/TestResponse.php:174
/var/www/tests/Feature/PromoTableTest.php:23
Кажется мы продвинулись, промоакции создались, ошибка теперь на строке, где мы проверяем код возврата ->get('/')
. То есть код, возвращаемый главной страницей. И мы видим 404 вместо 200. То есть главная страница не найдена (это если мы удалили дефолтную главную при установке Ларавела, что я рекомендую сделать для чистоты эксперимента). Добавим роут для главной страницы:
В routes/web.php:
Route::get('/', function () {
return view('home');
})->name('home');
Заметьте, что мы не создаем контроллер, а возвращаем вью прямо из анонимной функции в Route::get()
. Напоминаю, что наша задача на данном этапе разработки — пройти первый тест наиболее простым способом. Наиболее простым, а не самым элегантным, создающим сервис-контейнеры и три уровня абстракции, которые нам конечно же понадобятся через три с половиной года.
Запускаем опять. Следующая ошибка, очевидно:
View [home] not found.
Создадим такой вью (resources/views/home.blade.php), пока пустой.
Следующей ошибкой будет:
1) Tests\Feature\PromoTableTest::test_user_can_visit_front_page_and_see_list_of_promos
Failed asserting that '' [ASCII](length: 0) contains "Summer sale" [ASCII](length: 11).
То есть тест сообщает нам, что в выводе нашего пустого вью, каковой есть пустая строка, не найдено строки "Summer sale" (заголовка первой промоакции). То есть теперь нам нужно вывести собственно промоакции.
Реализуем эту логику прямо в web.php:
Route::get('/', function () {
$promos = \App\Models\Promo::all();
return view('home', compact('promos'));
})->name('home');
Опять же, мы не делаем здесь сортировку промоакций или постраничный вывод — потому что это был бы лишний нетестируемый код. Если сортировка и постраничный вывод нужны, мы их без труда выведем через соответствующие тесты (которые легко написать, скопировав текущий тест), или модифицировав наш первый тест.
Добавим вывод во вью home.blade.php:
<ul>
@foreach ($promos as $promo)
<li>{{ $promo->title }}, {{ $promo->code }}</li>
@endforeach
</ul>
Несмотря на то, что наш тест называется PromoTableTest, я не вывожу здесь таблицу чисто для простоты примера и экономии места. К тому же, стоит отметить, что некоторые практики вообще не рекомендуют применять TDD для вывода фронт-энда и конкретной html-разметки. И я с ними согласен. Потому что визуал изменчив и наличие тестов на применение конкретного тега table с конкретным классом внутри легко может обернуться обузой. Каждую правочку верстки таблицы придется сопровождать изменением тестов. Между тем, проверить, что таблица выводится нормально, можно неавтоматизированно — глазами.
Запустим тест ещё раз:
/var/www# phpunit
PHPUnit 11.5.21 by Sebastian Bergmann and contributors.
Runtime: PHP 8.3.21
Configuration: /var/www/phpunit.xml
. 1 / 1 (100%)
Time: 00:00.340, Memory: 40.50 MB
OK (1 test, 7 assertions)
Тест пройден. Поздравим нас: мы написали (ну, почти) первую фичу по методике TDD.
Обратите внимание, что мы не только решили нашу задачу, вывели список промоакций на страницу. Мы также обзавелись фабрикой для наших промоакций и в любой момент можем легко сгенерировать их сколько угодно. Ну и получили тест, который всегда может сказать — всё ли в порядке, выводятся ли промоакции по-прежнему на страницу, или что-то сломалось. И для этого нам не нужно трогать браузер, можно просто запустить тест.
А теперь сломаем тест
Да! Важный момент, о котором в угаре разработки можно забыть. Помните, как у Карла Поппера, научную теорию должно быть принципиально можно опровергнуть? С тестами такая же петрушка. Чтобы мы были уверены, что тест действительно работает, то есть на самом деле проверяет поведение программы, должна быть возможность его опровергнуть. Изменим ненадолго наш код: удалим из вью вывод промокода
<ul>
@foreach ($promos as $promo)
<li>{{ $promo->title }}, удалили промокод</li>
@endforeach
</ul>
и запустим тест:
1) Tests\Feature\PromoTableTest::test_user_can_visit_front_page_and_see_list_of_promos
Failed asserting that '\n
Summer sale, удалили промокод\n
Hello discount, удалили промокод\n
Amet voluptatem in culpa perferendis nulla vel., удалили промокод\n
\n
' [UTF-8](length: 188) contains "SUMMER100" [ASCII](length: 9).
как мы видим, тест немедленно сломался. Мы убедились, что тест проверяет наличие на странице промокода, а не какую-то ерунду. Теперь мы можем быть уверены, если вдруг промокоды из вывода куда-то пропадут — тест не даст нам пропасть вместе с ними.
Вернём всё как было и продолжим.
Стоит ли срезать дистанцию?
Можно заметить, что поставленная задача довольно простая и это склоняет нас срезать несколько поворотов и написать больше кода сразу, не запуская тест. Например, сразу создать модель, миграцию и фабрику одной строчкой:
php artisan make:model Promo -f -m
И в принципе можно. В дальнейшем вы так и будете делать, я например делаю. Но. Сначала нужно набить руку. Нужно пройти несколько раз через этот цикл TDD на простых задачах, попробовать быть движимым сообщениями теста, чтобы перестроить мышление в процессе разработки. Чтобы увидеть, что это не сложно и не очень-то долго. Чтобы заметить, что такая постановка процесса позволяет сосредоточиться на задаче, не растекаться мыслью по древу, и не писать лишний код, который потом очень вероятно придется переделать.
Выше кстати пару маленьких углов я все же срезал: там где нужно было добавить в миграцию и фабрику колонку title, я добавил сразу сразу title и code, не дожидаясь аналогичной ошибки для code. Не очень догматично, но демонстрации принципа не мешает, зато место экономит.
Результат
Как я уже заметил, задача не очень сложная. Когда рука набьется, можно писать сразу более сложный первый тест, например добавить в него, что акции отсортированы по дате, выводятся по страницам, и могут быть отфильтрованы по типу. Но это необязательно, можно двигаться и более простыми итерациями, создавая коротенькие читабельные тесты для каждого изменения. Это больше вопрос того, какой ритм вам подходит.
Также заметьте, мы не написали юнит-тесты для контроллера, модели и вью. Мы написали один feature-тест, который сразу тестирует use-case, а заодно и работу модели, роута и вью (ровно настолько, насколько мы их написали). И для отдельных юнит-тестов здесь нет никакой необходимости.
Unit-тесты могут понадобиться позже, чтобы промежуточно тестировать поведение кода. Например, если feature-тест, который мы сделали, слишком сложный, чтобы только по нему вывести весь нужный функционал. Скажем, если нам для feature-теста формы создания модели нужна валидация, и в ней требуется какое-нибудь необычное правило (например, валидация рекапчи), то под это правило имеет смысл написать отдельные unit-тесты.
Также unit-тесты годятся для скоупов и геттеров/сеттеров в моделях, которые мы можем добавить в ходе рефакторинга.
Рефакторинг

Заключительная часть цикла разработки отдельной фичи с помощью теста — это рефакторинг. Он нужен чтобы превратить наиболее простую реализацию для прохождения теста в элегантную. Смысл слова «рефакторинг» я думаю всем понятен — мы можем выделить повторяющийся код в метод, вынести работу с БД из контроллера в репозиторий, разделить длинную реализацию на несколько приватных методов, заменить статический вызов метода класса на сервис с интерфейсом… Самое главное с точки зрения TDD, чего мы здесь не делаем — мы на этапе рефакторинга не привносим новый, нетестируемый функционал и не меняем поведение кода.
И тогда рефакторинг проводить одно удовольствие, поскольку тест-то уже написан и проходит. Соответственно, нам не нужно трястись, что в процессе что-то сломается и надо будет всё заново проверять. Нужно просто чтобы тесты по-прежнему проходили. Сделали код более красивым → запустили тесты → тесты прошли → мы уверены, что закончили фичу.
Применительно к нашему простому примеру мы можем, скажем, перенести код из анонимной функции в Route::get() в контроллер, или же преобразовать вью home.blade.php в компонент Livewire Volt. Результат этого рефакторинга по-прежнему будет удовлетворять нашему тесту.
После чего можно сделать что? Правильно, выбрать следующую фичу и написать тест к ней. Например, скопировать первый тест и добавить в него параметры для фильтрации акций по регионам и типу. И так далее.
Наш тест, рассмотренный выше, стал первым, но поскольку следующие тесты первыми уже быть не могут, надо их как-то называть. Я обычно называю тест, который выводит нам очередную фичу или изменение, опорным тестом.
Полезные побочные эффекты
Выше мы рассмотрели методику TDD, то есть набор действий по проектированию нашего кода при помощи тестов. Ниже, вместо заключения, я приведу некоторые полезные плюшки, которые мы получаем вместе с методикой.
Тестовое покрытие
Очевидный эффект — наш код покрыт тестами, то есть он стал более поддерживаемым. Лично я, применяя данную методику, считаю тестовое покрытие именно побочным эффектом, а не самоцелью. Однако разве это не здорово, что в результате мы получаем добротный набор тестов, который можно например подвесить на CI/CD пайплайн, когда нам этого захочется? Конечно, здорово.
Меньше лишних деталей
Нам необязательно (в отличие от разработки без тестов, кстати) иметь возможность создавать и редактировать промоакции, чтобы получить работающую таблицу. Мы можем не думать, как вообще мы их будем создавать, пока не подойдем к этой реализации. Вбивать в админке? Импортировать из csv-файла? Из API загружать? Всё возможно и на наш первый тест это никак не повлияет.
Нам вообще говоря необязательно даже живую базу данных иметь. Как там Роберт Мартин завещал? Следует отложить выбор того или иного конкретного решения до того, как оно нам понадобится. А тесты можно и на sqlite в memory mode запускать. Так будет кстати быстрее, когда запускаем тесты по одному (но есть нюанс), а нам это сейчас и надо.
Меньше ручной работы с БД
Благодаря этому мы кстати можем сэкономить время на разработке схемы БД. Если мы отринем тесты и будем проверять нашу работу простым дедовским методом — написал, открыл ПО, посмотрел — то нам придется сразу сделать БД и сразу запускать миграции. И когда мы поймем, что нужно добавить, изменить, удалить колонку из таблицы модели — писать еще миграцию, и еще, и еще (ну или откатывать и заново генерировать тестовые данные).
С тестами мы можем этого не делать, мы можем менять одну и ту же миграцию сколько захотим, потому что она все равно переустанавливается при каждом запуске. И так пока мы не скажем: ок, я написал достаточно функционала пойти посмотреть как это выглядит живьем. Так что такая разработка еще и экономит время на необходимость вручную что-то перевбивать каждый раз, когда захотелось проверить работу кода во время реализации той или иной задачи.
Проще создавать API
А если мы пишем API для обращения с фронта, то тесты на мой взгляд вообще идеальны. Незачем подтаскивать еще один инструмент типа постмана и через него дергать роуты, если $this->json('post', '/myroute', [$data])
в тесте — это отличный, лаконичный способ сделать то же самое. Который к тому же говорит с нами в терминах нашей программы, а не постмана.
Папка с feature-тестами — это чертёж нашего ПО
Набор тестов — это еще и инструкция к нашему программному обеспечению. Как можно заметить, наш первый тест — это по сути user story. И если мы не будем лениться, а дадим нашим тестам длинные исчерпывающие названия, то просто читая списки тестов можно получить неплохое представление о том, что наша программа должна уметь делать.
Уверенность в завтрашнем дне :)
Когда у нас есть набор тестов, изменения в коде даются гораздо легче, как большие, так и малые. Многие думаю сталкивались с тем, что нужно добавить или изменить одну функцию, а она в свою очередь меняет поведение программы сразу в нескольких местах. 5 из них очевидны и мы исправили сразу. 3 — нашли в ходе ручной проверки фичи. А еще 2 оказались полной неожиданностью и превратились в баги, из-за которых мы остались на работе ночевать.
Кстати, это ответ на вопрос почему нельзя писать лишний, нетестируемый код, когда мы «проходим» наш опорный тест. Потому что тогда этот код будет сиротливо болтаться в нашей программе, грустный и никому не нужный. А еще потому, что когда придет время его изменить — у нас не будет опорного теста, чтобы проверить, на что влияет наше изменение. И, соответственно, не будет никакой уверенности, что это изменение не повлечет проблем.
Хороший набор тестов сразу подсветит всё что сломается при наших изменениях. В дальнейшем попробую опубликовать пример достаточно серьезного изменения в проекте, которое удалось разрешить за несколько часов именно благодаря тестам.
Понятно, это не серебряная пуля, и полностью от багов не избавит, иначе наша работа была бы слишком простой. Но разница, на мой взгляд так драматична, что в наше время не писать тесты легкомысленно. А если писать — то попробуйте с TDD.