Что такое PHPUnit

PHPUnit — основной test automation framework для PHP. Он реализует xUnit-подход: тесты пишутся как классы, отдельные проверки — как методы, а результат выражается через assertions. В обычном Composer-проекте PHPUnit чаще всего живёт в require-dev, запускается из vendor/bin/phpunit и входит в тот же контур качества, что PHPDoc, generics и статический анализ, style checks и CI, линтеры и автоматические проверки.

Минимальный unit test выглядит так:

<?php declare(strict_types=1);

namespace App\Tests;

use App\Email;
use PHPUnit\Framework\TestCase;

final class EmailTest extends TestCase
{
    public function testItNormalizesAddress(): void
    {
        $email = Email::fromString(' User@Example.COM ');

        self::assertSame('user@example.com', $email->value());
    }
}

Класс наследуется от PHPUnit\Framework\TestCase. Метод обычно начинается с test, хотя в современных версиях можно использовать атрибуты. assertSame() проверяет не только значение, но и тип: это часто лучше, чем слишком широкое assertEquals(). Такой стиль хорошо сочетается с Типы и strict_types: тест фиксирует ожидаемое поведение, а типы сужают допустимые значения.

Быстрое повторение
Почему в PHPUnit-тесте для точного результата часто выбирают `assertSame()`, а не `assertEquals()`?

Test case, assertion и failure

Test case — это один класс или один сценарий проверки, в котором есть arrange, act и assert: подготовить данные, выполнить действие, проверить результат. PHPUnit не требует писать эти слова в коде, но структура помогает не превращать тест в маленький скрипт с непонятной целью.

public function testCannotCreateEmailFromInvalidString(): void
{
    $this->expectException(InvalidArgumentException::class);

    Email::fromString('not an email');
}

expectException() нужно вызвать до действия, которое должно бросить исключение. Это напрямую связано со статьёй Ошибки, исключения и Throwable: в тестах обычно проверяют конкретный класс исключения, а не просто «что-то упало». Если код может бросить InvalidArgumentException, не стоит ожидать общий Throwable, иначе тест станет слишком терпимым и пропустит лишние ошибки.

Failure — это не авария тест-раннера, а полезный сигнал: assertion не совпал с фактическим результатом. Error — другая категория: тест не дошёл до нормальной проверки из-за фатальной ошибки, неверного вызова, отсутствующего класса или неожиданного исключения.

There were 2 failures:

1) App\Tests\EmailTest::testItNormalizesAddress
Failed asserting that two strings are identical.
--- Expected
+++ Actual
@@ @@
-'user@example.com'
+'User@Example.COM'

There was 1 error:

1) App\Tests\NewsletterTest::testSubscribesEmail
Error: Class "App\Newsletter" not found
flowchart TD A[Код приложения] --> B[TestCase] B --> C[Fixture: setUp] C --> D[Act: вызов проверяемого кода] D --> E[Assertions] E --> F{Результат} F -->|совпало| G[Pass] F -->|assertion не совпал| H[Failure] F -->|неожиданное исключение или ошибка| I[Error] B --> J[Test doubles] J --> K[Stub: подставляет ответы] J --> L[Mock: проверяет ожидаемые вызовы] G --> M[CI pipeline] H --> M I --> M
flowchart TD
    A[Код приложения] --> B[TestCase]
    B --> C[Fixture: setUp]
    C --> D[Act: вызов проверяемого кода]
    D --> E[Assertions]
    E --> F{Результат}
    F -->|совпало| G[Pass]
    F -->|assertion не совпал| H[Failure]
    F -->|неожиданное исключение или ошибка| I[Error]
    B --> J[Test doubles]
    J --> K[Stub: подставляет ответы]
    J --> L[Mock: проверяет ожидаемые вызовы]
    G --> M[CI pipeline]
    H --> M
    I --> M
Как PHPUnit связывает fixture, test case, assertions, failures/errors и test doubles в одном цикле проверки.
Быстрое повторение
В каком месте теста PHPUnit нужно вызвать `expectException()`, если проверяется ожидаемое исключение?

Fixtures: подготовка окружения без магии

Fixture — это состояние, нужное тесту: объект, временная коллекция, fake clock, тестовый репозиторий, подключение к in-memory базе. В PHPUnit для повторяемой подготовки используют setUp(), а для освобождения ресурсов — tearDown().

final class NewsletterTest extends TestCase
{
    private Newsletter $newsletter;

    protected function setUp(): void
    {
        $this->newsletter = new Newsletter();
    }

    public function testSubscribesEmail(): void
    {
        $this->newsletter->subscribe('a@example.com');

        self::assertTrue($this->newsletter->hasSubscriber('a@example.com'));
    }
}

setUp() выполняется перед каждым test method, поэтому тесты не должны зависеть от порядка запуска. Если один тест добавил пользователя, другой не должен рассчитывать, что этот пользователь уже есть. Shared state делает suite хрупким: локально всё зелёное, а в CI при другом порядке или параллельном запуске начинаются странные failures.

Быстрое повторение
Что гарантирует `setUp()` в PHPUnit относительно каждого test method?

Data providers

Data provider нужен, когда один и тот же сценарий надо проверить на нескольких наборах данных. Вместо копипасты тест получает аргументы из отдельного метода.

