<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
ТЕСТУВАННЯ · ОСНОВИ

Test doubles

Обʼєкт, який у тесті стає на місце справжньої залежності. Ролей у нього пʼять, і саме плутанина між ними робить тести крихкими.

11 хв читання Junior → Middle
01 · ЩО ЦЕ

Дублер замість справжньої залежності

Реєстрація користувача зберігає його в базі й надсилає вітальний лист. У unit-тесті не потрібні ні база, ні пошта: замість них стає , обʼєкт з тим самим інтерфейсом, який поводиться так, як треба тесту. Назва з кіно: каскадер-дублер (stunt double) стрибає з даху замість актора.

СЛОВНИКTest double. Загальна назва для будь-якого обʼєкта, що в тесті замінює справжню залежність. Має той самий інтерфейс, тож код, який тестуємо, різниці не помічає.

Слова , , , і походять з книжки Джерарда Месароша (Gerard Meszaros) «xUnit Test Patterns» 2007 року, а відомими їх зробила стаття Мартіна Фаулера (Martin Fowler) «Mocks Aren't Stubs». Бібліотеки вживають їх вільніше: Mockery::mock() створює обʼєкт, який стає stub або mock залежно від того, що ви з ним робите.

СЛОВНИКDummy. Заповнює параметр і ніколи не використовується. Якщо тест до нього дійшов, це помилка.
СЛОВНИКStub. Повертає заготовлені відповіді: existsByEmail() завжди false. Про виклики нічого не памʼятає.
СЛОВНИКSpy. Stub, який ще й записує, як його викликали. Тест перевіряє ці записи після дії.
СЛОВНИКMock. Отримує очікування до дії: має бути один виклик send() з такими аргументами. Сам падає, якщо очікування не справдилось.
СЛОВНИКFake. Робоча, але спрощена реалізація: репозиторій у масиві замість бази, скринька в памʼяті замість SMTP. Для продакшну не годиться.

Головна різниця в тому, що саме перевіряє тест. дивиться на результат: що повернув метод, що лежить у репозиторії. дивиться на виклики: чи викликали send() рівно раз і з якими аргументами. Перша спокійно переживає рефакторинг, друга від нього ламається, зате бачить дії, які не лишають сліду в стані, як-от надісланий лист.

СЛОВНИКПеревірка стану. State verification: тест дивиться, що повернув код і що лишилось після нього. Як саме код дійшов до результату, тесту байдуже.
СЛОВНИКПеревірка поведінки. Behaviour verification: тест дивиться, які методи залежностей викликали, скільки разів і з якими аргументами.
02 · МЕЖА

Дублер стоїть там, де клас говорить зі світом

ДІАГРАМА · УСЯ СХЕМА
Кроки: 4
register()RegistrationTestRegistrationregister(email)«interface»UserRepositorysave() · existsByEmail()«interface»Mailersend(to, subject)у продакшніEloquentUsersfake у тестіInMemoryUsersу продакшніSmtpMailerspy у тестіSpyMailer
register()RegistrationTestRegistrationregister(email)«interface»UserRepositorysave() · existsByEmail()«interface»Mailersend(to, subject)у продакшніEloquentUsersfake у тестіInMemoryUsersу продакшніSmtpMailerspy у тестіSpyMailer
Уся схема. Натисніть → або виберіть крок нижче, щоб пройти по ній частинами.
Абстракція Ключовий учасник Домен Інфраструктура Клієнт
  • RegistrationTest використовує Registration (register()).
  • Registration використовує UserRepository.
  • Registration використовує Mailer.
  • EloquentUsers реалізує UserRepository.
  • InMemoryUsers реалізує UserRepository.
  • SmtpMailer реалізує Mailer.
  • SpyMailer реалізує Mailer.
03 · ВАРІАНТИ

Один сценарій, пʼять дублерів

Registration реєструє адресу: перевіряє, що її ще немає в UserRepository, зберігає користувача й надсилає лист через Mailer. Перемикайте вкладки: залежності ті самі, міняється лише те, що підставляє і що перевіряє тест.

