Test double це загальна назва для обʼєкта, що в тесті стає на місце справжньої залежності й має той самий інтерфейс. Назви ролей походять з книжки Джерарда Месароша (Gerard Meszaros) «xUnit Test Patterns» 2007 року, а широко відомими їх зробила стаття Мартіна Фаулера (Martin Fowler) «Mocks Aren't Stubs». На співбесіді чекають саме цих пʼяти ролей, бо плутанина між ними і є головною причиною крихких тестів.
Dummy лише заповнює параметр: конструктор вимагає логер, але сценарій до нього не доходить. Stub повертає заготовлені відповіді й нічого не памʼятає: existsByEmail() завжди false. Spy це stub, який ще й записує, як його викликали, а тест читає ці записи після дії. Mock отримує очікування до дії («рівно один виклик send() з такими аргументами») і сам валить тест, якщо їх не виконано або прийшов зайвий виклик. Fake це робоча, але спрощена реалізація: репозиторій на масиві, що справді памʼятає збережене, або поштова скринька в памʼяті.
Головна різниця в тому, що саме перевіряє тест. Перевірка стану дивиться на результат: що повернув метод і що лежить у фейковому репозиторії. Перевірка поведінки дивиться на виклики залежностей. Stub і fake ведуть до першої, spy і mock до другої. Перевірка стану переживає рефакторинг: якщо save() замінили на saveMany(), користувач однаково лежить у репозиторії. Перевірка викликів знає, як код працює всередині, і падає від такої заміни, хоча поведінка не змінилась.
Звідси практичне правило: перевіряйте стан, коли можете, а виклики лише там, де дія не лишає сліду, який можна прочитати, як лист, подія чи запит до чужого API. Для запитів-читань ставте stub, а не очікування: питання «скільки разів код викликав find()» зазвичай нікого не цікавить. Між spy і mock різниця в порядку: зі spy тест іде звично (підготували, зробили, перевірили), mock строгіший і ловить неочікувані виклики одразу.
Інструмент не визначає роль. Mockery::mock() з одним allows() працює як stub, той самий обʼєкт без жодного дозволеного методу як dummy, що впаде, якщо сценарій його все ж торкнеться. Фейки Laravel (Mail::fake(), Queue::fake()) за класифікацією Месароша ближчі до spy: вони записують відправлене, а тест перевіряє його після дії. У PHPUnit для stub є createStub(), для mock createMock() з expects(), і починаючи з PHPUnit 12.5 мок без очікувань дає notice з порадою взяти stub.
// Registration: перевіряє адресу в UserRepository, зберігає, шле лист через Mailer.
it('rejects a taken email', function () {
$users = Mockery::mock(UserRepository::class);
$users->allows('existsByEmail')->andReturn(true); // stub: лише відповідь
$mailer = Mockery::mock(Mailer::class); // dummy: до send() не дійде
expect(fn () => (new Registration($users, $mailer))->register('olena@example.com'))
->toThrow(DomainException::class);
});
it('sends a welcome email', function () {
$users = new InMemoryUsers; // fake: справжня логіка в масиві
$mailer = Mockery::spy(Mailer::class); // spy: записує всі виклики
(new Registration($users, $mailer))->register('olena@example.com');
expect($users->existsByEmail('olena@example.com'))->toBeTrue(); // стан
$mailer->shouldHaveReceived('send') // поведінка
->once()
->with('olena@example.com', 'Вітаємо на PHP Ukraine');
});
it('sends a welcome email, mock style', function () {
$mailer = Mockery::mock(Mailer::class);
$mailer->expects('send') // mock: очікування до дії,
->with('olena@example.com', 'Вітаємо на PHP Ukraine'); // рівно один виклик
(new Registration(new InMemoryUsers, $mailer))->register('olena@example.com');
});
Скажіть одне речення про кожну роль на одному прикладі, скажімо реєстрації: dummy-логер, якого сценарій не торкається; stub репозиторію, що відповідає «адреса вільна»; spy пошти, у якому після дії перевіряємо лист; mock пошти з очікуванням до дії; fake-репозиторій на масиві. Потім додайте правило: стан перевіряю завжди, коли можу, виклики лише для дій без сліду в стані.