Тестирование use-case без базы данных

Главное преимущество гексагональной архитектуры - бизнес-логика тестируется без базы данных, без HTTP, без внешних сервисов. Use-case зависит от интерфейса (порта), а в тестах мы подставляем фейк или мок.

Фейк: in-memory реализация порта

Фейк - это простая реализация интерфейса, которая хранит данные в памяти. В отличие от мока, фейк содержит рабочую логику: добавляет, ищет, удаляет.

// usecase/create_task_test.go
package usecase_test

import (
    "context"
    "sync"
    "time"

    "myapp/internal/domain"
)

type FakeTaskRepo struct {
    mu    sync.RWMutex
    tasks map[string]domain.Task
}

func NewFakeTaskRepo() *FakeTaskRepo {
    return &FakeTaskRepo{tasks: make(map[string]domain.Task)}
}

func (r *FakeTaskRepo) Create(_ context.Context, t domain.Task) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.tasks[t.ID()] = t
    return nil
}

func (r *FakeTaskRepo) GetByID(_ context.Context, id string) (domain.Task, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    t, ok := r.tasks[id]
    if !ok {
        return domain.Task{}, domain.ErrTaskNotFound
    }
    return t, nil
}

func (r *FakeTaskRepo) ListByUser(_ context.Context, userID string) ([]domain.Task, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    var result []domain.Task
    for _, t := range r.tasks {
        if t.UserID() == userID {
            result = append(result, t)
        }
    }
    return result, nil
}

func (r *FakeTaskRepo) Update(_ context.Context, t domain.Task) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.tasks[t.ID()] = t
    return nil
}

func (r *FakeTaskRepo) Delete(_ context.Context, id string) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    delete(r.tasks, id)
    return nil
}
<?php
// tests/Fakes/FakeTaskRepository.php
declare(strict_types=1);

namespace App\Tests\Fakes;

use App\Application\Port\TaskRepositoryPort;
use App\Domain\Task;
use App\Domain\TaskError;

final class FakeTaskRepository implements TaskRepositoryPort
{
    /** @var array<string, Task> */
    private array $tasks = [];

    // clone на входе и выходе - обязателен, а не аккуратность.
    //
    // PHP хранит объекты по ссылке: без clone фейк отдавал бы тот же
    // объект, что лежит в массиве, и мутация домена «сохранялась» бы
    // без вызова update(). Тест на таком фейке зелёный, а в проде тот же
    // код теряет изменения. Go-версия выше хранит значения и получает
    // правильное поведение бесплатно - именно поэтому фейки на двух
    // языках здесь выглядят по-разному.
    public function create(Task $task): void
    {
        $this->tasks[$task->id()] = clone $task;
    }

    public function getById(string $id): Task
    {
        if (!isset($this->tasks[$id])) {
            throw TaskError::notFound();
        }
        return clone $this->tasks[$id];
    }

    /** @return list<Task> */
    public function listByUser(string $userId): array
    {
        $result = [];
        foreach ($this->tasks as $task) {
            if ($task->userId() === $userId) {
                $result[] = $task;
            }
        }
        return $result;
    }

    public function update(Task $task): void
    {
        $this->tasks[$task->id()] = clone $task;
    }

    public function delete(string $id): void
    {
        unset($this->tasks[$id]);
    }
}

Фейк ведёт себя как настоящий репозиторий: можно создать задачу, потом получить её по ID. Это позволяет писать тесты, которые проверяют всю цепочку use-case, а не только «был ли вызван метод Save».

Фраза выше держится на одном свойстве: фейк должен **копировать** данные на входе и выходе, как это делает настоящее хранилище. Go-версия получает это бесплатно - `map[string]domain.Task` хранит значения. PHP-версия хранила бы ссылки на те же объекты, поэтому там стоит `clone`.

