Код без тестов — это код, который страшно менять. Любая правка превращается в рулетку: «вроде работает». PHPUnit решает эту проблему дёшево — пять минут на установку, и у вас есть автоматическая проверка, которая ловит регрессии до того, как их увидит клиент. В этой статье разберём, как поднять PHPUnit с нуля, написать первый рабочий тест и закрыть тестами реальный сервис с зависимостями.
Установка PHPUnit через Composer
PHPUnit ставится одной командой как dev-зависимость — на проде он не нужен и не должен попадать в продакшен-сборку. Если у вас ещё нет composer.json, создайте его пустым: composer init -n.
# Установка последней версии PHPUnit как dev-зависимости
composer require --dev phpunit/phpunit ^11
# Проверим, что бинарник на месте
vendor/bin/phpunit --version
# PHPUnit 11.x by Sebastian Bergmann and contributors.
Минимальный phpunit.xml в корне проекта — без него PHPUnit будет ругаться на отсутствие конфига. Этот файл задаёт, где лежат тесты, и подключает автозагрузку Composer.
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache">
<testsuites>
<testsuite name="default">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
Папка tests/ — стандартное место для тестов. В composer.json добавьте автозагрузку для классов из неё, иначе PHPUnit не найдёт тесты как классы:
{
"autoload-dev": {
"psr-4": { "Tests\\": "tests/" }
},
"autoload": {
"psr-4": { "App\\": "src/" }
}
}
После правки composer.json обязательно выполните composer dump-autoload, иначе изменения в namespace не подхватятся.
Первый тест: AAA-паттерн
Любой нормальный тест строится по трём фазам: Arrange (подготовка), Act (действие), Assert (проверка). Это не догма, а способ не запутаться через месяц, когда вернётесь читать чужой или свой код.