php RegistrationTest.php Dummy
1it('rejects an email that is already taken', function () {
2 $users = Mockery::mock(UserRepository::class);
3 $users->allows('existsByEmail')->andReturn(true);
4 $mailer = Mockery::mock(Mailer::class); // dummy: лише заповнює параметр
5
6 $registration = new Registration($users, $mailer);
7
8 expect(fn () => $registration->register('olena@example.com'))
9 ->toThrow(DomainException::class);
10});
ЩО ПЕРЕВІРЯЄ
Що зайняту адресу відхилено. Mailer тут dummy: конструктор без нього не обійдеться, але до send() сценарій не доходить. Mockery::mock() без жодного дозволеного методу ще й підстрахує: якщо send() усе ж викличуть, тест упаде.
КОЛИ БРАТИ
Залежність обовʼязкова, але конкретний сценарій її не торкається.
php RegistrationTest.php Stub
1it('returns the registered user', function () {
2 $users = Mockery::mock(UserRepository::class);
3 $users->allows('existsByEmail')->andReturn(false); // stub: заготовлена відповідь
4 $users->allows('save');
5 $mailer = Mockery::mock(Mailer::class);
6 $mailer->allows('send');
7
8 $user = (new Registration($users, $mailer))->register('olena@example.com');
9
10 expect($user->email)->toBe('olena@example.com');
11});
ЩО ПЕРЕВІРЯЄ
Лише результат register(). Відповідь existsByEmail() заготовлена заздалегідь, а save() і send() дозволені, але тест не питає, чи їх викликали.
КОЛИ БРАТИ
Коду потрібна відповідь залежності, щоб дійти до результату, який ви перевіряєте.
php RegistrationTest.php Spy
1it('sends a welcome email', function () {
2 $users = Mockery::mock(UserRepository::class);
3 $users->allows(['existsByEmail' => false, 'save' => null]);
4 $mailer = Mockery::spy(Mailer::class); // spy: записує всі виклики
5
6 (new Registration($users, $mailer))->register('olena@example.com');
7
8 $mailer->shouldHaveReceived('send')
9 ->once()
10 ->with('olena@example.com', 'Вітаємо на PHP Ukraine');
11});
ЩО ПЕРЕВІРЯЄ
Поведінку: send() викликали рівно раз з адресою й темою. Перевірка стоїть після дії, тож тест читається звично: підготували, зробили, перевірили.
КОЛИ БРАТИ
Дія не лишає сліду в стані, який можна прочитати, як-от лист, подія чи запит до API.
php RegistrationTest.php Mock
1it('sends a welcome email', function () {
2 $users = Mockery::mock(UserRepository::class);
3 $users->allows(['existsByEmail' => false, 'save' => null]);
4 $mailer = Mockery::mock(Mailer::class);
5 $mailer->expects('send') // mock: очікування до дії
6 ->with('olena@example.com', 'Вітаємо на PHP Ukraine');
7
8 (new Registration($users, $mailer))->register('olena@example.com');
9});
ЩО ПЕРЕВІРЯЄ
Те саме, що й spy, але очікування описане до дії. expects() означає «рівно один виклик», і Mockery перевіряє його, коли тест закінчується. Тому тест без жодного expect() теж щось перевіряє.
КОЛИ БРАТИ
Як і spy, для дій без сліду в стані. Mock строгіший: зайвий виклик з іншими аргументами впаде одразу, а не на перевірці.
php RegistrationTest.php Fake
1it('registers an email once', function () {
2 $users = new InMemoryUsers;
3 $mailer = new SpyMailer;
4 $registration = new Registration($users, $mailer);
5
6 $registration->register('olena@example.com');
7
8 expect($users->existsByEmail('olena@example.com'))->toBeTrue()
9 ->and(fn () => $registration->register('olena@example.com'))
10 ->toThrow(DomainException::class)
11 ->and($mailer->sent)->toHaveCount(1);
12});
ЩО ПЕРЕВІРЯЄ
Стан після дії: користувач лежить у фейковому репозиторії, а друга реєстрація тієї самої адреси падає, бо фейк справді памʼятає першу. Як Registration викликала репозиторій, тест не цікавить.
КОЛИ БРАТИ
Залежність тримає дані, які сценарій пише й читає: репозиторій, кеш, черга. Один fake служить сотням тестів, а InMemoryUsers і SpyMailer ви побачите в коді нижче.
04 · КОД

Запустіть і подивіться

