php

PHPUnit: модульное тестирование PHP с нуля — установка, моки и Data Provider

Код без тестов — это код, который страшно менять. Любая правка превращается в рулетку: «вроде работает». 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 (проверка). Это не догма, а способ не запутаться через месяц, когда вернётесь читать чужой или свой код.

Схема 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, видите зелёный — едете дальше.