Почему это важно именно для проверки «всей цепочки»: без копирования мутация домена сохраняется сама, без вызова update(). Тест assert.True(t, got.Completed()) проходит - и продолжал бы проходить, если из use-case удалить сохранение вообще. Такой тест не проверяет цепочку, а создаёт впечатление, что проверяет: в проде тот же код теряет изменения.

Проверить свой фейк можно одним тестом: получить объект, изменить его, не сохранять, получить снова - и убедиться, что изменение не сохранилось.

Тест use-case: CompleteTask

// FakeNotifier записывает вызовы вместо отправки уведомлений.
type FakeNotifier struct {
    calls []string
    err   error
}

func (n *FakeNotifier) TaskCompleted(_ context.Context, userID, title string) error {
    n.calls = append(n.calls, userID+":"+title)
    return n.err
}

func TestCompleteTask_Success(t *testing.T) {
    repo := NewFakeTaskRepo()
    notifier := &FakeNotifier{}
    uc := usecase.NewCompleteTask(repo, notifier)

    // Подготовка: восстанавливаем незавершённую задачу так, как это делает
    // адаптер БД. Литералом `domain.Task{...}` не получится - поля закрыты,
    // и это ровно то, ради чего они закрыты: тест не может собрать
    // сущность в состоянии, которое домен считает невозможным.
    task, err := domain.TaskFromStorage(
        "task-1", "user-1", "Написать тесты", "", false, time.Now(), nil,
    )
    require.NoError(t, err)
    _ = repo.Create(context.Background(), task)

    // Действие: завершаем задачу.
    err = uc.Execute(context.Background(), "task-1", "user-1")

    // Проверка: ошибки нет, задача отмечена как completed.
    require.NoError(t, err)

    got, _ := repo.GetByID(context.Background(), "task-1")
    assert.True(t, got.Completed())
}
<?php
// tests/Application/UseCase/CompleteTaskUseCaseTest.php
declare(strict_types=1);

namespace App\Tests\Application\UseCase;

use App\Application\UseCase\CompleteTaskUseCase;
use App\Domain\Task;
use App\Tests\Fakes\FakeTaskRepository;
use PHPUnit\Framework\TestCase;

final class CompleteTaskUseCaseTest extends TestCase
{
    public function testCompletesTaskSuccessfully(): void
    {
        // Arrange
        $repo = new FakeTaskRepository();
        $repo->create(Task::fromStorage(
            id: 'task-1',
            userId: 'user-1',
            title: 'Написать тесты',
            completed: false,
        ));
        $uc = new CompleteTaskUseCase($repo);

        // Act
        $uc->execute('task-1');

        // Assert
        self::assertTrue($repo->getById('task-1')->isCompleted());
    }
}

Никакого PostgreSQL, никакого Docker, никаких миграций. Тест выполняется за миллисекунды.

Table-driven тесты

Когда у use-case несколько сценариев (успех, задача не найдена, уже завершена), table-driven подход компактнее отдельных функций.

func TestCompleteTask(t *testing.T) {
    tests := []struct {
        name    string
        setup   func(repo *FakeTaskRepo)
        taskID  string
        wantErr error
    }{
        {
            name: "успешное завершение",
            setup: func(repo *FakeTaskRepo) {
                task, _ := domain.TaskFromStorage(
                    "t1", "u1", "Задача", "", false, time.Now(), nil,
                )
                _ = repo.Create(context.Background(), task)
            },
            taskID:  "t1",
            wantErr: nil,
        },
        {
            name:    "задача не найдена",
            setup:   func(repo *FakeTaskRepo) {},
            taskID:  "nonexistent",
            wantErr: domain.ErrTaskNotFound,
        },
        {
            name: "задача уже завершена",
            setup: func(repo *FakeTaskRepo) {
                // Завершённую задачу через NewTask не создать - и не нужно:
                // восстановление из хранилища воспроизводит прошлое состояние.
                done := time.Now()
                task, _ := domain.TaskFromStorage(
                    "t2", "u1", "Задача", "", true, done.Add(-time.Hour), &done,
                )
                _ = repo.Create(context.Background(), task)
            },
            taskID:  "t2",
            wantErr: domain.ErrTaskAlreadyCompleted,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            repo := NewFakeTaskRepo()
            tt.setup(repo)

            uc := usecase.NewCompleteTask(repo, &FakeNotifier{})
            err := uc.Execute(context.Background(), tt.taskID, "u1")

            if tt.wantErr != nil {
                assert.ErrorIs(t, err, tt.wantErr)
            } else {
                require.NoError(t, err)
            }
        })
    }
}
<?php
// PHPUnit data provider - идиоматичный аналог table-driven
declare(strict_types=1);