<?php declare(strict_types=1);

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class SlugTest extends TestCase
{
    public static function slugProvider(): array
    {
        return [
            'lowercase words' => ['Hello World', 'hello-world'],
            'extra spaces' => ['  PHP   Tests  ', 'php-tests'],
            'already slug' => ['phpunit', 'phpunit'],
        ];
    }

    #[DataProvider('slugProvider')]
    public function testBuildsSlug(string $input, string $expected): void
    {
        self::assertSame($expected, Slug::fromTitle($input));
    }
}

Именованные datasets полезны не для красоты, а для диагностики: в выводе PHPUnit видно, какой набор сломался. Но data provider не должен скрывать разные бизнес-сценарии в одном гигантском тесте. Если меняется смысл проверки, лучше завести отдельный test method с честным названием.

App\Tests\SlugTest::testBuildsSlug with data set "extra spaces"
Failed asserting that two strings are identical.
--- Expected
+++ Actual
@@ @@
-'php-tests'
+'php---tests'

Stubs, mocks и test doubles

Test double — общее название для объекта-заменителя. Он нужен, когда настоящий collaborator слишком медленный, нестабильный, дорогой или опасный: SMTP, платёжный API, очередь, внешняя база. В PHPUnit чаще всего различают stubs и mocks.

Stub подставляет ответы. Он помогает проверить код, которому нужен collaborator, но сама коммуникация с collaborator не является главным предметом теста.

$gateway = $this->createStub(PaymentGateway::class);
$gateway
    ->method('charge')
    ->willReturn(new PaymentResult(success: true));

$service = new CheckoutService($gateway);

self::assertTrue($service->pay($order)->isPaid());

Mock дополнительно проверяет ожидания: был ли вызван метод, сколько раз и с какими аргументами.

$mailer = $this->createMock(Mailer::class);
$mailer
    ->expects($this->once())
    ->method('sendWelcomeEmail')
    ->with('user@example.com');

$service = new RegistrationService($mailer);
$service->register('user@example.com');

Практический риск: слишком много mocks привязывают тест к внутренней реализации. Если тест проверяет каждый промежуточный вызов, рефакторинг ломает suite даже при сохранении поведения. Обычно устойчивее проверять observable result: return value, state change, запись события, отправленную команду. Mock уместен, когда сам факт взаимодействия и есть поведение: например, сервис обязан отправить письмо или поставить job в очередь.

PHPUnit в проекте и CI

В Composer-проекте PHPUnit обычно устанавливают как dev-зависимость:

composer require --dev phpunit/phpunit
vendor/bin/phpunit

Конфигурация живёт в phpunit.xml или phpunit.xml.dist: там задают test suites, bootstrap-файл, coverage-настройки, переменные окружения для тестов. Это пересекается с Composer, Packagist и composer.json: автозагрузка классов тестов почти всегда идёт через Composer autoload.

<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
    <testsuites>
        <testsuite name="unit">
            <directory>tests/Unit</directory>
        </testsuite>
    </testsuites>

    <php>
        <env name="APP_ENV" value="test"/>
    </php>
</phpunit>

В CI PHPUnit должен запускаться на чистой установке зависимостей из lock-файла, рядом с composer validate, composer audit, PHPStan/Psalm и форматерами. Связка с composer.lock, install/update и audit здесь простая: CI проверяет то, что реально собирается из зафиксированных версий, а не случайный набор пакетов после локального composer update.

name: tests

on: [push, pull_request]

jobs:
  php:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
      - run: composer install --no-interaction --prefer-dist
      - run: composer validate --strict
      - run: composer audit
      - run: vendor/bin/phpunit
      - run: vendor/bin/phpstan analyse

Code coverage полезен как индикатор слепых зон, но не как единственная метрика качества. 95% coverage не означает, что проверены важные edge cases. И наоборот, небольшой набор хороших tests вокруг платежей, прав доступа или парсинга пользовательского ввода может быть ценнее, чем сотни проверок trivial getters.

Где PHPUnit заканчивается

PHPUnit исполняет код и ловит регрессии поведения. Он не заменяет статический анализ, потому что не перебирает все возможные типы и ветки. Он не заменяет security review из статей про XSS, экранирование вывода и шаблоны, CSRF и state-changing запросы и Prepared statements и SQL injection. И он не делает архитектуру хорошей сам по себе: если класс трудно тестировать без десятка mocks, это часто сигнал о слишком сильной связности.

Хороший тестовый слой похож на справочник поведения проекта. В нём видно, какие ошибки считаются ожидаемыми, какие форматы данных поддерживаются, какие зависимости можно заменить test doubles и какие сценарии должны оставаться стабильными перед merge.

См. также

Источники

  1. PHPUnit 12.5 Manual — Writing Tests for PHPUnit
  2. PHPUnit 12.5 Manual — Fixtures
  3. PHPUnit 12.5 Manual — Test Doubles
  4. PHPUnit 12.5 Manual — The Command-Line Test Runner
  5. PHPUnit 12.5 Manual — Installation
  6. PHPUnit 12.5 Manual — Organizing Tests
  7. PHPUnit 12.5 Manual — Risky Tests
  8. PHPUnit 12.5 Manual — Code Coverage