Перейти к основному содержимому

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-тестов с реальной БД

Связанные материалы


Стратегия тестов для реального проекта

Хороший практический ориентир для команды:

  • unit-тесты для доменной логики и правил расчетов;
  • integration-тесты для репозиториев и внешних адаптеров;
  • feature-тесты для критичных пользовательских сценариев.

Такой баланс держит проверку быстрой и одновременно защищает ключевые бизнес-потоки.


Матрица критичных сценариев

ЗонаМинимальный набор проверок
Аутентификациявход, выход, блокировка невалидных данных
Платежи/подпискиуспешный платеж, отказ, повторная попытка
Права доступа200, 403, корректный контент для роли
Очередиjob поставлена и обработана

Полезные привычки в CI

  1. Запускать тесты на каждом pull request.
  2. Добавлять отчеты покрытия только для ключевых модулей.
  3. Фиксировать flaky-тесты сразу после появления.
  4. Держать быстрый smoke-набор, который проходит за минуты.

Эти привычки делают тестирование частью ежедневного ритма команды, а не отдельным этапом перед релизом.


Основа по протоколу

Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.