Вы когда-нибудь тратили целый день на поиск бага, который появился после добавления одной строчки кода? Или боялись обновлять зависимости, потому что «всё может сломаться»? Знакомая ситуация. Именно здесь на сцену выходит PHPUnit — инструмент, который превращает хаос в порядок, а страх перед изменениями — в уверенность. Сегодня разберем, как писать тесты так, чтобы они приносили пользу, а не отнимали время.
Зачем вообще нужны тесты и что такое PHPUnit
Представьте, что вы собираете мебель из IKEA. Можно, конечно, собрать шкаф без инструкции, но есть риск, что останутся лишние детали, а дверцы будут открываться внутрь. Тесты — это та самая инструкция, которая говорит: «Всё собрано правильно, можно пользоваться».
PHPUnit — это фреймворк для тестирования PHP-кода. Он позволяет автоматически проверять, что ваш код работает так, как задумано. Написали функцию — написали тест. Изменили логику — запустили тесты и сразу видите, что сломалось.
Основные виды тестов, которые мы рассмотрим:
- Юнит-тесты — проверяют отдельные куски кода (функции, методы, классы) в изоляции.
- Интеграционные тесты — проверяют, как разные части системы работают вместе (например, контроллер + база данных).
- Функциональные тесты — проверяют поведение приложения с точки зрения пользователя (нажал кнопку — получил результат).
Начнем с установки. Если у вас есть Composer (а он должен быть), то всё просто:
composer require --dev phpunit/phpunit
После установки в корне проекта появится файл phpunit.xml — это конфигурация. Минимальный вариант выглядит так:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php">
<testsuites>
<testsuite name="My Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
tests, а структура папок внутри должна повторять структуру вашего исходного кода. Если класс лежит в src/Service/UserService.php, то тест для него — в tests/Service/UserServiceTest.php. Это экономит время на поиск.
Юнит-тесты: проверяем кирпичики
Юнит-тест — это как проверить, что дверная ручка поворачивается. Мы не смотрим на весь дом, только на ручку. В PHP это означает, что мы тестируем один метод или функцию, не завязываясь на базу данных, API или другие классы.
Допустим, у нас есть простой класс для работы с корзиной:
<?php
class Cart
{
private array $items = [];
public function addItem(string $name, float $price): void
{
$this->items[] = ['name' => $name, 'price' => $price];
}
public function getTotal(): float
{
$total = 0;
foreach ($this->items as $item) {
$total += $item['price'];
}
return $total;
}
public function getCount(): int
{
return count($this->items);
}
}
Теперь напишем тест. В PHPUnit тесты — это классы, которые наследуются от TestCase. Каждый тестовый метод начинается со слова test или имеет аннотацию @test.
<?php
use PHPUnit\Framework\TestCase;
class CartTest extends TestCase
{
public function testEmptyCartHasZeroTotal(): void
{
$cart = new Cart();
$this->assertEquals(0, $cart->getTotal());
}
public function testAddItemIncreasesCount(): void
{
$cart = new Cart();
$cart->addItem('Book', 15.99);
$this->assertEquals(1, $cart->getCount());
}
public function testTotalIsSumOfPrices(): void
{
$cart = new Cart();
$cart->addItem('Book', 15.99);
$cart->addItem('Pen', 2.50);
$this->assertEquals(18.49, $cart->getTotal(), '', 0.01);
}
}
Запускаем тесты командой:
./vendor/bin/phpunit
Если всё зелёное — отлично. Если красное — PHPUnit покажет, какой тест упал и почему.
Основные методы утверждений (assertions), которые вы будете использовать чаще всего:
| Метод | Что проверяет | Пример |
|---|---|---|
| assertEquals | Равенство значений | $this->assertEquals(5, $result); |
| assertTrue / assertFalse | Истина или ложь | $this->assertTrue($user->isActive()); |
| assertCount | Количество элементов в массиве | $this->assertCount(3, $items); |
| assertInstanceOf | Принадлежность к классу | $this->assertInstanceOf(User::class, $user); |
| expectException | Ожидание исключения | $this->expectException(InvalidArgumentException::class); |
/**
* @dataProvider additionProvider
*/
public function testAdd(float $a, float $b, float $expected): void
{
$this->assertEquals($expected, $a + $b);
}
public function additionProvider(): array
{
return [
[1, 1, 2],
[2, 3, 5],
[-1, 1, 0],
[0.1, 0.2, 0.3],
];
}
Моки и стабы: изолируем зависимости
В реальном мире классы редко работают в вакууме. Один класс вызывает другой, тот обращается к базе данных, а база — к файловой системе. Если мы будем тестировать всё это вместе, то получим не юнит-тест, а медленный и хрупкий интеграционный.
Чтобы изолировать тестируемый класс, PHPUnit предоставляет моки (mocks) и стабы (stubs). Проще говоря, это подделки, которые ведут себя как настоящие объекты, но мы сами решаем, что они возвращают.
Допустим, у нас есть сервис, который отправляет email через внешний API:
<?php
interface MailerInterface
{
public function send(string $to, string $subject, string $body): bool;
}
class UserService
{
private MailerInterface $mailer;
public function __construct(MailerInterface $mailer)
{
$this->mailer = $mailer;
}
public function sendWelcomeEmail(string $email): bool
{
return $this->mailer->send($email, 'Welcome', 'Hello!');
}
}
В тесте мы не хотим реально отправлять письма. Создадим мок:
public function testSendWelcomeEmail(): void
{
$mailerMock = $this->createMock(MailerInterface::class);
$mailerMock->expects($this->once())
->method('send')
->with('test@example.com', 'Welcome', 'Hello!')
->willReturn(true);
$service = new UserService($mailerMock);
$result = $service->sendWelcomeEmail('test@example.com');
$this->assertTrue($result);
}
Здесь мы говорим: «Мы ожидаем, что метод send будет вызван ровно один раз с такими-то аргументами и вернёт true». Если это не так — тест упадёт.
Стабы — это упрощённые моки, которые просто возвращают заданное значение, без проверки вызовов. Они полезны, когда нужно, чтобы метод вернул конкретный результат.
$stub = $this->createStub(MailerInterface::class);
$stub->method('send')->willReturn(false);
Интеграционные тесты: проверяем взаимодействие
Юнит-тесты хороши, но они не скажут, что ваша форма регистрации не работает, потому что контроллер не передаёт данные в базу. Интеграционные тесты проверяют, как разные части системы работают вместе.
Самый частый сценарий — тестирование репозитория с реальной базой данных. Для этого можно использовать SQLite в памяти, чтобы тесты были быстрыми и не зависели от внешней БД.
Настроим подключение к SQLite в памяти и создадим таблицу:
<?php
use PHPUnit\Framework\TestCase;
use PDO;
class UserRepositoryTest extends TestCase
{
private PDO $pdo;
private UserRepository $repository;
protected function setUp(): void
{
$this->pdo = new PDO('sqlite::memory:');
$this->pdo->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT, name TEXT)');
$this->repository = new UserRepository($this->pdo);
}
public function testSaveUser(): void
{
$user = new User('john@example.com', 'John');
$this->repository->save($user);
$stmt = $this->pdo->query('SELECT * FROM users WHERE email = "john@example.com"');
$row = $stmt->fetch(PDO::FETCH_ASSOC);
$this->assertEquals('John', $row['name']);
}
}
Метод setUp вызывается перед каждым тестом. Здесь мы создаём чистое окружение, чтобы тесты не влияли друг на друга.
Для более сложных сценариев, например, тестирования контроллера, можно использовать фреймворк Laravel или Symfony, которые предоставляют свои инструменты для интеграционного тестирования. Но даже без фреймворка можно проверить, что ваш код правильно работает с базой.
Практические советы и типичные ошибки
Тестирование — это навык, который приходит с опытом. Вот несколько советов, которые сэкономят вам часы отладки.
1. Не тестируйте всё подряд. Если метод просто возвращает значение свойства — тест не нужен. Тестируйте логику, условия, циклы.
2. Один тест — одна проверка. Если тест упал, вы должны сразу понять, что именно сломалось. Не пихайте в один метод десять assert'ов.
3. Используйте описательные имена. testAddItemIncreasesCount лучше, чем test1.
4. Запускайте тесты перед коммитом. Это как чистить зубы — привычка, которая спасает от больших проблем.
5. Не бойтесь рефакторить тесты. Если тест сложно читать, его нужно переписать. Тесты — это тоже код.
Типичные ошибки новичков:
- Тестирование приватных методов. Тестируйте публичный интерфейс, а приватные методы проверяются через него.
- Зависимость тестов от порядка выполнения. Каждый тест должен быть независимым.
- Использование реальных внешних сервисов (API, почта). Всегда используйте моки.
- Игнорирование покрытия кода. Стремитесь к 70-80%, но не гонитесь за 100%.
Пример простого workflow для GitHub Actions:
name: PHPUnit
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- run: composer install
- run: ./vendor/bin/phpunit
Что в итоге
PHPUnit — это не просто инструмент, это философия. Вы пишете код, который проверяет код. Звучит странно, но это работает. Юнит-тесты дают уверенность в отдельных кусках, интеграционные — в том, что система работает целиком.
Начните с малого: напишите тесты для одной функции. Потом для класса. Потом настройте CI. Через месяц вы не сможете работать без тестов. Это как ремень безопасности — сначала непривычно, потом без него страшно.
Помните: тесты — это инвестиция. Вы тратите время сейчас, чтобы сэкономить его завтра. И да, писать тесты может быть скучно, но отлаживать баги в продакшене в пятницу вечером — ещё скучнее.
Студия WNDER