namespace App\Tests\Application\UseCase;

use App\Application\UseCase\CompleteTaskUseCase;
use App\Domain\Task;
use App\Domain\TaskError;
use App\Tests\Fakes\FakeTaskRepository;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class CompleteTaskScenariosTest extends TestCase
{
    /** @return iterable<string, array{0: callable, 1: string, 2: ?string}> */
    public static function scenarios(): iterable
    {
        yield 'успешное завершение' => [
            fn (FakeTaskRepository $r) => $r->create(Task::fromStorage('t1', 'u1', '', false)),
            't1',
            null,
        ];
        yield 'задача не найдена' => [
            fn (FakeTaskRepository $r) => null,
            'nonexistent',
            'task not found',
        ];
        yield 'задача уже завершена' => [
            fn (FakeTaskRepository $r) => $r->create(Task::fromStorage('t2', 'u1', '', true)),
            't2',
            'task already completed',
        ];
    }

    #[DataProvider('scenarios')]
    public function testScenario(callable $setup, string $taskId, ?string $expectedError): void
    {
        $repo = new FakeTaskRepository();
        $setup($repo);
        $uc = new CompleteTaskUseCase($repo);

        if ($expectedError !== null) {
            $this->expectException(TaskError::class);
            $this->expectExceptionMessage($expectedError);
        }

        $uc->execute($taskId);
        if ($expectedError === null) {
            self::assertTrue($repo->getById($taskId)->isCompleted());
        }
    }
}
Создавай `NewFakeTaskRepo()` внутри каждого подтеста. Если использовать общий репозиторий, тесты начнут влиять друг на друга, и порядок запуска станет важен. Изоляция - основа надёжных тестов.

Тестирование ошибок репозитория

Что если база упала? Use-case должен корректно пробросить ошибку. Для таких сценариев удобен failing fake - фейк, который всегда возвращает ошибку.

type FailingTaskRepo struct{}

func (r *FailingTaskRepo) GetByID(context.Context, string) (domain.Task, error) {
    return domain.Task{}, errors.New("connection refused")
}
func (r *FailingTaskRepo) Update(context.Context, domain.Task) error {
    return errors.New("connection refused")
}
// ... остальные методы аналогично

func TestCompleteTask_RepoError(t *testing.T) {
    uc := usecase.NewCompleteTask(&FailingTaskRepo{}, &FakeNotifier{})
    err := uc.Execute(context.Background(), "any-id", "u1")

    assert.Error(t, err)
    assert.Contains(t, err.Error(), "connection refused")
}
<?php
// tests/Fakes/FailingTaskRepository.php
declare(strict_types=1);

namespace App\Tests\Fakes;

use App\Application\Port\TaskRepositoryPort;
use App\Domain\Task;
use PHPUnit\Framework\TestCase;
use RuntimeException;

final class FailingTaskRepository implements TaskRepositoryPort
{
    public function create(Task $task): void { throw new RuntimeException('connection refused'); }
    public function getById(string $id): Task { throw new RuntimeException('connection refused'); }
    public function listByUser(string $userId): array { throw new RuntimeException('connection refused'); }
    public function update(Task $task): void { throw new RuntimeException('connection refused'); }
    public function delete(string $id): void { throw new RuntimeException('connection refused'); }
}

