<? phpukraine СТАТТІ
Пошук по платформі
ТЕСТУВАННЯ 4 вересня 2026 · 7 хв читання

Фейки замість моків: як тестувати use case без бази

Мок, який знає про кожен виклик репозиторію, ламається від будь-якого рефакторингу і при цьому не перевіряє стан. Показуємо, як in-memory реалізація порту на 20 рядків і FrozenClock замінюють десяток shouldReceive і роблять unit-тести use case швидкими та детермінованими.

РP
Редакція phpukraine
Редакція платформи

Коли 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.

ПИШЕТЕ ПРО PHP?Опублікуйте розбір або історію з проєкту на платформіРедактор із чеклістом, редактура, авторська сторінка. Републікація з блогу отримує canonical на оригінал. Відкрити редактор →
РP
Редакція phpukraine
Редакція платформи
Матеріали, які готує команда платформи на основі власних даних: каталогу вакансій, зарплатного звіту й банку питань. Кожна цифра в них рахується з бази, а не береться з голови.
оновлено 4 вересня 2026 · ліцензія CC-BY-SA-4.0
ДАЛІ ПО ТЕМІ
ЧИТАТИ ДАЛІ
← Усі статті