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

Фейки адаптерівFake adapters

Фейк адаптера це друга робоча реалізація порту, яка живе в тестах: репозиторій на масиві замість бази, клієнт із заготовленими відповідями замість API. Сценарій працює з ним так само, як з продакшн-адаптером, а тест дивиться на результат.

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

Друга реалізація порту

У гексагональній архітектурі сценарій бачить лише : інтерфейс JobRepository, JobSource, Mailer. У продакшні на його місці стоїть з базою чи HTTP. Для тестів поруч пишуть ще одну реалізацію того самого інтерфейсу: тримає обʼєкти в масиві, фейковий клієнт віддає заготовлені відповіді й записує, що в нього просили. Такий клас кладуть у tests/Support, і ним користуються сотні тестів.

СЛОВНИКПорт. Інтерфейс, через який ядро застосунку говорить зі світом: зберегти вакансію, отримати курс, надіслати лист. Описаний мовою домену й живе поруч з ним.
СЛОВНИКАдаптер. Реалізація порту під конкретну технологію: EloquentJobRepository для бази, DouRssSource для RSS-стрічки DOU.
СЛОВНИКIn-memory репозиторій. Fake репозиторію, що зберігає обʼєкти в масиві PHP. Сам видає id, шукає й фільтрує, як база, але живе лише до кінця тесту.

Для репозиторію fake кращий за mock, бо репозиторій тримає стан. Сценарій синхронізації спершу шукає вакансію, потім зберігає, потім шукає дублі. З моком тест мусить заздалегідь описати кожен виклик і його відповідь, тобто повторити реалізацію. З фейком сценарій викликає що завгодно й у будь-якому порядку, а тест перевіряє, що лишилось у репозиторії. Рефакторинг, який не міняє результату, такий тест не ламає.

Друга перевага в тому, що фейк живе в одному місці. Коли в порту зʼявляється новий метод, PHPStan і PHP самі покажуть, що InMemoryJobRepository його не реалізує, і ви додасте його один раз. Сто моків, розкиданих по тестах, нічого не покажуть, доки їх не запустять.

Фейк може розійтися зі справжнім адаптером: Eloquent сортує за датою, а масив віддає в порядку додавання. Від цього рятує , один набір перевірок, який проганяють і на фейку, і на справжньому адаптері. У Pest це dataset з двох фабрик, у PHPUnit абстрактний клас тесту з методом, що створює реалізацію. Фейкову частину запускають з unit-тестами, справжню з базою в tests/Feature.

СЛОВНИККонтрактний тест. Набір перевірок поведінки порту, який запускають на кожній його реалізації. Якщо фейк і справжній адаптер проходять ті самі перевірки, тестам на фейку можна вірити.
02 · МЕЖА

Фейки стають на місце адаптерів

ДІАГРАМА · УСЯ СХЕМА
Кроки: 4
handle()SyncJobsFromSourceTestSyncJobsFromSourcehandle(source)«interface»JobRepositoryfindBySourceRef() · save()«interface»JobSourcefetch()у продакшніEloquentJobRepositoryfake у тестахInMemoryJobRepositoryу продакшніDouRssSourcefake у тестахArraySource
handle()SyncJobsFromSourceTestSyncJobsFromSourcehandle(source)«interface»JobRepositoryfindBySourceRef() · save()«interface»JobSourcefetch()у продакшніEloquentJobRepositoryfake у тестахInMemoryJobRepositoryу продакшніDouRssSourcefake у тестахArraySource
Уся схема. Натисніть → або виберіть крок нижче, щоб пройти по ній частинами.
Абстракція Ключовий учасник Домен Інфраструктура Клієнт
  • SyncJobsFromSourceTest використовує SyncJobsFromSource (handle()).
  • SyncJobsFromSource використовує JobRepository.
  • SyncJobsFromSource використовує JobSource.
  • EloquentJobRepository реалізує JobRepository.
  • InMemoryJobRepository реалізує JobRepository.
  • DouRssSource реалізує JobSource.
  • ArraySource реалізує JobSource.
03 · ВАРІАНТИ

Mock, fake і контракт

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

