PHPUnit и тестирование PHP
PHPUnit и тестирование PHP
Когда вы меняете код в проекте, легко случайно сломать то, что уже работало. Автотесты — это программы, которые сами проверяют поведение вашего кода: запускаются одной командой в терминале или на сервере CI и сообщают "всё ок" или "здесь ошибка".
PHPUnit — стандартный фреймворк для таких проверок в PHP. Он пришёл из семейства xUnit (как JUnit в Java) — вы пишете классы-тесты, внутри — методы с утверждениями (assert*), PHPUnit сравнивает фактический результат с ожидаемым.
Pest — тонкая обёртка поверх PHPUnit с более коротким синтаксисом (похож на Jest в JavaScript). Под капотом всё равно PHPUnit.
В Laravel и Symfony тесты уже встроены в скелет проекта — остаётся написать сценарии под свою логику.
Обзор экосистемы: Экосистема PHP-приложений. Общая теория: раздел тестирования.
Зачем тесты, если можно проверить вручную
Ручная проверка полезна, но она медленная и забывчивая. Автотесты дают три практических выгоды:
| Выгода | Что это значит на практике |
|---|---|
| Регрессии | После рефакторинга вы за секунды видите, сломалась ли старая логика |
| Документация | Тест "скидка 10% при сумме от 1000" показывает, как код должен вести себя |
| Уверенность при деплое | CI запускает тесты перед слиянием ветки — меньше сюрпризов на production |
Тесты дополняют здравый смысл и code review, но снимают рутину повторяющихся проверок.
Термины, которые встретятся везде
| Термин | Простыми словами |
|---|---|
| Тест-кейс | Один сценарий: "при таких входных данных ожидаем такой результат" |
| Утверждение (assertion) | Строка вида $this->assertSame(5, $result) — "я ожидаю, что…" |
| Fixture / arrange | Подготовка данных перед проверкой (создали объект, заполнили БД) |
| Мок (mock) | Поддельный объект-заглушка: "притворяемся, что почта отправлена" |
| Stub (заглушка) | Объект, который возвращает заранее заданные ответы без проверки вызовов |
| Data provider | Один тест, много наборов входных данных в таблице |
| Регрессия | Сломанное поведение, которое раньше работало |
Схема одного теста часто описывают как AAA: Arrange (подготовка) → Act (действие) → Assert (проверка).
Уровни тестирования PHP
| Уровень | Что проверяем | Пример | Скорость |
|---|---|---|---|
| Unit | Одна функция или класс изолированно | расчёт скидки, валидатор email | Очень быстро |
| Integration | Несколько слоёв вместе, часто с БД | репозиторий + SQLite in-memory | Средне |
| Feature / HTTP | Маршрут от запроса до ответа | GET /api/users → 200 + JSON | Медленнее |
Чем "выше" уровень, тем ближе к реальному пользователю, но тем дольше тест и сложнее найти причину падения. Обычно пишут много unit-тестов на чистую логику и меньше feature-тестов на критичные сценарии.
Минимальный проект на PHPUnit
Создадим учебный проект с нуля — так видно, из чего состоит PHPUnit без магии Laravel.
mkdir php-tests && cd php-tests
composer init -n
composer require --dev phpunit/phpunit ^11
Разбор:
mkdir php-tests && cd php-tests— создаёт рабочую папку и сразу переходит в неё.composer init -n— инициализируетcomposer.jsonбез интерактивных вопросов.composer require --dev phpunit/phpunit ^11— устанавливает PHPUnit как dev-зависимость в диапазоне версии 11.- Флаг
--devудерживает тестовый фреймворк в секции разработки, чтобы production-сборка оставалась легче. - Команды формируют минимальную основу проекта, где можно писать и запускать тесты.
Composer (composer require --dev) ставит PHPUnit только для разработки: на production-сервере эти пакеты часто не устанавливают.
В composer.json укажем autoload для папки tests/:
{
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
}
}
Разбор:
- Блок
autoload-devуправляет автозагрузкой только для dev-окружения. psr-4задаёт правило сопоставления namespace с каталогом."Tests\\": "tests/"означает, что классы с префиксомTests\ищутся в папкеtests/.- Двойной обратный слэш в JSON нужен для экранирования символа
\. - После изменения autoload обычно выполняют
composer dump-autoload, чтобы Composer пересобрал карту классов.
PSR-4 — соглашение: namespace Tests\ соответствует каталогу tests/. Класс Tests\CalculatorTest лежит в tests/CalculatorTest.php.
Код приложения — src/Calculator.php:
<?php
namespace App;
final class Calculator
{
public function add(int $a, int $b): int
{
return $a + $b;
}
}
Разбор:
namespace App;определяет пространство имён прикладного кода.final class Calculatorзапрещает наследование класса, что делает API более предсказуемым.add(int $a, int $b): int— строгая сигнатура: два целых аргумента и целое возвращаемое значение.return $a + $b;содержит чистую бизнес-логику без побочных эффектов, поэтому метод удобно покрывать unit-тестами.- Такой класс служит учебной моделью для демонстрации схемы Arrange → Act → Assert.
Тест — tests/CalculatorTest.php:
Код ITЗагрузка примера кода…
Разбор:
use PHPUnit\Framework\TestCase;подключает базовый класс PHPUnit с наборомassert*методов.final class CalculatorTest extends TestCaseопределяет тестовый класс, который PHPUnit обнаруживает автоматически.test_adds_two_integers()— отдельный тест-кейс с говорящим именем сценария.$calc = new Calculator();— этап подготовки (Arrange), создаётся тестируемый объект.$this->assertSame(5, $calc->add(2, 3));— проверка (Assert): ожидается строгое равенство результата5.- При расхождении PHPUnit выводит ожидаемое и фактическое значения, упрощая локализацию ошибки.
Разбор строк теста
| Строка | Смысл |
|---|---|
extends TestCase | Наследуем базовый класс PHPUnit — получаем методы assert* |
test_adds_two_integers | Имя метода с префиксом test — PHPUnit считает его тестом |
new Calculator() | Arrange — создали объект |
$calc->add(2, 3) | Act — вызвали проверяемый метод |
assertSame(5, …) | Assert — ожидаем ровно 5 с учётом типа (===) |
assertSame и assertEquals: assertSame сравнивает строго (5 и "5" — разные значения). assertEquals приводит типы — для чисел иногда удобнее, для строк и объектов чаще берут assertSame.
Конфиг phpunit.xml говорит, где искать тесты и как подключить autoload:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
<testsuites>
<testsuite name="App">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
Разбор:
- Корневой тег
<phpunit ...>хранит базовую конфигурацию тестового раннера. bootstrap="vendor/autoload.php"подключает автозагрузку Composer перед стартом тестов.colors="true"включает цветной вывод в терминале для удобной навигации по ошибкам.<testsuite name="App">группирует набор тестов под именемApp.<directory>tests</directory>указывает папку, где PHPUnit ищет классы тестов.
Запуск:
./vendor/bin/phpunit
Разбор:
- Запускается локальная версия PHPUnit из
vendor/bin, закреплённая в проекте. - Такой запуск исключает зависимость от глобально установленного PHPUnit на машине.
- Команда читает конфиг
phpunit.xmlи стартует все найденные тест-кейсы. - В отчёте отображаются прогнанные тесты, время выполнения и список падений.
- На CI обычно используют ту же команду для идентичного поведения в средах.
Успешный прогон выглядит как зелёные точки или слово OK с количеством тестов. Красный вывод показывает, какое утверждение не совпало и какие значения пришли на самом деле.
Data provider — один тест, много примеров
Код ITЗагрузка примера кода…
Разбор:
- Аннотация
@dataProvider additionProviderсвязывает тест с методом-поставщиком входных наборов. test_addition_provider(int $a, int $b, int $expected)принимает параметры для каждого кейса.assertSame($expected, ...)проверяет результат на каждом входном наборе.additionProvider(): arrayвозвращает массив сценариев, где каждый элемент запускает отдельный прогон.- Именованные ключи (
'нули','большие числа') попадают в отчёт и упрощают чтение провалов.
Каждый элемент массива — один прогон теста. Ключи ('нули') отображаются в отчёте PHPUnit и помогают понять, какой набор упал.
Моки и заглушки
Unit-тест должен проверять один класс. Если UserService отправляет почту через MailerInterface, в тесте почту реально слать не нужно — подменяем зависимость моком:
Код ITЗагрузка примера кода…
Разбор:
$this->createMock(MailerInterface::class)создаёт тестовый double, реализующий интерфейс почтового сервиса.expects($this->once())задаёт ожидание:sendдолжен вызваться ровно один раз.method('send')уточняет, к какому методу применяется ожидание.with($this->stringContains('welcome'))проверяет аргумент вызова по условию вхождения подстроки.(new UserService($mailer))->register('a@b.c');запускает тестируемый сценарий и подтверждает взаимодействие через mock.- Такой тест проверяет контракт взаимодействия без реальной отправки почты и без внешних эффектов.
| Вызов | Что проверяем |
|---|---|
createMock(MailerInterface::class) | PHPUnit создаёт объект, реализующий интерфейс |
expects($this->once()) | Метод send должен вызваться ровно один раз |
with($this->stringContains('welcome')) | В аргументе есть подстрока welcome |
Pest — короткий синтаксис
composer require pestphp/pest --dev --with-dependencies
./vendor/bin/pest --init
Разбор:
composer require pestphp/pest --dev --with-dependenciesдобавляет Pest и совместимые пакеты в dev-зависимости.- Флаг
--with-dependenciesпозволяет Composer обновить связанные библиотеки под требования Pest. ./vendor/bin/pest --initвыполняет первичную инициализацию тестовой структуры Pest.- После инициализации можно писать тесты в стиле
test(...)при сохранении совместимости с PHPUnit. - Команда удобна для команд, которым нужен более лаконичный синтаксис без смены инфраструктуры.
test('addition works', function () {
expect((new Calculator())->add(2, 3))->toBe(5);
});
Разбор:
test('addition works', function () { ... })объявляет тест-кейс в функциональном стиле.expect(...)начинает цепочку проверок в стиле fluent API.(new Calculator())->add(2, 3)— действие, результат которого проверяется.toBe(5)эквивалент строгой проверки значения на5.- Такой синтаксис уменьшает шаблонный код и делает сценарии короче при том же уровне проверки.
Pest совместим с отчётами PHPUnit и теми же assert-методами.
Тестирование Laravel
В скелете Laravel уже есть tests/Unit и tests/Feature. Базовый класс Tests\TestCase поднимает приложение так же, как при HTTP-запросе.
namespace Tests\Feature;
use Tests\TestCase;
class HelloTest extends TestCase
{
public function test_home_returns_ok(): void
{
$response = $this->get('/');
$response->assertStatus(200);
}
}
Разбор:
namespace Tests\Feature;относит тест к feature-уровню, где проверяется HTTP-поведение приложения.class HelloTest extends TestCaseиспользует базовый класс Laravel с поднятием контейнера и маршрутов.$response = $this->get('/');выполняет HTTP GET-запрос к приложению внутри тестового окружения.$response->assertStatus(200);подтверждает, что endpoint вернул успешный статус.- Проверка фокусируется на пользовательском контракте маршрута, а не на деталях реализации контроллера.
Запуск:
php artisan test
# или
./vendor/bin/phpunit
Разбор:
php artisan test— рекомендованный запуск тестов в Laravel через встроенный CLI.- Команда автоматически использует настройки фреймворка и удобнее интегрируется с Laravel-окружением.
./vendor/bin/phpunitостаётся прямым запуском PHPUnit и даёт тот же базовый результат.- Строка
# илификсирует альтернативный путь для команд/CI, где принят прямой вызов раннера. - Оба варианта запускают тесты из
tests/и формируют отчёт по статусу прогона.
Полезные приёмы Laravel
| Приём | Зачем |
|---|---|
RefreshDatabase | Перед каждым тестом чистая БД |
$this->actingAs($user) | Запрос "от имени" залогиненного пользователя |
Http::fake() | Подмена исходящих HTTP к внешним API |
Queue::fake() + Queue::assertPushed(...) | Проверка постановки job в очередь |
См. Laravel — очереди и политики.
Тестирование Symfony
composer require --dev symfony/test-pack
php bin/phpunit
Разбор:
composer require --dev symfony/test-packустанавливает набор тестовых пакетов Symfony.test-packподтягивает типичные зависимости для unit и функциональных тестов.php bin/phpunitзапускает обёртку PHPUnit, которая поставляется в Symfony-проекте.- Такой старт даёт единый вход для тестов без ручной настройки каждого пакета.
- Команда применяется сразу после добавления маршрутов и сервисов для быстрой проверки.
WebTestCase поднимает kernel и клиент:
$client = static::createClient();
$client->request('GET', '/hello');
$this->assertResponseIsSuccessful();
Разбор:
static::createClient()создаёт тестовый HTTP-клиент Symfony.$client->request('GET', '/hello');отправляет запрос к маршруту внутри поднятого kernel.$this->assertResponseIsSuccessful();проверяет, что ответ находится в диапазоне 2xx.- Этот паттерн позволяет тестировать связку роутинг → контроллер → ответ как единый поток.
- Подход особенно полезен для smoke-проверок основных endpoint-ов.
CI
- run: composer install --no-interaction
- run: ./vendor/bin/phpunit --coverage-text
Разбор:
- Фрагмент показывает шаги CI-пайплайна для сборки и тестирования PHP-проекта.
composer install --no-interactionустанавливает зависимости без интерактивных запросов../vendor/bin/phpunit --coverage-textзапускает тесты и печатает текстовый отчёт покрытия.- Формат
- run:типичен для YAML-конфигураций CI-сервисов. - Такой минимум закрывает базовую гарантию качества перед слиянием изменений.
Частые ошибки новичков
| Симптом | Вероятная причина |
|---|---|
Class not found | Нет autoload в composer.json или не выполнен composer dump-autoload |
| Тест "зелёный", баг на сайте | Тестируете не тот уровень (unit вместо feature) |
| Мок не срабатывает | В конструкторе конкретный класс вместо интерфейса |
| Медленные тесты | Слишком много feature-тестов с реальной БД |
Связанные материалы
- Laravel — очереди и политики
- Первая программа на Laravel
- Laravel API с Sanctum
- Рекомендации по разработке на PHP
Стратегия тестов для реального проекта
Хороший практический ориентир для команды:
- unit-тесты для доменной логики и правил расчетов;
- integration-тесты для репозиториев и внешних адаптеров;
- feature-тесты для критичных пользовательских сценариев.
Такой баланс держит проверку быстрой и одновременно защищает ключевые бизнес-потоки.
Матрица критичных сценариев
| Зона | Минимальный набор проверок |
|---|---|
| Аутентификация | вход, выход, блокировка невалидных данных |
| Платежи/подписки | успешный платеж, отказ, повторная попытка |
| Права доступа | 200, 403, корректный контент для роли |
| Очереди | job поставлена и обработана |
Полезные привычки в CI
- Запускать тесты на каждом pull request.
- Добавлять отчеты покрытия только для ключевых модулей.
- Фиксировать flaky-тесты сразу после появления.
- Держать быстрый smoke-набор, который проходит за минуты.
Эти привычки делают тестирование частью ежедневного ритма команды, а не отдельным этапом перед релизом.
Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.