Коли application-шар залежить від портів, а не від Eloquent, unit-тест use case стає можливим без бази. Далі є розвилка: підставити мок або написати фейк. Мок швидший на старті — один рядок замість класу. Фейк дорожчий на старті й дешевший потім. У phpukraine ми пройшли обидва шляхи і залишили фейки: усі in-memory реалізації живуть у tests/Support, а моки з Mockery в unit-тестах домену не використовуються взагалі.
Приклади нижче — з робочого коду порталу: Laravel 13, PHP 8.3+, Pest 5.
Чому мок на кожен метод ламкий
Візьмемо простий use case. Він знімає з публікації вакансії, у яких вийшов термін:
final readonly class ExpireJobs
{
public function __construct(
private JobRepository $jobs,
private Clock $clock,
) {}
public function handle(): int
{
$now = $this->clock->now();
$expired = 0;
foreach ($this->jobs->visible() as $job) {
if ($job->expireIfDue($now)) {
$this->jobs->save($job);
$expired++;
}
}
return $expired;
}
}
Тест на моках виглядає так:
$jobs = Mockery::mock(JobRepository::class);
$jobs->shouldReceive('visible')->once()->andReturn([$fresh, $stale]);
$jobs->shouldReceive('save')->once()->with($stale);
$expired = (new ExpireJobs($jobs, FrozenClock::at('2026-09-02 10:00:00')))->handle();
expect($expired)->toBe(1);
Він проходить. Проблем у нього чотири, і всі вилазять не одразу.
Він фіксує реалізацію, а не поведінку. shouldReceive('visible')->once() — це твердження «use case викликає рівно один метод рівно один раз». Якщо завтра ми додамо посторінкову вибірку й visible() викликатиметься тричі, тест впаде, хоча поведінка не змінилась: прострочені вакансії досі знімаються з публікації. Червоний тест, який не вказує на баг, привчає команду правити тест, а не код.
Він ламається від будь-якої зміни порту. JobRepository у нас має сім методів. Додали восьмий — і кожен мок, у якому цей метод хтось викличе, кине BadMethodCallException. Виправляти доводиться в кожному тесті окремо: моки не мають спільного місця, де можна дописати реалізацію один раз.
Він не перевіряє стан. save() у мока нічого не зберігає, тому єдине доступне твердження — «метод викликали». Питання «а в якому статусі вакансія опинилась після цього» лишається без відповіді. Саме воно й цікаве.
Він дозволяє неможливий стан. Мок поверне те, що йому наказали. Можна написати сценарій, у якому visible() віддає вакансію, яку той самий тест щойно «зберіг» як прострочену, — реальна база такого ніколи не поверне. Тест зелений, продакшен ні.
Мок описує розмову з залежністю. Фейк описує залежність. Тестувати зазвичай треба друге.
In-memory репозиторій за 20 рядків
Фейк — це звичайна реалізація порту, яка тримає дані в масиві. Ось порт для чернеток статей від учасників платформи:
interface SubmissionRepository
{
public function findById(int $id): ?ArticleSubmission;
/** @return list<ArticleSubmission> newest first */
public function forUser(int $userId): array;
public function save(ArticleSubmission $submission): void;
public function delete(ArticleSubmission $submission): void;
}
І його in-memory реалізація цілком:
final class InMemorySubmissionRepository implements SubmissionRepository
{
/** @var array<int, ArticleSubmission> */
public array $submissions = [];
private int $nextId = 1;
public function findById(int $id): ?ArticleSubmission
{
return $this->submissions[$id] ?? null;
}
public function forUser(int $userId): array
{
$found = array_filter($this->submissions, fn ($s) => $s->userId === $userId);
return array_values(array_reverse($found));
}
public function save(ArticleSubmission $submission): void
{
if ($submission->id === null) {
$submission->assignId($this->nextId++);
}
$this->submissions[(int) $submission->id] = $submission;
}
public function delete(ArticleSubmission $submission): void
{
unset($this->submissions[(int) $submission->id]);
}
}
Двадцять рядків, які пишуться один раз і живуть у tests/Support. Що з цього виходить.
Тест говорить про стан. expect($submissions->submissions[1]->status)->toBe(SubmissionStatus::Published) — це твердження про результат, а не про виклики. Публічна властивість $submissions тут навмисна: фейк існує для тестів, інкапсуляція в ньому нічого не купує.
Тест не знає, як саме use case ходить у репозиторій. Викликав findById() двічі — байдуже. Замінили цикл на один запит — тест не зауважить. Він впаде лише тоді, коли зміниться результат.
Зміна порту ламається в одному місці. Додали метод до інтерфейсу — PHP кине помилку на класі фейка, а не в двадцяти тестах. Дописали реалізацію, і всі тести знову зелені. Це та сама причина, чому фейк варто оголошувати через implements, а не через анонімний клас усередині тесту.
Фейк ловить те, чого мок не бачить. In-memory save() присвоює ідентифікатори послідовно, тому use case, який покладався на те, що ID вже є до збереження, падає одразу. Мок таке пропустить.
Ціна теж є, і про неї варто сказати чесно: фейк може розійтися з реальною реалізацією. forUser() вище повертає новіші першими через array_reverse, а Eloquent-версія — через orderByDesc. Якщо в SQL забути сортування, unit-тести лишаться зеленими. Тому фейк не скасовує тесту на Eloquent-репозиторій, він лише виносить із нього логіку use case.
Не всі порти потребують саме репозиторію. Для портів без стану — логера, зовнішнього виклику — фейк виглядає як записувач: RecordingSyncRunLog складає отримані звіти в масив, FakeJobRewriter повертає заготовлений текст замість запуску зовнішнього процесу. Це ті самі фейки, просто перевіряють у них не збережений стан, а список того, що прийшло.
FrozenClock і детерміновані тести
Друга залежність, яка робить тести плаваючими, — час. now(), Carbon::now(), new DateTimeImmutable усередині домену дають тест, що падає раз на місяць о 23:59 і на CI в іншому часовому поясі.
Рішення таке ж, як із репозиторієм: порт і фейк.
interface Clock
{
public function now(): DateTimeImmutable;
}
final class FrozenClock implements Clock
{
public function __construct(private DateTimeImmutable $now) {}
public static function at(string $datetime): self
{
return new self(new DateTimeImmutable($datetime));
}
public function now(): DateTimeImmutable
{
return $this->now;
}
public function advance(string $modifier): void
{
$this->now = $this->now->modify($modifier);
}
}
SystemClock у продакшені повертає new DateTimeImmutable, і на цьому вся інфраструктура закінчується.
Ключовий метод — advance(). Він дозволяє писати сценарії, у яких час минає всередині одного тесту, без жодного sleep():
it('expires jobs that were not seen for two weeks', function () {
$jobs = new InMemoryJobRepository;
$clock = FrozenClock::at('2026-09-02 10:00:00');
$expire = new ExpireJobs($jobs, $clock);
$jobs->save($jobWithDeadline('2026-09-10'));
expect($expire->handle())->toBe(0);
$clock->advance('+2 weeks');
expect($expire->handle())->toBe(1)
->and($jobs->jobs[1]->status)->toBe(JobStatus::Expired);
});
Порівняйте з Carbon::setTestNow(): той працює через глобальний стан, вимагає Laravel у тесті й лишається встановленим, доки хтось не скине його в tearDown. FrozenClock — звичайний обʼєкт, який передається в конструктор. Забути його «скинути» неможливо, бо в наступному тесті буде інший екземпляр.
Побічний ефект приємніший за сам тест: коли час стає явною залежністю, у сигнатурах доменних методів зʼявляється DateTimeImmutable $now ($job->expireIfDue($now)), і домен перестає мати приховані входи. Такий метод тестується взагалі без фейків — чиста функція від стану й моменту часу.
Що лишити feature-тестам
Unit-тест із фейками не перевіряє двох речей, і робити вигляд, що перевіряє, — найшвидший спосіб отримати зелений CI й зламаний продакшен.
Реальну реалізацію порту. EloquentJobRepository мусить мати власний тест із базою: чи правильні колонки, чи є сортування, чи не втрачається null, чи метод із iterable не тягне всю таблицю в памʼять. Такий тест пишеться один раз на репозиторій, а не на кожен use case, який його використовує.
Звʼязування в контейнері. Фейк доводить, що use case працює з якоюсь реалізацією JobRepository. Що в продакшені підставиться саме Eloquent-версія, а за Clock стоїть SystemClock, доводить провайдер — і тест, який резолвить ці інтерфейси з контейнера.
Практичне правило поділу таке: use case, доменні правила й усе, що дає різні відповіді залежно від стану чи часу, — unit-тести з фейками, сотні за секунди. SQL, HTTP, черги, зовнішні виклики — feature-тести, десятки. Якщо feature-тест доводиться писати, щоб перевірити гілку if усередині use case, значить, у порту бракує методу або в домені бракує обʼєкта.
Про те, як не дати домену знову потягнути Illuminate і перетворити фейк на неможливий, є окрема стаття: Архітектурні тести в Pest.