php SyncJobsTest.php Mock
1it('creates a job on the first sync', function () {
2 $jobs = Mockery::mock(JobRepository::class);
3 $jobs->expects('findBySourceRef')->with('dou', '1')->andReturn(null);
4 $jobs->expects('save')->with(Mockery::type(Job::class));
5
6 (new SyncJobs($jobs))->handle(new ArraySource([
7 new ExternalJob('dou', '1', 'Senior Laravel Engineer'),
8 ]));
9});
ЩО ПЕРЕВІРЯЄ
Послідовність викликів: спершу пошук за id джерела, потім save(). Що саме збереглось, тест не бачить, а другий запуск синхронізації потребував би інших відповідей findBySourceRef(), тобто ще одного опису реалізації.
КОЛИ БРАТИ
Майже ніколи для репозиторію. Mock доречний для порту без стану, де важливий сам виклик: надіслати лист, опублікувати подію.
php SyncJobsTest.php Fake
1it('creates a job once and updates it on the next sync', function () {
2 $jobs = new InMemoryJobRepository;
3 $sync = new SyncJobs($jobs);
4 $source = new ArraySource([new ExternalJob('dou', '1', 'Senior Laravel Engineer')]);
5
6 $sync->handle($source);
7 $sync->handle($source);
8
9 expect($jobs->jobs)->toHaveCount(1)
10 ->and($jobs->jobs[1]->title)->toBe('Senior Laravel Engineer');
11});
ЩО ПЕРЕВІРЯЄ
Стан після двох запусків: вакансія одна, а не дві. Фейк памʼятає перший запуск, тож другий справді йде гілкою оновлення. Як саме SyncJobs шукає й зберігає, тест не цікавить.
КОЛИ БРАТИ
Для сценаріїв, що пишуть і читають через порт: репозиторій, кеш, черга, сховище файлів.
php SyncJobsTest.php Контракт
1// tests/Feature/JobRepositoryContractTest.php
2dataset('job repositories', [
3 'in-memory' => fn () => new InMemoryJobRepository,
4 'eloquent' => fn () => app(EloquentJobRepository::class),
5]);
6
7it('finds a saved job by its source reference', function (JobRepository $jobs) {
8 $jobs->save(new Job('dou', '1', 'Senior Laravel Engineer'));
9
10 expect($jobs->findBySourceRef('dou', '1')?->title)->toBe('Senior Laravel Engineer')
11 ->and($jobs->findBySourceRef('dou', '2'))->toBeNull();
12})->with('job repositories');
ЩО ПЕРЕВІРЯЄ
Поведінку самого порту, однаково для фейка й для бази. Pest викликає кожну фабрику з dataset перед тестом і передає готовий репозиторій, тож тест іде двічі, і падіння показує, яка реалізація розійшлася.
КОЛИ БРАТИ
Для кожного фейка, на який спираються тести домену. Контракт росте разом з портом: новий метод, нова перевірка в цьому файлі.
04 · КОД

Контракт ловить фейк, що розійшовся

