Введение
Если вы уже пишете тесты в 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()везде, даже где не надо, потому что "так проще".
Таблица для быстрого выбора.
| Ситуация | Что использовать |
|---|---|
| Пишете новый код | Первый способ (явное внедрение) |
| Правите метод, сигнатуру которого можно менять | Первый способ |
| Метод реализует интерфейс | Второй способ (или рефакторинг интерфейса) |
| Код в продакшне, менять страшно | Второй способ как временное решение |
| Хотите, чтобы зависимости были видны с первого взгляда | Первый способ |
| Пишете тесты для чужого легаси-кода | Второй способ |
Вывод
Связь между тестируемостью и качеством кода прямая: если код легко тестировать, с ним, скорее всего, все в порядке. Если тесты приходится писать с костылями и оглядкой — код сигнализирует о проблемах.
Внедрение зависимостей и сервис-контейнер часто воспринимают как инфраструктуру для тестов. На самом деле это инструменты архитектуры, которые делают код слабосвязанным и гибким. Тесты лишь первый, кто замечает их отсутствие.
Три правила, которые стоит вынести из статьи:
- Явное лучше неявного. Когда метод объявляет свои зависимости в сигнатуре, его поведение понятно без чтения тела. Когда он создаёт их внутри — это скрытая связанность, которая рано или поздно усложнит и разработку, и тестирование.
- Тест должен проверять поведение, а не факт выполнения. Проверка HTTP-статуса не говорит о том, сработала ли бизнес-логика. Хороший тест подтверждает, что нужные действия были выполнены с правильными параметрами.
- Моки — инструмент изоляции, а не подмена реальности. Они позволяют тестировать один слой приложения, не запуская всю цепочку зависимостей. Использовать моки — нормально, если вы понимаете, зачем они нужны.
Если вы пишете новый код — используйте явное внедрение зависимостей. Если работаете с легаси — resolve() и ручная подмена в контейнере дадут хотя бы минимальную защиту от регрессии. Но во втором случае стоит помнить, что это временное решение, а не новая норма.
Начните с одного контроллера. Перепишите его так, чтобы зависимости передавались извне. Напишите тест, который проверяет не только ответ, но и вызовы. Скорее всего, вы заметите, что код стал понятнее ещё до запуска тестов. А когда через месяц в проект прилетит задача "добавить логирование подписок", вы просто обернёте сервис в декоратор, даже не открывая контроллер. Это и есть главный эффект.