Підміняти варто те, що заважає тесту бути швидким і передбачуваним: базу, мережу, файлову систему, пошту, час і випадковість. Усе, що живе в памʼяті й не має побічних ефектів, краще брати справжнім. Value object Money чи доменна сутність створюються через new за мікросекунди, а мок замість них перевіряє лише власну заглушку: тест з allows('add')->andReturn(...) доводить, що Mockery вміє повертати значення, а не що гроші додаються правильно.
Друге правило походить з книжки Стіва Фрімена (Steve Freeman) і Ната Прайса (Nat Pryce) «Growing Object-Oriented Software, Guided by Tests»: не мокайте типи, якими не володієте. Мок Guzzle чи SDK платіжки фіксує ваше уявлення про те, як вони поводяться. Коли бібліотека після оновлення змінить формат відповіді чи кине інший виняток, мок цього не помітить, і зелений тест буде брехати. Крім того, ланцюжки на кшталт getBody()->getContents() у моках повторюють реалізацію клієнта рядок за рядком.
Замість цього чужий код загортають у власний порт з вузьким інтерфейсом мовою домену: ExchangeRates::usdToUah(), PaymentGateway::charge(). Адаптер у шарі Infrastructure знає про HTTP і JSON, а сценарій лише про порт. Тоді тести домену отримують простий фейк порту, а адаптер перевіряють окремо: у Laravel через Http::fake() з записаними відповідями справжнього API і Http::preventStrayRequests(), у Symfony через MockHttpClient.
Який дублер узяти для власного порту, залежить від того, чи тримає він стан. Репозиторій, кеш, черга: сценарій пише в них і читає, тож fake на масиві чесніший і стійкіший за mock, де треба заздалегідь описати кожен виклик. Порт без стану, як пошта чи шина подій, закривається spy: важливий сам факт виклику. Модель Eloquent і query builder не мокають узагалі: код, що будує запити, тестують на справжній тестовій базі.
Фейк має свою ціну: він може розійтися зі справжнім адаптером. Eloquent-репозиторій сортує за датою, а масив віддає в порядку додавання, і сотні тестів спираються на поведінку, якої в продакшні немає. Від цього рятує контрактний тест, один набір перевірок, який проганяють і на фейку, і на адаптері з базою. Змінилась поведінка порту, змінився контракт, і він сам покаже, яка реалізація відстала.
// Порт мовою домену: про Guzzle і JSON сценарій нічого не знає.
interface ExchangeRates
{
public function usdToUah(DateTimeImmutable $day): Money;
}
// Фейк порту в tests/Support: проста робоча реалізація.
final class FixedExchangeRates implements ExchangeRates
{
public function __construct(private Money $rate) {}
public function usdToUah(DateTimeImmutable $day): Money
{
return $this->rate;
}
}
it('converts a salary to hryvnias', function () {
$rates = new FixedExchangeRates(Money::uah(41_50)); // курс 41.50 у копійках
$salary = new ConvertSalary($rates);
$uah = $salary(Money::usd(5000_00), new DateTimeImmutable('2026-10-01'));
expect($uah)->toEqual(Money::uah(207_500_00)); // справжній Money, не мок
});
// Адаптер окремо: справжній HTTP-клієнт Laravel, записана відповідь НБУ.
it('reads the NBU rate', function () {
Http::preventStrayRequests();
Http::fake(['bank.gov.ua/*' => Http::response([['cc' => 'USD', 'rate' => 41.5]])]);
$rate = app(NbuExchangeRates::class)->usdToUah(new DateTimeImmutable('2026-10-01'));
expect($rate)->toEqual(Money::uah(41_50));
});
Відповідайте трьома шарами: домен тестую справжніми обʼєктами, межі (база, HTTP, пошта, час) підміняю фейками власних портів, а адаптери до чужого коду перевіряю окремо проти справжньої бібліотеки. І додайте про контрактний тест: без нього фейк рано чи пізно почне брехати.