Мок-объекты и фикстуры в PHP-тестах: как не сойти с ума при написании юнит-тестов

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Почему тесты без 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                

Блоги

Рекомендаций по книгам