PHP 8.5
1<?php
2
3final class Vacancy
4{
5 public function __construct(public string $title, public DateTimeImmutable $publishedAt) {}
6}
7
81// Порт: найновіші вакансії першими.
9interface Vacancies
10{
11 public function save(Vacancy $vacancy): void;
12
13 /** @return list<Vacancy> */
14 public function latest(int $limit): array;
15}
16
17// Сценарій: дайджест для Telegram з трьох найновіших вакансій.
18final class WeeklyDigest
19{
20 public function __construct(private Vacancies $vacancies) {}
21
22 public function text(): string
23 {
24 return implode("\n", array_map(fn (Vacancy $v) => '• '.$v->title, $this->vacancies->latest(3)));
25 }
26}
27
282final class InMemoryVacancies implements Vacancies
29{
30 /** @var list<Vacancy> */
31 private array $items = [];
32
33 public function save(Vacancy $vacancy): void
34 {
35 $this->items[] = $vacancy;
36 }
37
38 public function latest(int $limit): array
39 {
40 $items = $this->items;
41 usort($items, fn (Vacancy $a, Vacancy $b) => $b->publishedAt <=> $a->publishedAt);
42
43 return array_slice($items, 0, $limit);
44 }
45}
46
473// Старий фейк: забув, що порт обіцяє сортування.
48final class NaiveVacancies implements Vacancies
49{
50 /** @var list<Vacancy> */
51 private array $items = [];
52
53 public function save(Vacancy $vacancy): void
54 {
55 $this->items[] = $vacancy;
56 }
57
58 public function latest(int $limit): array
59 {
60 return array_slice($this->items, 0, $limit);
61 }
62}
63
64function check(string $what, bool $ok): void
65{
66 echo $ok ? ' ✓ ' : ' ✗ ', $what, "\n";
67}
68
694// Контракт: ті самі перевірки для будь-якої реалізації порту.
70function vacanciesContract(Vacancies $vacancies): void
71{
72 $vacancies->save(new Vacancy('PHP Developer', new DateTimeImmutable('2026-09-01')));
73 $vacancies->save(new Vacancy('Senior Laravel Engineer', new DateTimeImmutable('2026-09-20')));
74 $vacancies->save(new Vacancy('Symfony Developer', new DateTimeImmutable('2026-09-10')));
75
76 $titles = array_map(fn (Vacancy $v) => $v->title, $vacancies->latest(2));
77
78 check('віддає не більше limit', count($titles) === 2);
79 check('найновіша перша', $titles[0] === 'Senior Laravel Engineer');
80 check('далі за спаданням дати', $titles[1] === 'Symfony Developer');
81}
82
835foreach ([new InMemoryVacancies(), new NaiveVacancies()] as $vacancies) {
84 echo $vacancies::class, "\n";
85 vacanciesContract($vacancies);
86}
87
886// Тест сценарію на фейку, який пройшов контракт.
89$vacancies = new InMemoryVacancies();
90$vacancies->save(new Vacancy('Middle PHP Developer', new DateTimeImmutable('2026-09-15')));
91$vacancies->save(new Vacancy('Team Lead PHP', new DateTimeImmutable('2026-09-18')));
92
93echo "WeeklyDigest\n";
94check('дайджест починається з найновішої', (new WeeklyDigest($vacancies))->text() === "• Team Lead PHP\n• Middle PHP Developer");
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 8–15

    Порт обіцяє порядок: найновіші першими. Ця обіцянка входить у контракт, тож фейк мусить її дотримати.

  2. 2 рядки 28–45

    Фейк на масиві. Він сортує сам, бо так поводиться справжній адаптер з ORDER BY published_at DESC.

  3. 3 рядки 47–62

    Фейк, що розійшовся з портом. Тести сценаріїв на ньому були б зелені, а продакшн показав би інший дайджест.

  4. 4 рядки 69–81

    Контракт описує поведінку порту один раз. У проєкті його запускають і на фейку, і на Eloquent-репозиторії з базою.

  5. 5 рядки 83–86

    Той самий контракт для кожної реалізації. NaiveVacancies провалює дві перевірки з трьох.

  6. 6 рядки 88–94

    Тільки тепер тест сценарію на фейку чогось вартий: фейк поводиться як порт.

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

Синхронізація вакансій без бази

Сценарій SyncJobsFromSource оновлює каталог вакансій через адаптер даних. Його unit-тест збирає сценарій з фейків з tests/Support: InMemoryJobRepository й InMemoryCompanyRepository замість бази, RecordingSyncRunLog записує звіти про запуски, ArraySource віддає заготовлений список вакансій і вміє імітувати збій стрічки, а FrozenClock тримає час. Eloquent-реалізації тих самих портів працюють у feature-тестах: tests/Support/SeedsJobs.php наповнює базу через той самий сценарій, узятий з контейнера.

php SyncJobsFromSourceTest.php фрагмент, скорочено
1function syncSetup(?JobDetailsSource $details = null): array
2{
3 $jobs = new InMemoryJobRepository;← fake замість бази
4 $companies = new InMemoryCompanyRepository;
5 $log = new RecordingSyncRunLog;← spy для журналу запусків
6 $clock = FrozenClock::at('2026-09-02 10:00:00');
7 $sync = $details !== null
8 ? new SyncJobsFromSource($jobs, new JobClassifier, $log, $clock, new ResolveCompany($companies), new LinkDuplicates($jobs), $details)
9 : new SyncJobsFromSource($jobs, new JobClassifier, $log, $clock, new ResolveCompany($companies), new LinkDuplicates($jobs));
10
11 return [$sync, $jobs, $companies, $log, $clock];
12}
13
14it('creates jobs and companies on the first run and updates them on the next', function () {
15 [$sync, $jobs, $companies, $log, $clock] = syncSetup();
16
17 $source = new ArraySource(SourceName::Dou, [← fake джерела вакансій
18 external(SourceName::Dou, '1', 'Senior Laravel Engineer', 'Genesis', 'genesis'),
19 external(SourceName::Dou, '2', 'PHP Developer', 'MacPaw', 'macpaw'),
20 ]);
21
22 $first = $sync->handle($source);
23
24 expect($first->created)->toBe(2)
25 ->and($first->updated)->toBe(0)
26 ->and($jobs->jobs)->toHaveCount(2)← стан фейка, а не виклики
27 ->and($companies->companies)->toHaveCount(2)
28 ->and($log->reports)->toHaveCount(1);
29
30 $clock->advance('+1 hour');← час іде лише за командою
31 $second = $sync->handle($source);
32
33 expect($second->created)->toBe(0)
34 ->and($second->updated)->toBe(2)
35 ->and($jobs->jobs)->toHaveCount(2)
36 ->and($jobs->jobs[1]->lastSeenAt)->toEqual($clock->now());
37});
06 · ТИПОВІ ПОМИЛКИ

