Почему тесты без mock-объектов — это как готовить без соли
Представьте, что вы пишете приложение, которое отправляет email-уведомления, работает с базой данных и ходит в сторонний API. Как протестировать это без mock-объектов и фикстур в PHP? Вы будете каждый раз отправлять реальные письма, создавать тестовые записи в БД и ждать ответа от внешнего сервиса. Это медленно, ненадёжно и просто глупо.
Здесь на помощь приходят test doubles — заменители реальных объектов. В PHP-экосистеме для этого используется связка: мок-объекты (mock) для проверки взаимодействий и фикстуры (fixtures) для подготовки данных. Давайте разберёмся, как это работает на практике.
Mock vs Stub vs Fake: не путайте тёплое с мягким
Прежде чем нырять в код, давайте расставим точки над i. В мире юнит-тестирования PHP есть три основных типа заменителей:
- Stub (заглушка) — возвращает заранее заданные данные. Вы проверяете, что ваш код обрабатывает эти данные правильно.
- Mock (имитация) — не только возвращает данные, но и проверяет, как ваша система вызывает методы: сколько раз, с какими аргументами, в каком порядке.
- Fake (подделка) — упрощённая рабочая реализация (например, in-memory база данных).
В PHPUnit, который является стандартом де-факто для тестирования PHP, эти понятия часто смешивают. Но мы будем точны: mock-объекты в PHP — это те, где вы задаёте ожидания (expectations).
Пишем первый mock-объект на PHPUnit
Допустим, у нас есть класс Mailer, который отправляет письма. Мы хотим проверить, что сервис регистрации вызывает метод send() ровно один раз с правильными данными.
<?php
use PHPUnit\Framework\TestCase;
class UserRegistrationTest extends TestCase
{
public function testSendWelcomeEmailOnRegistration(): void
{
// Создаём mock для Mailer
$mailer = $this->createMock(Mailer::class);
// Настраиваем ожидание: метод send будет вызван 1 раз
$mailer->expects($this->once())
->method('send')
->with(
$this->equalTo('user@example.com'),
$this->stringContains('Welcome')
);
$service = new UserRegistrationService($mailer);
$service->register('user@example.com', 'password123');
}
}
Обратите внимание: мы не проверяем, что письмо реально ушло. Мы проверяем, что наш код попытался его отправить — это и есть суть mock-объектов в PHP-тестах. Если метод send() не будет вызван (или будет вызван дважды), тест упадёт.
Метод expects и его возможности
PHPUnit предоставляет богатый API для настройки ожиданий:
$this->once()— ровно один вызов$this->exactly(3)— ровно три вызова$this->atLeastOnce()— минимум один вызов$this->never()— метод не должен вызываться
Для аргументов используйте матчеры (constraints):
$mailer->expects($this->once())
->method('send')
->with(
$this->callback(function($email) {
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}),
$this->anything() // нам всё равно на второй аргумент
);
Фикстуры в PHP: готовим данные до того, как они понадобятся
Фикстуры — это предопределённое состояние данных, в котором находится система перед выполнением теста. В PHPUnit для этого используются методы setUp() и tearDown().
Зачем это нужно? Представьте, что вы тестируете репозиторий пользователей. Каждый тест должен начинаться с «чистого листа»: в базе три пользователя, один из них — админ. Без фикстур вам пришлось бы дублировать код в каждом тесте.
Классический пример фикстур с PHPUnit
<?php
use PHPUnit\Framework\TestCase;
class UserRepositoryTest extends TestCase
{
private UserRepository $repository;
protected function setUp(): void
{
// Создаём in-memory SQLite базу (Fake)
$pdo = new PDO('sqlite::memory:');
$pdo->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, role TEXT)');
// Заполняем тестовыми данными (Fixture)
$pdo->exec("INSERT INTO users VALUES (1, 'Alice', 'admin')");
$pdo->exec("INSERT INTO users VALUES (2, 'Bob', 'user')");
$pdo->exec("INSERT INTO users VALUES (3, 'Charlie', 'user')");
$this->repository = new UserRepository($pdo);
}
public function testFindAdmin(): void
{
$admin = $this->repository->findByRole('admin');
$this->assertCount(1, $admin);
$this->assertEquals('Alice', $admin[0]->name);
}
public function testCountAllUsers(): void
{
$users = $this->repository->findAll();
$this->assertCount(3, $users);
}
protected function tearDown(): void
{
// Очищаем после теста (опционально, т.к. БД in-memory)
parent::tearDown();
}
}
Метод setUp() вызывается перед каждым тестом. Это гарантирует изоляцию тестов — один тест не влияет на другой. Если вам нужно подготовить данные один раз для всех тестов класса, используйте setUpBeforeClass() с модификатором static.
Продвинутые техники: Mock Builder и возврат значений
Иногда вам нужно, чтобы mock-объект в PHP возвращал разные значения при последовательных вызовах. Или выбрасывал исключение при определённых условиях. Для этого используйте Mock Builder.
<?php
class PaymentGatewayTest extends TestCase
{
public function testRetryOnFailure(): void
{
// Создаём mock через Mock Builder
$gateway = $this->getMockBuilder(PaymentGateway::class)
->onlyMethods(['charge'])
->getMock();
// Первый вызов — ошибка, второй — успех
$gateway->method('charge')
->willReturnOnConsecutiveCalls(
new Payment