PHP 8.5
1<?php
2
3final class User
4{
5 public function __construct(public string $email) {}
6}
7
81interface UserRepository
9{
10 public function save(User $user): void;
11
12 public function existsByEmail(string $email): bool;
13}
14
15interface Mailer
16{
17 public function send(string $to, string $subject): void;
18}
19
20final class Registration
21{
22 public function __construct(private UserRepository $users, private Mailer $mailer) {}
23
24 public function register(string $email): User
25 {
26 if ($this->users->existsByEmail($email)) {
27 throw new DomainException("Адреса {$email} уже зайнята");
28 }
29
30 $user = new User($email);
31 $this->users->save($user);
32 $this->mailer->send($email, 'Вітаємо на PHP Ukraine');
33
34 return $user;
35 }
36}
37
382// Fake: справжня логіка репозиторію, але в масиві замість бази.
39final class InMemoryUsers implements UserRepository
40{
41 /** @var array<string, User> */
42 private array $users = [];
43
44 public function save(User $user): void
45 {
46 $this->users[$user->email] = $user;
47 }
48
49 public function existsByEmail(string $email): bool
50 {
51 return isset($this->users[$email]);
52 }
53}
54
553// Spy: нічого не надсилає, лише записує кожен виклик.
56final class SpyMailer implements Mailer
57{
58 /** @var list<array{string, string}> */
59 public array $sent = [];
60
61 public function send(string $to, string $subject): void
62 {
63 $this->sent[] = [$to, $subject];
64 }
65}
66
67function check(string $what, bool $ok): void
68{
69 echo $ok ? '✓ ' : '✗ ', $what, "\n";
70}
71
724$users = new InMemoryUsers();
73$mailer = new SpyMailer();
74$registration = new Registration($users, $mailer);
75
76$registration->register('olena@example.com');
77
78check('користувача збережено', $users->existsByEmail('olena@example.com'));
79check('надіслано один лист', count($mailer->sent) === 1);
80check('лист пішов на olena@example.com', $mailer->sent[0][0] === 'olena@example.com');
81
825try {
83 $registration->register('olena@example.com');
84 check('зайняту адресу відхилено', false);
85} catch (DomainException) {
86 check('зайняту адресу відхилено', true);
87}
88
89check('другого листа немає', count($mailer->sent) === 1);
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 8–18

    Registration залежить від двох інтерфейсів. Саме на їхнє місце стають дублери, тому класи з new всередині так важко тестувати.

  2. 2 рядки 38–53

    Fake: справжня логіка репозиторію, лише в масиві. Повторну адресу він помітить так само, як база.

  3. 3 рядки 55–65

    Spy: нічого не надсилає, а складає виклики в масив, щоб тест прочитав їх після дії.

  4. 4 рядки 72–80

    Підготували, зробили, перевірили. Перевірки дивляться в стан фейка й у записи спая.

  5. 5 рядки 82–89

    Друга реєстрація тієї самої адреси: виняток, і другого листа немає.

  6. Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
05 · ЯК У PHP UKRAINE

InMemoryJobRepository у наших тестах

Оновлення каталогу вакансій тестують без бази. Use case SyncJobsFromSource отримує InMemoryJobRepository: той реалізує доменний порт JobRepository, тримає вакансії в масиві й сам видає id, як це зробила б база. Тест перевіряє стан: expect($jobs->jobs)->toHaveCount(2).

php InMemoryJobRepository.php фрагмент, скорочено
1final class InMemoryJobRepository implements JobRepository← доменний порт
2{
3 /** @var array<int, Job> */
4 public array $jobs = [];← стан видно тесту
5
6 private int $nextId = 1;
7
8 public function findById(int $id): ?Job
9 {
10 return $this->jobs[$id] ?? null;
11 }
12
13 public function findBySourceRef(SourceName $source, string $sourceId): ?Job
14 {
15 foreach ($this->jobs as $job) {
16 if ($job->source === $source && $job->sourceId === $sourceId) {
17 return $job;
18 }
19 }
20
21 return null;
22 }
23
24 public function save(Job $job): void
25 {
26 if ($job->id === null) {
27 $job->assignId($this->nextId++);← id, як у бази
28 }
29
30 $this->jobs[(int) $job->id] = $job;
31 }
32}
06 · ТИПОВІ ПОМИЛКИ

Як дублери псують тести

✕ 1

Мок на кожну залежність

Тест підміняє все, крім класу, який перевіряє, і описує кожен виклик. Він повторює реалізацію рядок за рядком і падає від будь-якого рефакторингу, навіть коли поведінка не змінилась.

Натомість: підміняйте лише те, що повільне, недетерміноване або має побічні ефекти: базу, мережу, пошту, час. Value objects і доменні класи беріть справжні.

✕ 2

Мок класу, яким ви не володієте