Напишем класс-калькулятор и тест к нему. Файл src/Calculator.php:
<?php
declare(strict_types=1);
namespace App;
final class Calculator
{
public function add(int $a, int $b): int
{
return $a + $b;
}
public function divide(float $a, float $b): float
{
if ($b === 0.0) {
throw new \DivisionByZeroError('Деление на ноль запрещено');
}
return $a / $b;
}
}
Тест к нему лежит в tests/CalculatorTest.php. Имя класса = имя тестируемого класса + суффикс Test — это конвенция, без неё PHPUnit просто не подхватит файл.
<?php
declare(strict_types=1);
namespace Tests;
use App\Calculator;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
final class CalculatorTest extends TestCase
{
#[Test]
public function складывает_два_положительных_числа(): void
{
// Arrange
$calc = new Calculator();
// Act
$result = $calc->add(2, 3);
// Assert
$this->assertSame(5, $result);
}
#[Test]
public function кидает_исключение_при_делении_на_ноль(): void
{
$calc = new Calculator();
$this->expectException(\DivisionByZeroError::class);
$this->expectExceptionMessage('Деление на ноль');
$calc->divide(10.0, 0.0);
}
}
Запуск — vendor/bin/phpunit. Зелёные точки означают, что тесты прошли. Красная F — упал ассерт, E — необработанное исключение. Названия методов на русском работают и читаются как спецификация: «складывает два положительных числа» — это и есть документация поведения.
Важный момент: используйте assertSame, а не assertEquals. Первый сравнивает строго (как ===), второй приводит типы — и тест на 0 == '0abc' внезапно становится зелёным.
Data Provider: один тест — много кейсов
Когда нужно прогнать одну и ту же логику на десяти разных входах, не пишите десять методов — используйте data provider. PHPUnit вызовет тест столько раз, сколько наборов вернёт провайдер.
<?php
declare(strict_types=1);
namespace Tests;
use App\Validator\Email;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class EmailTest extends TestCase
{
public static function валидныеEmail(): iterable
{
// Ключ массива станет именем подтеста в отчёте
yield 'простой адрес' => ['user@example.com'];
yield 'с поддоменом' => ['user@mail.example.com'];
yield 'с плюсом' => ['user+tag@example.com'];
yield 'с цифрами' => ['user123@example.com'];
}
#[Test]
#[DataProvider('валидныеEmail')]
public function принимает_валидные_адреса(string $email): void
{
$this->assertTrue(Email::isValid($email));
}
public static function невалидныеEmail(): iterable
{
yield 'без собаки' => ['userexample.com'];
yield 'двойная собака' => ['user@@example.com'];
yield 'пустая строка' => [''];
yield 'без TLD' => ['user@example'];
}
#[Test]
#[DataProvider('невалидныеEmail')]
public function отвергает_невалидные_адреса(string $email): void
{
$this->assertFalse(Email::isValid($email));
}
}
Провайдер обязан быть public static — PHPUnit вызывает его без создания объекта теста. Возвращайте через yield с именованными ключами: в отчёте увидите принимает_валидные_адреса with data set "с плюсом" вместо безликих #0, #1, #2.
Моки: тестируем сервис с зависимостями
Реальный код почти всегда зависит от других объектов: репозиториев, HTTP-клиентов, шин событий. В юнит-тесте мы не хотим лезть в базу или в сеть — нужно подсунуть «заглушку», которая ведёт себя как реальный объект, но контролируется тестом.
Допустим, у нас есть сервис заказов, который сохраняет заказ в репозиторий и отправляет письмо:
<?php
declare(strict_types=1);
namespace App\Order;
final class OrderService
{
public function __construct(
private OrderRepository $repo,
private MailerInterface $mailer,
) {}
public function place(array $items, string $email): Order
{
if ($items === []) {
throw new \InvalidArgumentException('Корзина пуста');
}
$order = new Order($items, $email);
$this->repo->save($order);
$this->mailer->send($email, 'Заказ принят', "Номер: {$order->id}");
return $order;
}
}
Тест с моками: репозиторий и мейлер заменяем заглушками. Проверяем, что метод save вызвался ровно один раз, а мейлер получил правильный email.
<?php
declare(strict_types=1);
namespace Tests\Order;
use App\Order\MailerInterface;
use App\Order\OrderRepository;
use App\Order\OrderService;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class OrderServiceTest extends TestCase
{
#[Test]
public function сохраняет_заказ_и_отправляет_письмо(): void
{
// Arrange: мокаем зависимости
$repo = $this->createMock(OrderRepository::class);
$repo->expects($this->once())
->method('save'); // ожидаем ровно один вызов
$mailer = $this->createMock(MailerInterface::class);
$mailer->expects($this->once())
->method('send')
->with(
$this->equalTo('client@example.com'),
$this->stringContains('Заказ'),
$this->anything()
);
$service = new OrderService($repo, $mailer);
// Act
$order = $service->place(
[['sku' => 'ABC', 'qty' => 1]],
'client@example.com'
);
// Assert
$this->assertSame('client@example.com', $order->email);
}
#[Test]
public function кидает_исключение_на_пустую_корзину(): void
{
$repo = $this->createMock(OrderRepository::class);
$mailer = $this->createMock(MailerInterface::class);
// Ни сохранения, ни письма быть не должно
$repo->expects($this->never())->method('save');
$mailer->expects($this->never())->method('send');
$service = new OrderService($repo, $mailer);
$this->expectException(\InvalidArgumentException::class);
$service->place([], 'client@example.com');
}
}
createMock создаёт двойник интерфейса или класса — все методы возвращают null по умолчанию. expects($this->once()) — это «строгая» проверка вызова: если метод не вызовут или вызовут дважды, тест упадёт. expects($this->never()) страхует от ложноположительных результатов в негативных сценариях.
setUp, фикстуры и общие данные
Если у нескольких тестов одинаковый код в Arrange — выносите в setUp(). Метод вызывается перед каждым тестом, поэтому состояние объектов всегда чистое.
<?php
declare(strict_types=1);
namespace Tests\Order;
use App\Order\OrderService;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
final class OrderServiceSetupTest extends TestCase
{
private OrderService $service;
private MockObject $repo;
private MockObject $mailer;
protected function setUp(): void
{
$this->repo = $this->createMock(OrderRepository::class);
$this->mailer = $this->createMock(MailerInterface::class);
$this->service = new OrderService($this->repo, $this->mailer);
}
#[Test]
public function успешный_заказ(): void
{
$this->repo->expects($this->once())->method('save');
$order = $this->service->place([['sku' => 'A']], 'a@b.c');
$this->assertNotEmpty($order->id);
}
#[Test]
public function пустая_корзина_не_сохраняется(): void
{
$this->repo->expects($this->never())->method('save');
$this->expectException(\InvalidArgumentException::class);
$this->service->place([], 'a@b.c');
}
}
Никогда не используйте статические свойства или $GLOBALS для шаринга состояния между тестами — порядок выполнения не гарантирован, и зелёная сборка локально может стать красной в CI.
Code Coverage и запуск в CI
Покрытие кода тестами считается через Xdebug или PCOV. Установите Xdebug на dev-машине (на проде он не нужен) и запустите:
# HTML-отчёт с подсветкой строк
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html=coverage/
# Краткий отчёт в консоль
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text
# Только провалившиеся тесты
vendor/bin/phpunit --filter="кидает_исключение"
# Параллельный запуск (нужен paratest)
composer require --dev brianium/paratest --dev
vendor/bin/paratest -p 4
Для GitHub Actions минимальный workflow выглядит так. Файл .github/workflows/tests.yml:
name: Tests
on: [push, pull_request]
jobs:
phpunit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: pcov
- run: composer install --no-interaction --prefer-dist
- run: vendor/bin/phpunit --coverage-text
Покрытие 100% — не цель. Покрытие критичных путей (оплата, авторизация, расчёт цен) — да. Геттеры и DTO покрывать ради цифры — пустая трата времени.
Чеклист: как не сломать себе тесты
- Один тест — одно поведение. Если в названии теста есть «и», скорее всего, это два теста.
- Тест должен падать. Сломайте тестируемый код намеренно и убедитесь, что тест краснеет — иначе он ничего не проверяет.
- Никаких сетевых вызовов и обращений к БД в юнит-тестах. Только моки. Для интеграционных тестов — отдельный suite.
- assertSame вместо assertEquals. Строгое сравнение ловит баги, нестрогое — прячет.
- Тесты — это документация. Имена методов читайте как ТЗ: «отвергает невалидные адреса», «кидает исключение на пустую корзину».
- Запускайте тесты в CI на каждый push. Локально все забывают.
- Не тестируйте приватные методы напрямую — тестируйте публичное API. Если приватный метод тяжело покрыть через публичный — вынесите в отдельный класс.
Когда тесты становятся привычкой, рефакторинг перестаёт быть страшной процедурой и превращается в обычную работу: меняете внутренности, гоняете vendor/bin/phpunit, видите зелёный — едете дальше.
