PHPUnit: как выбрать между data provider, mock и test double
Один и тот же сценарий в PHPUnit можно покрыть тремя способами — параметризацией, заглушкой или полноценным моком. Разбираем, что выигрываете и чем платите в каждом.

Вы пишете тест для сервиса, который дёргает платёжный шлюз и пишет результат в базу. Как только доходит до реальных зависимостей, встаёт вопрос: подставить фиктивный объект, полноценный мок с проверкой вызовов или прогнать один метод через набор входных данных. PHPUnit даёт все три инструмента, и выбор не косметический — от него зависит, будет ли тест ловить регрессии или ломаться при каждом рефакторинге. Разбираемся на PHPUnit 11.x (актуальная линейка на конец 2024 года), где часть старых методов уже удалена.
Что вообще выбираем
Три частых развилки при написании юнит-теста в PHPUnit:
- Data provider — один тест-метод прогоняется на множестве наборов входных данных. Про то, как метод реагирует на разные аргументы.
- Stub (заглушка) — фиктивный объект, который на вызов метода возвращает заранее заданное значение. Про то, как ваш код ведёт себя при определённом ответе зависимости.
- Mock — тот же фиктивный объект, но с проверкой: был ли метод вызван, сколько раз и с какими аргументами. Про взаимодействие вашего кода с зависимостью.
Часто их путают, потому что stub и mock в PHPUnit создаются похоже, а разница — в том, что вы проверяете. Test double1 — общий термин для обоих.
Data provider: параметризация вместо копипасты
Если у вас пять почти одинаковых тест-методов, отличающихся только входом и ожидаемым результатом, — это кандидат на data provider. В PHPUnit 10+ провайдер задаётся атрибутом #[DataProvider], старая аннотация @dataProvider в PHPUnit 11 помечена deprecated и будет удалена.
<?php
// PHPUnit 11.x
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
final class PriceCalculatorTest extends TestCase
{
#[DataProvider('discountCases')]
public function testApplyDiscount(int $price, int $percent, int $expected): void
{
$calculator = new PriceCalculator();
$this->assertSame($expected, $calculator->applyDiscount($price, $percent));
}
// Провайдер — статический метод, возвращает наборы аргументов
public static function discountCases(): array
{
return [
'без скидки' => [1000, 0, 1000],
'скидка 10%' => [1000, 10, 900],
'скидка 100%' => [1000, 100, 0],
];
}
}
Именованные ключи массива попадают в вывод при падении: вы сразу видите «скидка 100%», а не «set #2». В PHPUnit 10+ метод-провайдер должен быть статическим — нестатический вызовет ошибку.
Stub: фиксируем ответ зависимости
Тестируемому сервису нужен репозиторий, но лезть в реальную базу в юнит-тесте не нужно. Подставляем заглушку, которая на findById() вернёт готовый объект.
<?php
// PHPUnit 11.x
use PHPUnit\Framework\TestCase;
final class OrderServiceTest extends TestCase
{
public function testReturnsTotalForExistingOrder(): void
{
// createStub — заглушка без проверки вызовов
$repository = $this->createStub(OrderRepository::class);
$repository->method('findById')
->willReturn(new Order(id: 42, total: 1500));
$service = new OrderService($repository);
$this->assertSame(1500, $service->getTotal(42));
}
}
Здесь неважно, сколько раз сервис дёрнул репозиторий. Важен только результат при заданном ответе. Это stub.
Mock: проверяем сам факт взаимодействия
Другой случай: вы хотите убедиться, что при отмене заказа сервис ровно один раз вызвал notify() у сервиса уведомлений с правильным аргументом. Тут ответ зависимости неважен — важен сам вызов.
<?php
// PHPUnit 11.x
use PHPUnit\Framework\TestCase;
final class CancelOrderTest extends TestCase
{
public function testSendsExactlyOneNotification(): void
{
// createMock — с ожиданиями по вызовам
$notifier = $this->createMock(Notifier::class);
$notifier->expects($this->once())
->method('notify')
->with($this->equalTo('order.cancelled'));
$service = new OrderService($this->createStub(OrderRepository::class), $notifier);
$service->cancel(42);
// Проверка ожиданий произойдёт автоматически в teardown
}
}
Разница с stub — в expects($this->once()). Если notify() не вызвался или вызвался дважды, тест упадёт, даже если все ассерты выполнены. В этом и цена: mock завязан на внутреннюю реализацию.
Цена каждого подхода
Инструмент выбирается не по привычке, а по тому, что именно проверяем и чем готовы платить за это в хрупкости теста.
| Подход | Что проверяет | Выигрыш | Плата |
|---|---|---|---|
| Data provider | реакцию на разные входы | нет копипасты, понятный вывод по имени набора | провайдер статический, тяжело отлаживать один набор |
| Stub | поведение кода при заданном ответе | изоляция от БД и сети, устойчив к рефакторингу зависимости | не заметит, что вы вообще перестали вызывать зависимость |
| Mock | факт и параметры вызова | ловит регрессию во взаимодействии | ломается при любом изменении внутренней логики вызовов |
Mock проверяет, как вы делаете; stub — что получается. Чем больше в тестах mock-ожиданий, тем сильнее они цементируют текущую реализацию и тем больнее любой рефакторинг.
Когда что выбирать
Критерий простой — что вы хотите зафиксировать как контракт.
- Один метод, много входных данных, чистая логика без зависимостей — data provider. Валидаторы, форматтеры, калькуляторы.
- Зависимость возвращает данные, а вы проверяете результат вашего кода — stub. Репозитории, кеши, конфиги.
- Важен сам факт побочного эффекта: отправка письма, запись в очередь, вызов внешнего API — mock. Здесь проверить нечего, кроме взаимодействия.
Правило по умолчанию: берите stub, а mock — только когда взаимодействие и есть суть теста. Если сомневаетесь между ними, значит вам нужен stub.
Комбинация в одном тесте
Data provider свободно сочетается со stub и mock. Например, прогоняете один сценарий отмены заказа с разными статусами через провайдер, а зависимости при этом подставляете заглушками. Смешивать провайдер и mock тоже можно, но следите, чтобы ожидания expects() не зависели от набора данных — иначе тест станет нечитаемым.
Что ломается
Типичные грабли, на которые наступают при выборе test double.
- Mock там, где хватило бы stub. Тест проходит, но при рефакторинге, не меняющем поведение, падает десяток тестов на несуществующих проблемах. Признак: в тесте есть
expects(), но по сути вас интересует только возвращаемое значение. - Провайдер как нестатический метод. В PHPUnit 10+ это ошибка. Текст: Data Provider method ... is not static. Сделайте метод
public static. - Старая аннотация
@dataProvider. В PHPUnit 11 работает с предупреждением о deprecation, в следующей мажорной версии перестанет. Переходите на атрибут#[DataProvider]. - Mock на классе с final-методами. PHPUnit не может переопределить
finalметод, мок вернёт исходную реализацию без предупреждения. Выносите зависимость за интерфейс. - Изменяемые данные в провайдере. Провайдер вычисляется один раз до тестов. Если внутри создаёте объект и мутируете его в одном наборе, другие наборы получат изменённое состояние. Возвращайте свежие данные для каждого набора.
Что настроить дальше
Когда с выбором double разобрались, смотрите в сторону инфраструктуры:
- Включите строгий режим в
phpunit.xml: падение на рискованных тестах и на непокрытых ожиданиях mock ловит забытые ассерты. - Настройте покрытие через Xdebug или PCOV и смотрите не на общий процент, а на то, покрыты ли ветки в критичной логике.
- Если mock-ожиданий в проекте становится слишком много, это сигнал пересмотреть архитектуру, а не тесты — обычно за этим стоит слишком связный код.
Интеграционные тесты с реальной БД и HTTP — отдельная тема, здесь мы разбирали только изоляцию зависимостей в юнит-тестах.
1 Test double — «дублёр» тестируемого объекта: фиктивная замена реальной зависимости. Общий термин, под который попадают stub, mock, spy, fake и dummy.
Сценарист праздников: Тосты, квесты, сценарии корпоративов и дней рождения.
Спросить за 8 ₽Источники: PHPUnit Documentation — Test Doubles, PHPUnit Documentation — Data Providers
Частые вопросы
У меня PHPUnit 9, атрибут #[DataProvider] не работает — что делать?
Тест падает с ошибкой «Data Provider method is not static» — почему?
Чем createStub отличается от createMock, если оба создают объект?
Мок не срабатывает на методе, хотя я задал expects — в чём причина?
Можно ли использовать data provider вместе с mock в одном тесте?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.