Как сделать Laravel-контроллер тестируемым и перестать бояться рефакторинга

Если вы пишете тесты в Laravel, но чувствуете, что они какие-то формальные — проверяют статусы, но не логику — этот текст для вас. Разбираем один приём, после которого ваши тесты начнут находить проблемы, а не просто проходить.

Введение

Если вы уже пишете тесты в Laravel, но чувствуете, что они какие-то формальные — проверяют статусы, но не логику — этот текст для вас. Мы разберём один приём, после которого ваши тесты начнут находить проблемы, а не просто "проходить".

Возьмём типичный контроллер подписки на рассылку:

class NewsletterSubscriptionController extends Controller
{
public function store(Request $request): JsonResponse
{
$service = new NewsletterSubscriptionService();
$service->handle($request->email);

return response()->json(['success' => true]);
}
}

И его тест:

public function test_success_response_is_returned()
{
$this->postJson('/newsletter/subscriptions', [
'email' => 'test@example.com',
])->assertExactJson(['success' => true]);
}

Тест зелёный. Код работает. Можно выдыхать? Не совсем.

Представьте, что кто-то (допустим, вы же, но в пятницу вечером) случайно закомментирует строку с $service->handle(). Тест все равно пройдёт — он же проверил только ответ. А рассылка встанет.

В чем дело? Класс создаётся прямо в методе, через new. Для теста это чёрный ящик: мы видим запрос на входе и ответ на выходе, но понятия не имеем, что происходило внутри. Хороший тест должен проверять не только результат, но и факт, что нужные действия действительно выполнялись.

Менять архитектуру? Не обязательно кардинально. Достаточно подружиться с сервис-контейнером и внедрением зависимостей — теми инструментами, которые Laravel даёт из коробки.

Рассмотрим два способа подружить этот код с тестами. Первый — через внедрение зависимости в аргументы метода, с нативными моками Laravel — это обёртка над Mockery, которая убирает шаблонный код. Второй — для случаев, когда код уже живёт в продакшне и сигнатуру метода менять страшно — через хелпер resolve(). Оба научат вас главному: проверять не только что метод вернул, но и что он реально сделал то, что должен.

Поехали.

Внедрение зависимости + нативные моки Laravel

Давайте перепишем контроллер так, чтобы он сам не создавал сервис, а просил его извне. В Laravel это делается через внедрение зависимости прямо в метод:

class NewsletterSubscriptionController extends Controller
{
public function store(Request $request, NewsletterSubscriptionService $service): JsonResponse
{
$service->handle($request->email);

return response()->json(['success' => true]);
}
}

Единственное изменение — мы добавили NewsletterSubscriptionService $service в аргументы. Laravel автоматически подтянет нужный класс из контейнера и передаст его в метод. Теперь мы можем контролировать, что именно туда попадёт — настоящий сервис или его двойник для теста.

В сообществе сложилось два способа писать такие тесты. Первый — современный, с использованием встроенных возможностей Laravel. Второй — классический, через Mockery, который показывает механику изнутри. Рассмотрим оба.

Вариант 1. Нативные моки Laravel

public function test_success_response_is_returned()
{
$mock = $this->mock(NewsletterSubscriptionService::class, function (MockInterface $mock) {
$mock->shouldReceive('handle')
->once()
->with('test@example.com')
->andReturnNull();
});

$this->postJson('/newsletter/subscriptions', [
'email' => 'test@example.com',
])->assertExactJson(['success' => true]);
}

Хелпер $this->mock() делает две вещи: создаёт объект-заглушку для указанного класса и сразу регистрирует его в сервис-контейнере. Любой запрос к NewsletterSubscriptionService из контейнера вернёт не настоящий класс, а нашу подготовленную заглушку.

Дальше мы сообщаем заглушке: ожидаем, что метод handle будет вызван ровно один раз, с аргументом test@example.com. В контроллере, когда Laravel попытается подставить зависимость, он получит именно этот мок.

В конце теста проверка происходит автоматически — Laravel сам вызывает Mockery::close() в tearDown(). Если метод не вызывали, вызвали больше раза или передали не тот email — тест упадёт.

Вариант 2. Классический Mockery

