Тестирование: база PHPUnit
Тесты - это когда ты говоришь будущему себе: «я тебя люблю, поэтому проверил это заранее».
Установка PHPUnit
composer require --dev phpunit/phpunit
Конфигурация
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);
Тестирование исключений
<?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],
];
}
}
Структура тестов: 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с откатом или SymfonyKernelTestCase+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без ошибок