Як фейки підводять

✕ 1

Фейк з логікою, якої немає в адаптері

InMemory-репозиторій сам відкидає застарілі вакансії, хоча в продакшні це робить сценарій. Тести зелені, бо фейк виконує чужу роботу, а з Eloquent правило не спрацьовує.

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

✕ 2

Фейк без контракту

Фейк написали рік тому, відтоді Eloquent-адаптер навчився сортувати й пропускати приховані вакансії. Сотні unit-тестів спираються на поведінку, якої в продакшні вже немає.

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

✕ 3

Фейк чужого класу замість власного порту

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

Натомість: спершу власний порт з вузьким інтерфейсом, як ExchangeRates чи JobSource, і фейк саме для нього. Сам HTTP-адаптер перевіряйте з Http::fake() на записаних відповідях справжнього API.

✕ 4

Фейк, що протікає між тестами

In-memory репозиторій зроблено статичним або синглтоном у контейнері, і вакансії з одного тесту зʼявляються в іншому. Тести проходять поодинці й падають разом.

Натомість: створюйте новий фейк у кожному тесті через new або в beforeEach(). Якщо фейк реєструєте в контейнері, робіть це в тесті, як FakeProjectWeb::install() у PHP Ukraine.

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

Fake чи mock

1
Залежність стоїть за вашим власним портом?
Інтерфейс у домені чи застосунку, а не клас з vendor/.
НІ →Спершу виділіть порт. Чужий клас загорніть в адаптер і перевірте його integration-тестом.
2
Сценарій і пише в залежність, і читає з неї?
Репозиторій, кеш, черга, сховище файлів. Лист, раз надісланий, назад не читають.
НІ →Spy або mock: перевірте сам виклик. Для порту без стану фейк зайвий.
3
Ця залежність потрібна багатьом тестам?
Один сценарій з особливим випадком простіше закрити анонімним класом прямо в тесті.
НІ →Анонімний клас з потрібною відповіддю прямо в тесті, як JobDetailsSource у SyncJobsFromSourceTest.
ТАК НА ВСІ →Fake у tests/Support і контрактний тест, що проганяє його разом зі справжнім адаптером.
08 · СЛОВНИК
Порт
Інтерфейс, через який ядро застосунку говорить зі світом: зберегти вакансію, отримати курс, надіслати лист. Описаний мовою домену й живе поруч з ним.
Адаптер
Реалізація порту під конкретну технологію: EloquentJobRepository для бази, DouRssSource для RSS-стрічки DOU.
In-memory репозиторій
Fake репозиторію, що зберігає обʼєкти в масиві PHP. Сам видає id, шукає й фільтрує, як база, але живе лише до кінця тесту.
Контрактний тест
Набір перевірок поведінки порту, який запускають на кожній його реалізації. Якщо фейк і справжній адаптер проходять ті самі перевірки, тестам на фейку можна вірити.
09 · ПЕРЕВІР СЕБЕ

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

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

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

Репозиторій тримає стан, і сценарій звертається до нього кілька разів: знайти, зберегти, знайти знову. Mock змушує описати кожен виклик і відповідь, тобто повторити реалізацію, і ламається від рефакторингу. Fake памʼятає збережене, тож тест перевіряє результат, а один клас служить усім тестам.

Контрактним тестом: одним набором перевірок, який запускають на кожній реалізації порту. У Pest це dataset з фабриками фейка й справжнього адаптера, у PHPUnit абстрактний тест-клас з методом створення. Справжній адаптер перевіряють на базі в tests/Feature, і якщо поведінка розійдеться, впаде саме та реалізація, що відстала.

Фейк порту підміняє залежність на рівні домену: сценарій навіть не знає, що є HTTP. Http::fake() підміняє відповіді на рівні HTTP-клієнта Laravel і перевіряє сам адаптер: URL, заголовки, розбір JSON. Тести сценаріїв пишуть з фейком порту, а адаптер покривають кількома тестами з Http::fake() на записаних відповідях справжнього API.