Тест мокає HTTP-клієнт Guzzle чи SDK платіжної системи й сам вирішує, що вони повернуть. Коли бібліотека змінить поведінку, мок цього не помітить, і зелений тест бреше.

Натомість: загорніть чужий код у власний адаптер з вузьким інтерфейсом, як PaymentGateway. Домен тестуйте з фейком цього адаптера, а сам адаптер integration-тестом проти справжньої бібліотеки або з Http::fake().

✕ 3

Перевірка викликів замість результату

expects('save')->once() там, де можна просто подивитися в репозиторій. Такий тест знає, як код працює всередині, а не що він робить, і ламається, щойно save() замінять на saveMany().

Натомість: спершу перевіряйте стан: що повернулось і що збереглось. Перевірку викликів лишайте для дій без сліду в стані: лист, подія, запит до API.

✕ 4

Фейк, що розійшовся зі справжнім адаптером

InMemory-репозиторій віддає вакансії в порядку додавання, а Eloquent-репозиторій сортує за датою. Тести на фейку зелені, а на продакшні список інший.

Натомість: проганяйте один набір контрактних тестів і на фейку, і на справжньому адаптері, а коли змінюється порт, оновлюйте обидва в тому самому коміті.

07 · ДЕРЕВО РІШЕНЬ

Який дублер узяти

1
Сценарій узагалі викликає цю залежність?
Логер чи пошта потрібні конструктору, але тест про відхилену адресу до них не доходить.
НІ →Dummy. Передайте будь-що потрібного типу, хоч Mockery::mock() без дозволених методів.
2
Тесту важливо, що сталося із залежністю, а не лише що вона відповіла?
Чи надіслано лист, чи збережено користувача, чи пішла подія.
НІ →Stub. Заготовте відповідь і перевіряйте результат методу.
3
Залежність тримає дані, які сценарій сам і читає?
Репозиторій, кеш, черга. Лист, раз надісланий, назад не читають.
НІ →Spy або mock: перевірте виклик. Spy читається природніше, бо перевірка стоїть після дії.
ТАК НА ВСІ →Fake. In-memory реалізація порту, а тест дивиться на її стан.
08 · СЛОВНИК
Test double
Загальна назва для будь-якого обʼєкта, що в тесті замінює справжню залежність. Має той самий інтерфейс, тож код, який тестуємо, різниці не помічає.
Dummy
Заповнює параметр і ніколи не використовується. Якщо тест до нього дійшов, це помилка.
Stub
Повертає заготовлені відповіді: existsByEmail() завжди false. Про виклики нічого не памʼятає.
Spy
Stub, який ще й записує, як його викликали. Тест перевіряє ці записи після дії.
Mock
Отримує очікування до дії: має бути один виклик send() з такими аргументами. Сам падає, якщо очікування не справдилось.
Fake
Робоча, але спрощена реалізація: репозиторій у масиві замість бази, скринька в памʼяті замість SMTP. Для продакшну не годиться.
Перевірка стану
State verification: тест дивиться, що повернув код і що лишилось після нього. Як саме код дійшов до результату, тесту байдуже.
Перевірка поведінки
Behaviour verification: тест дивиться, які методи залежностей викликали, скільки разів і з якими аргументами.
09 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з тестування →

Stub лише повертає заготовлені відповіді, а тест перевіряє результат коду, тобто стан. Mock несе очікування щодо викликів, і тест падає, якщо виклику не було або аргументи інші: це перевірка поведінки. Той самий Mockery::mock() стає stub або mock залежно від того, що ви з ним робите.

Mock отримує очікування до дії, spy записує все й перевіряється після неї. Зі spy тест іде звичним порядком: підготували, зробили, перевірили, а mock строгіший і падає вже на неочікуваному виклику.

Коли залежність тримає стан, який сценарій пише й читає: репозиторій, кеш, черга. Тест з фейком перевіряє результат, а не послідовність викликів, і не ламається від рефакторингу. Ціна: fake треба підтримувати разом з портом і перевіряти тими самими контрактними тестами, що й справжній адаптер.

Мок фіксує ваше припущення про чужий API, і коли бібліотека змінить поведінку, тест цього не побачить. Правило походить з книжки «Growing Object-Oriented Software, Guided by Tests» Стіва Фрімена й Ната Прайса. Чужий код загортають у власний адаптер з вузьким інтерфейсом, домен тестують з його фейком, а сам адаптер перевіряють integration-тестом.