Тестирование 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».
Почему это важно именно для проверки «всей цепочки»: без копирования
мутация домена сохраняется сама, без вызова 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());
}
}
}
Тестирование ошибок репозитория
Что если база упала? 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: когда что использовать
| Fake | Mock (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: '',
));
}
}
Пирамида тестов в Hex
Большинство тестов - 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 нет