Тестирование: база PHPUnit

Тесты - это когда ты говоришь будущему себе: «я тебя люблю, поэтому проверил это заранее».

Установка PHPUnit

composer require --dev phpunit/phpunit
Composer - менеджер зависимостей PHP. Установи его: `curl -sS https://getcomposer.org/installer | php`. Для нормальной разработки он обязателен - подробнее в [уроке про namespaces и Composer](./16-namespaces.md).

Конфигурация

phpunit.xml в корне проекта:

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

Первый тест

Код, который тестируем - src/Math.php:

<?php
function sum(int $a, int $b): int {
    return $a + $b;
}

Тест - tests/MathTest.php:

<?php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class MathTest extends TestCase {
    public function testSumPositiveNumbers(): void {
        $this->assertSame(5, sum(2, 3));
    }

    public function testSumWithZero(): void {
        $this->assertSame(3, sum(3, 0));
    }

    public function testSumNegativeNumbers(): void {
        $this->assertSame(-5, sum(-2, -3));
    }
}

Запуск:

./vendor/bin/phpunit
# OK (3 tests, 3 assertions)

Основные assert-методы

<?php
// Строгое сравнение (===)
$this->assertSame(5, sum(2, 3));

// Нестрогое (==) - реже используется
$this->assertEquals(5, sum(2, 3));

// True / False
$this->assertTrue(isValid('test@mail.com'));
$this->assertFalse(isValid('not-email'));

// Null
$this->assertNull(findUser(999));
$this->assertNotNull(findUser(1));

// Массивы
$this->assertCount(3, $items);
$this->assertContains('admin', $roles);
$this->assertArrayHasKey('name', $user);

// Строки
$this->assertStringContainsString('error', $message);
$this->assertMatchesRegularExpression('/^\d{4}$/', $code);

// Типы
$this->assertIsArray($result);
$this->assertIsString($name);
$this->assertInstanceOf(User::class, $user);
`assertSame` проверяет `===` (тип + значение). `assertEquals` проверяет `==` (только значение). Всегда предпочитай `assertSame` - как и `===` в обычном коде.

Тестирование исключений

<?php
public function testParseAgeRejectsNonNumeric(): void {
    $this->expectException(\InvalidArgumentException::class);
    $this->expectExceptionMessage('Должно быть числом');

    parseAge('abc');
}

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

    parseAge('-5');
}

setUp и tearDown

Код, который выполняется перед/после каждого теста:

<?php
final class TaskServiceTest extends TestCase {
    private array $tasks;

    protected function setUp(): void {
        // Вызывается перед КАЖДЫМ тестом
        $this->tasks = [
            ['id' => 1, 'title' => 'Купить молоко', 'done' => false],
            ['id' => 2, 'title' => 'Написать код', 'done' => true],
        ];
    }

    public function testCountTasks(): void {
        $this->assertCount(2, $this->tasks);
    }

    public function testFilterDone(): void {
        $done = array_filter($this->tasks, fn($t) => $t['done']);
        $this->assertCount(1, $done);
    }
}

Data Providers - параметризованные тесты

Когда один тест нужно прогнать с разными данными:

<?php
final class ValidatorTest extends TestCase {
    /**
     * @dataProvider emailProvider
     */
    public function testValidateEmail(string $email, bool $expected): void {
        $this->assertSame($expected, isValidEmail($email));
    }

    public static function emailProvider(): array {
        return [
            'valid email'    => ['test@mail.com', true],
            'no @'           => ['testmail.com', false],
            'no domain'      => ['test@', false],
            'empty string'   => ['', false],
            'with subdomain' => ['user@sub.domain.com', true],
        ];
    }
}
Ключи массива (`'valid email'`, `'no @'`) становятся именами тестов в выводе. Когда тест падает, сразу видно какой кейс сломался.

Структура тестов: Arrange-Act-Assert

<?php
public function testCompleteTaskMarksAsDone(): void {
    // Arrange - подготовка
    $tasks = [['id' => 1, 'title' => 'Test', 'done' => false]];

    // Act - действие
    $result = completeTask($tasks, 1);

    // Assert - проверка
    $this->assertTrue($result[0]['done']);
}

Что тестировать

Начни с самого полезного:

<?php
// 1. Чистые функции (вход → выход, без побочных эффектов)
function formatPrice(int $cents): string {
    return number_format($cents / 100, 2, '.', ' ') . ' ₽';
}

// 2. Валидацию
function validateAge(string $input): int { ... }

// 3. Парсинг и трансформацию данных
function parseCSVLine(string $line): array { ... }

// 4. Граничные случаи
// Пустой ввод, null, отрицательные числа, очень длинные строки

Что НЕ стоит тестировать (на старте)

Не пытайся покрыть всё сразу. Пропусти на первом этапе: приватные методы (тестируй через публичные), геттеры/сеттеры (слишком тривиально), сторонние библиотеки (они уже протестированы).

Запуск тестов

# Все тесты
./vendor/bin/phpunit

# Конкретный файл
./vendor/bin/phpunit tests/MathTest.php

# Конкретный метод
./vendor/bin/phpunit --filter testSumPositiveNumbers

# С покрытием (нужен Xdebug или PCOV)
./vendor/bin/phpunit --coverage-text

Типичные ошибки

  • Зависимость от состояния БД между тестами. Один тест создаёт запись, второй ждёт её, третий падает на чужой машине. Используй транзакцию в setUp/tearDown с откатом или Symfony KernelTestCase + DAMADoctrineTestBundle для автоматического rollback.
  • Копипаста параметризованных тестов. Пять методов testEmail1, testEmail2 с одной логикой и разными входами - это сигнал к @dataProvider. Кейсы в массиве, ключи становятся именами в выводе.
  • Поход в сеть или БД из unit-теста. file_get_contents("https://api.example.com") в unit-тесте - это уже интеграционный тест, который падает без интернета и тормозит CI. Мокай HTTP-клиент, выноси интеграцию в отдельный suite.
  • Беззубые ассерты типа assertTrue($result !== null). Тест зелёный, но ничего не проверяет - функция могла вернуть мусор. Пиши assertSame($expected, $result) с конкретным значением.
  • Игнор флейков. «У меня иногда падает, перезапусти» - это race condition или зависимость от порядка тестов. Запусти с --random-order, найди скрытое глобальное состояние и убей его.

Best practices

  • Изолируй каждый тест: транзакция с rollback или фикстуры в памяти. В Symfony - KernelTestCase с тестовой БД и DAMADoctrineTestBundle.
  • Параметризуй однотипные кейсы через @dataProvider со строковыми ключами - имена кейсов попадут в вывод PHPUnit.
  • Мокай границы системы (HTTP-клиент, репозиторий, файловая система), а не внутреннюю логику - тесты не должны знать про устройство кода.
  • Проверяй конкретное значение через assertSame, а не факт «не null». Слабый ассерт ловит только полное отсутствие результата.
  • Гоняй тесты с --random-order в CI и фикси флейки сразу - отложенный флейк превращается в неотлаживаемый ад через месяц.

Мини-задание

  • Напиши функцию normalizeTitle(string $s): string (trim + ucfirst + убрать двойные пробелы)
  • Сделай на неё 5 тестов: обычная строка, лишние пробелы, пустая, с табами, уже нормализованная
  • Напиши тест с expectException для функции, которая бросает исключение
  • Используй @dataProvider для параметризованного теста валидации email
  • Добей тесты до прохождения ./vendor/bin/phpunit без ошибок

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.