// tests/Application/UseCase/CompleteTaskRepoErrorTest.php
final class CompleteTaskRepoErrorTest extends TestCase
{
    public function testPropagatesInfrastructureError(): void
    {
        $uc = new CompleteTaskUseCase(new FailingTaskRepository());

        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage('connection refused');

        $uc->execute('any-id');
    }
}

Mock vs Fake: когда что использовать

FakeMock (testify/mock)
Что этоРабочая реализация на map/sliceЗаглушка, записывающая вызовы
ПроверяетРезультат (state-based)Взаимодействие (вызван ли метод, с какими аргументами)
Когда использоватьUse-case тесты, когда важен итогПроверка side-effects (отправлено ли уведомление)
ХрупкостьНизкаяВысокая (ломается при рефакторинге)

Пример с testify/mock для проверки side-effect:

type MockNotifier struct {
    mock.Mock
}

func (m *MockNotifier) Notify(ctx context.Context, userID, message string) error {
    args := m.Called(ctx, userID, message)
    return args.Error(0)
}

func TestCreateTask_NotifiesUser(t *testing.T) {
    repo := NewFakeTaskRepo()
    notifier := new(MockNotifier)
    notifier.On("Notify", mock.Anything, "user-1", mock.Anything).Return(nil)

    uc := usecase.NewCreateTask(repo, notifier)
    _ = uc.Execute(context.Background(), usecase.CreateTaskInput{
        UserID: "user-1",
        Title:  "Новая задача",
    })

    notifier.AssertCalled(t, "Notify", mock.Anything, "user-1", mock.Anything)
}
<?php
// PHPUnit mock через createMock - встроенный аналог testify/mock
declare(strict_types=1);

namespace App\Tests\Application\UseCase;

use App\Application\Port\TaskNotifierPort;
use App\Application\UseCase\CreateTaskInput;
use App\Application\UseCase\CreateTaskUseCase;
use App\Tests\Fakes\FakeTaskRepository;
use PHPUnit\Framework\TestCase;

final class CreateTaskNotifiesUserTest extends TestCase
{
    public function testNotifiesUserOnCreate(): void
    {
        $repo = new FakeTaskRepository();
        $notifier = $this->createMock(TaskNotifierPort::class);
        $notifier->expects(self::once())
            ->method('taskCreated')
            ->with('user-1', self::isType('string'));

        $uc = new CreateTaskUseCase($repo, $notifier);
        $uc->execute(new CreateTaskInput(
            userId: 'user-1',
            title: 'Новая задача',
            description: '',
        ));
    }
}
Если каждый тест проверяет «метод X вызван с аргументами Y» - при любом рефакторинге тесты посыплются. Используй фейки для репозиториев (state-based), а моки - только для side-effects (уведомления, отправка событий).

Пирамида тестов в Hex

80% - unit-тесты use-case на фейках. Они быстрые (мс), много, и покрывают бизнес-логику. 15% - integration на HTTP-слое с фейковым use-case. 5% - E2E с Docker и реальной БД, проверяют только что адаптеры работают.

Большинство тестов - unit-тесты use-case на фейках. Они быстрые (тысячи за секунду), надёжные (нет flaky из-за сети) и покрывают всю бизнес-логику. Интеграционные тесты адаптеров нужны, но их мало: достаточно проверить, что SQL-запросы корректны и маппинг работает.

Чтобы потренироваться на чём-то целом, а не на отдельных фрагментах, возьми мини-проект Tasks API: там уже есть домен с инвариантами, два use-case и in-memory адаптер - ровно тот набор, на котором такая пирамида и строится.

Мини-задание

  • Напиши FakeUserRepo с методами Create, GetByID, GetByEmail - используй map[string]domain.User
  • Напиши table-driven тест для use-case RegisterUser: успех, дублирующий email, пустое имя
  • Добавь FailingUserRepo, который всегда возвращает ошибку, и проверь, что use-case корректно её прокидывает
  • Запусти тесты с флагом -race и убедись, что data race нет

Зарегистрируйтесь бесплатно, чтобы пройти квиз, вести прогресс.