rzaripov.kz

Как я организовал модульное тестирование в PHP-проекте с PHPUnit

Я Равиль Зарипов, разработчик из Алматы. В PHP-проекте, который постепенно рос вместе с продуктом, модульные тесты появились не как отдельный этап, а как способ контролировать изменения в ядре. Я выбрал PHPUnit и поставил задачу проверять каждое правило изолированно, без полного запуска веб-приложения. Такой подход быстро показал, какие классы связаны с хранилищем, очередями и внешними сервисами.

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

Для тестового окружения я использовал Composer, автозагрузку PSR-4 и отдельный каталог tests. Команда запускалась через скрипт Composer, поэтому разработчики не копировали параметры PHPUnit вручную. Все тесты располагались рядом с проверяемыми областями, хотя физически находились в самостоятельном дереве и не попадали в production-сборку.

Главным принципом стало правило: если проверка требует работающей базы данных, сетевого запроса или очереди, она probably不属于 модульной. Такие сценарии я переносил в интеграционный набор. Благодаря этому набор модульных тестов оставался быстрым и давал понятный результат ещё до развёртывания приложения.

Сначала я определил границы модулей

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

Один тестовый метод проверял одно наблюдаемое поведение. Внутри я использовал последовательность «подготовка, действие, проверка»: создавал исходные данные, вызывал нужную операцию и сравнивал результат с ожидаемым. Несколько ожидаемых значений для одного правила оформлялись отдельным тестом или параметризованным сценарием, чтобы сбой сразу показывал конкретный набор входных данных.

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

Тестовые данные стали отдельным слоем

Для создания объектов я использовал фабрики и builders, чтобы тесты не содержали длинные цепочки конструкторов. Фабрика отвечала за корректные значения по умолчанию, а builder позволял изменить только значимый параметр. Благодаря этому изменение обязательного поля модели не приводило к исправлению сотни тестовых методов.

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

Данные, которые невозможно было сформировать в памяти, я вынес в интеграционные тесты. Там применялась отдельная тестовая база, очистка состояния и транзакции. Общие рецепты подготовки записей я храню в каталоге PHP-материалов, чтобы не смешивать их с assertions модульных тестов и не переносить инфраструктурные детали в проверки бизнес-логики.

Зависимости изолировались через подмену

Вместо создания зависимостей внутри бизнес-класса я передавал их через конструктор. Это давало возможность подставить обычный объект для модульного теста и настоящий adapter для интеграционного сценария. Контракты зависимостей описывались интерфейсами, поэтому тесты не знали о конкретной базе, HTTP-клиенте или файловом хранилище.

В зависимости от проверки я использовал разные тестовые двойники. Stub возвращал заранее заданный ответ, mock проверял наличие вызова и его аргументы, fake накапливал состояние, а spy записывал события. Выбор делался по смыслу assertions: если важно было узнать, отправлено ли уведомление, достаточно было проверить сохранённое событие, не создавая сложную цепочку ожиданий.

Я также следил за количеством моков. Если тест требовал настроить десять методов, вероятно, проверяемая операция имела слишком широкую зону ответственности. В таких случаях я выделял отдельный объект, упрощал сигнатуру или переносил часть сценария на следующий уровень. Цель изоляции состояла в создании ясного контракта, а не в полном исключении любых вызовов.

PHPUnit запускался одинаково локально и в CI

Настройки тестового запуска я хранил в phpunit.xml. Файл задавал bootstrap, каталоги suites, подключение переменных окружения и параметры отчёта о покрытии кода. Для разных наборов использовались отдельные группы: быстрые модульные тесты запускались при каждом изменении, а интеграционные выполнялись по расписанию или перед выпуском.

Базовые сценарии наследовались от PHPUnit\Framework\TestCase. Общая подготовка находилась в setUp(), а очистка временных ресурсов — в tearDown(). Повторяющиеся входные данные передавались через data providers, чтобы один тестовый метод сохранял отдельную причину сбоя для каждого набора. Assertions выбирались по проверяемому типу: точное сравнение, проверка диапазона или отсутствие значения.

Локально тесты запускались одной командой composer test, а в CI использовалась та же конфигурация. Сервер выполнял быстрый набор, сохранял отчёты и отмечал сборку как неуспешную при любой ошибке. Одинаковые параметры уменьшали различие между результатом разработчика и результатом автоматической проверки, а также исключали ситуацию, когда непроверенный тест случайно считался успешным.

Покрытие помогало видеть риски, а не гнаться за цифрой

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

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

Регулярный просмотр тестов выявил ещё одну пользу модульного подхода. Одинаковые входные данные и явные ожидания показывали, где бизнес-логика меняет смысл, а название метода расходится с его назначением. В итоге PHPUnit стал выполнять две задачи: быстро сообщать о регрессиях и удерживать структуру кода в состоянии, в котором каждое правило можно проверить независимо от остального приложения.