public function test_success_response_is_returned()
{
$mock = Mockery::mock(NewsletterSubscriptionService::class);
$mock->shouldReceive('handle')
->once()
->with('test@example.com')
->andReturnNull();

app()->instance(NewsletterSubscriptionService::class, $mock);

$this->postJson('/newsletter/subscriptions', [
'email' => 'test@example.com',
])->assertExactJson(['success' => true]);

Mockery::close();
}

Здесь мы делаем то же самое, но руками. Создаём мок через Mockery, потом явно помещаем его в сервис-контейнер через app()->instance(). И в конце обязательно вызываем Mockery::close(), чтобы он проверил, совпали ли ожидания с реальностью.

Дополнительные инструменты.

Частичные моки (partialMock):

// Если нужно замокать только handle(), а остальные методы пусть работают по-настоящему
$mock = $this->partialMock(NewsletterSubscriptionService::class, function (MockInterface $mock) {
$mock->shouldReceive('handle')->once()->with('test@example.com');
});

partialMock пригодится, когда в сервисе есть другие методы, которые нужны тесту в реальном исполнении — например, они возвращают данные, но не делают внешних вызовов.

Шпионы (spy):

public function test_success_response_is_returned()
{
$spy = $this->spy(NewsletterSubscriptionService::class);

$this->postJson('/newsletter/subscriptions', [
'email' => 'test@example.com',
])->assertExactJson(['success' => true]);

$spy->shouldHaveReceived('handle')->once()->with('test@example.com');
}

Шпионы удобны, когда логика разветвлённая и проще проверить факт вызова постфактум, чем описывать ожидания заранее.

Зачем показывать оба варианта.

Первый — это то, как вы будете писать тесты в реальных проектах на современном Laravel. Он короче, чище и меньше шансов забыть закрыть Mockery.

Второй нужен для понимания механики. Когда вы видите $this->mock(), полезно знать, что под капотом происходит ровно это: создаётся мок и регистрируется в контейнере. Кроме того, во многих легаси-проектах тесты написаны именно так, и их придётся читать и поддерживать.

resolve() + Mockery (для легаси-кода)

В реальных проектах не всегда можно просто добавить аргумент в метод. Бывает, что сигнатуру метода менять нельзя — потому что метод реализует интерфейс, потому что его вызывают из сотни мест или просто потому что код старый и страшный. Для таких случаев у Laravel есть запасной путь: хелпер resolve().

Вернёмся к исходному контроллеру, но теперь вместо new будем использовать resolve():

class NewsletterSubscriptionController extends Controller
{
public function store(Request $request): JsonResponse
{
$service = resolve(NewsletterSubscriptionService::class);
$service->handle($request->email);

return response()->json(['success' => true]);
}
}

resolve() — это глобальный хелпер, который просит сервис-контейнер создать экземпляр класса. Если класс зарегистрирован в контейнере, контейнер отдаст его со всеми зависимостями. Если нет — просто создаст новый экземпляр через new. Главное отличие от прямого вызова new в том, что теперь мы можем подменить класс в контейнере до того, как resolve() сработает.

Тест для такого кода будет выглядеть так:

public function test_success_response_is_returned()
{
// Создаём мок через Mockery (Laravel-хелпер тут не сработает)
$mock = Mockery::mock(NewsletterSubscriptionService::class);
$mock->shouldReceive('handle')
->once()
->with('test@example.com')
->andReturnNull();

// Подкладываем мок в контейнер
app()->instance(NewsletterSubscriptionService::class, $mock);

$this->postJson('/newsletter/subscriptions', [
'email' => 'test@example.com',
])->assertExactJson(['success' => true]);

// В отличие от Laravel-хелпера, Mockery нужно попросить проверить ожидания
Mockery::close();
}

Сначала мы создаём мок через Mockery. В первом решении мы использовали $this->mock() — это был встроенный хелпер Laravel, который сразу регистрировал мок в сервис-контейнере. Здесь мы делаем то же самое, но руками: создаём мок, потом явно говорим контейнеру "вот этот объект теперь будет ответом на запрос класса".

Когда контроллер вызовет resolve(NewsletterSubscriptionService::class), контейнер посмотрит: есть ли у него зарегистрированный экземпляр этого класса? Мы его только что подложили через app()->instance(), поэтому сервис-контейнер отдаст наш мок. И вызов handle() уйдёт в мок, а не в настоящий сервис.

Минусы этого подхода

Главный минус — мы не получили проверку вызовов автоматически. В первом решении тест упал бы сам, потому что Laravel-хелпер регистрирует мок так, что Mockery проверяет ожидания после завершения теста. Здесь мы должны явно вызвать Mockery::close() в конце, иначе Mockery промолчит, даже если ожидания не совпали.

Второй минус — сам факт использования resolve() внутри контроллера. Это все ещё скрытая зависимость. Метод по-прежнему не говорит явно "мне нужен сервис", он говорит "мне нужен какой-то объект, я сам его себе добуду". Тестировать можно, но читать код сложнее.

Подробнее о моках, шпионах и частичных моках — в официальной документации Laravel.

Сравнение подходов

Мы разобрали два способа сделать код тестируемым. Первый — через явное внедрение зависимости в аргументы метода. Второй — через resolve() с ручной подменой в контейнере. Оба работают, но решают разные задачи.

Явное внедрение

public function store(Request $request, NewsletterSubscriptionService $service)
{
$service->handle($request->email);
}

Плюсы:

  • Зависимость видна сразу. Достаточно взглянуть на сигнатуру метода, чтобы понять, какие сервисы ему нужны.
  • Тесты пишутся коротко и красиво через $this->mock().
  • Контроллер ничего не знает про сервис-контейнер — он просто просит то, что ему нужно.

Минусы:

  • Не всегда можно изменить сигнатуру метода. Например, если метод реализует интерфейс или переопределяется в наследниках.

resolve()

public function store(Request $request)
{
$service = resolve(NewsletterSubscriptionService::class);
$service->handle($request->email);
}

Плюсы:

  • Сигнатура метода остаётся нетронутой. Старый код не ломается.
  • Можно подменить зависимость через контейнер даже в самом безнадёжном легаси.

Минусы:

  • Зависимость спрятана. Чтобы узнать, что нужно этому методу, придётся лезть в его тело.
  • Тесты требуют ручной регистрации мока и явного вызова Mockery::close().
  • Соблазн использовать resolve() везде, даже где не надо, потому что "так проще".

Таблица для быстрого выбора.

СитуацияЧто использовать
Пишете новый кодПервый способ (явное внедрение)
Правите метод, сигнатуру которого можно менятьПервый способ
Метод реализует интерфейсВторой способ (или рефакторинг интерфейса)
Код в продакшне, менять страшноВторой способ как временное решение
Хотите, чтобы зависимости были видны с первого взглядаПервый способ
Пишете тесты для чужого легаси-кодаВторой способ

Вывод

Связь между тестируемостью и качеством кода прямая: если код легко тестировать, с ним, скорее всего, все в порядке. Если тесты приходится писать с костылями и оглядкой — код сигнализирует о проблемах.

Внедрение зависимостей и сервис-контейнер часто воспринимают как инфраструктуру для тестов. На самом деле это инструменты архитектуры, которые делают код слабосвязанным и гибким. Тесты лишь первый, кто замечает их отсутствие.

Три правила, которые стоит вынести из статьи:

  1. Явное лучше неявного. Когда метод объявляет свои зависимости в сигнатуре, его поведение понятно без чтения тела. Когда он создаёт их внутри — это скрытая связанность, которая рано или поздно усложнит и разработку, и тестирование.
  2. Тест должен проверять поведение, а не факт выполнения. Проверка HTTP-статуса не говорит о том, сработала ли бизнес-логика. Хороший тест подтверждает, что нужные действия были выполнены с правильными параметрами.
  3. Моки — инструмент изоляции, а не подмена реальности. Они позволяют тестировать один слой приложения, не запуская всю цепочку зависимостей. Использовать моки — нормально, если вы понимаете, зачем они нужны.

Если вы пишете новый код — используйте явное внедрение зависимостей. Если работаете с легаси — resolve() и ручная подмена в контейнере дадут хотя бы минимальную защиту от регрессии. Но во втором случае стоит помнить, что это временное решение, а не новая норма.

Начните с одного контроллера. Перепишите его так, чтобы зависимости передавались извне. Напишите тест, который проверяет не только ответ, но и вызовы. Скорее всего, вы заметите, что код стал понятнее ещё до запуска тестов. А когда через месяц в проект прилетит задача "добавить логирование подписок", вы просто обернёте сервис в декоратор, даже не открывая контроллер. Это и есть главный эффект.

Комментарии


Дополнительные материалы

Предыдущая Статья

Мягкое удаление (Soft Delete) в Laravel: Полное руководство