Laravel: Как сделать ваше приложение более тестируемым

Источник: «How to Make Your Laravel App More Testable»
Тестирование - неотъемлемая часть разработки программного обеспечения. Это даёт уверенность, что код соответствует критериям приемлемости и снижает вероятность ошибок.

Целевая аудитория

Эта статья предназначена для разработчиков плохо знакомых с миром Laravel, но имеющих базовое представление о тестах. В этой статье не будет рассказываться как писать базовые тесты (это для другой публикации, которая выйдет позже). Она покажет вам, как вы можете подойти к своему коду несколько иначе, чтобы улучшить качество кода и тестирования.

Зачем писать тесты

Часто думают, что тесты — это второстепенная задача, и что их полезно иметь для любого написанного кода. Это особенно заметно в организациях, где бизнес-цели и ограничения по времени оказывают давление на команду разработчиков. И, честно говоря, если вы просто пытаетесь быстро получить MVP (minimum viable product - минимально жизнеспособный продукт) или прототип, возможно, тесты могут отойти на второй план. Но реальность такова, что написание тестов до того, как код будет запущен в производство — всегда лучший вариант!

Когда вы пишете тесты, вы делаете несколько вещей:

Давайте напишем тест

Для объяснения, как сделать ваш код более тестируемым, мы воспользуемся простым примером. Конечно, есть разные способы написания кода, и это может быть настолько просто, что это не имеет значения. Но, надеюсь, это поможет объяснить общую концепцию.

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

use App\Services\NewsletterSubscriptionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class NewsletterSubscriptionController extends Controller
{
/**
* Store a new newsletter subscriber.
*
* @param Request $request
* @return JsonResponse
*/

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

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

Вышеупомянутый метод, как мы предполагаем вызывается, если происходит POST запрос к /newsletter/subscriptions, который принимает в качестве параметра email и передаёт его в сервис. Затем мы можем предположить, что сервис выполняет различные процессы, которые необходимо выполнить для завершения подписки пользователя на сообщения.

Чтобы протестировать вышеуказанный метод контроллера, мы создадим следующий пример:

class NewsletterSubscriptionControllerTest extends TestCase
{
/** @test */
public function success_response_is_returned()
{
$this->postJson('/newsletter/subscriptions', [
'email' => 'mail@ashallendesign.co.uk',
])->assertExactJson([
'success' => true,
]);
}
}

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

Давайте напишем лучший тест

В чём проблема

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

Конечно, мы могли бы добавить больше проверок в этот тест контроллера, чтобы проверить, выполняется ли код сервисного класса. Но это может привести к дублированию ваших тестов. Представим, что наше приложение Laravel позволяет пользователям регистрироваться. Каждый раз, когда пользователь регистрируется, он автоматически подписывается на рассылку. Теперь, если бы нам нужно было написать тесты для этого контроллера, которые проверяли бы, что сервисный класс был запущен корректно, у нас было бы два дубликата тестового кода. Это означает, что если бы мы обновили внутренний механизм работы сервисного класса, нам потребовалось бы обновить все эти тесты.

Честно говоря, иногда вы можете захотеть сделать так. Если вы пишете функциональный тест и выполняете проверки для всего процесса, это приемлемо. Однако, если вы пытаетесь написать модульные тесты и хотите проверить только контроллер, этот подход не сработает.

Как решить проблему

Для улучшения теста мы можем использовать имитирование/mocking, сервис-контейнер/service container и внедрение зависимостей/dependency injection. Я не буду вдаваться в подробности, что такое сервис-контейнер (это заслуживает отдельной статьи). Но я определённо рекомендую почитать документацию о нём, так как он невероятно полезный и является основной частью Laravel. Документация Laravel хорошо рассказывает про сервисный контейнер, поэтому её определённо стоит почитать.

Вкратце (и в очень простых терминах) сервис-контейнер управляет зависимостями классов и позволяет использовать классы, которые Laravel уже настроил для нас. В будущем я напишу статью о том, как использовать сервис провайдеров для привязки классов к сервис-контейнеру для внедрения зависимостей. А пока, следующий пример должен показать основную идею.

Что бы сделать наш пример более тестируемым, мы можем создать экземпляр NewsletterSubscriptionService используя внедрение зависимостей, что бы решить это из сервис-контейнера, например:

use App\Services\NewsletterSubscriptionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class NewsletterSubscriptionController extends Controller
{
/**
* Store a new newsletter subscriber.
*
* @param Request $request
* @param NewsletterSubscriptionService $service
* @return JsonResponse
*/

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

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

То, что мы сделали выше — добавили класс NewsletterSubscriptionService в качестве аргумента к методу store(), потому что Laravel допускает внедрение зависимостей в контроллеры. По сути, он сообщает laravel при вызове этого метода: Эй, я тоже хочу, чтобы вы передали мне NewsletterSubscriptionService!. Laravel отвечает: Хорошо, я сейчас возьму для вас один из сервис-контейнеров.

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

Поскольку теперь мы можем выполнять разрешение из контейнера, мы можем обновить наш тест:

class NewsletterSubscriptionControllerTest extends TestCase
{
/** @test */
public function success_response_is_returned()
{
// Create the mock of the service class.
$mock = Mockery::mock(NewsletterSubscriptionService::class)->makePartial();

// Set the mocked class' expectations.
$mock->shouldReceive('handle')
->once()
->withArgs(['mail@ashallendesign.co.uk'])
->andReturnNull();

// Add this mock to the service container to take the service class' place.
app()->instance(NewsletterSubscriptionService::class, $mock);

$this->postJson('/newsletter/subscriptions', [
'email' => 'mail@ashallendesign.co.uk',
])->assertExactJson([
'success' => true,
]);
}
}

В приведённом выше тесте мы начали использовать Mockery для имитации сервисного класса. Затем мы сообщаем сервисному классу, что к моменту завершения теста мы ожидаем, что метод handle() будет вызван один раз с параметром mail@ashallendesign.co.uk. После этого мы говорим Laravel: Привет, Laravel, если вам нужно разрешить NewsletterSubscriptionService в любой момент, вот один, который ты можешь вернуть.

Это означает, что теперь в нашем контроллере второй параметр не является сервисным классом, а имитация версии этого класса.

Когда мы запустим тест, то увидим, что метод handle() действительно вызван. В результате, если бы мы удалили вызов этого кода или добавили логику, которая могла бы предотвратить его вызов, тест завершится ошибкой. Потому что Mockery обнаружил бы, что метод не был вызван.

Бонусный совет

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

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

use App\Services\NewsletterSubscriptionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class NewsletterSubscriptionController extends Controller
{
/**
* Store a new newsletter subscriber.
*
* @param Request $request
* @return JsonResponse
*/

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

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

Вывод

Я надеюсь, что эта статья дала небольшое представление о том, как сделать своё приложение Laravel более тестируемым используя сервис-контейнер, имитацию/mock и внедрения зависимостей.

Помните, что тесты — ваши друзья и могут сэкономить невероятно много времени, стресса и давления, если они добавляются при первом написании кода. И в качестве дополнительно бонуса — более качественные тесты и большое покрытие тестами обычно означает меньше багов, что означает запросов в службу поддержки и больше довольных клиентов!

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

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

JavaScript: Неизменность / Иммутабельно­сть строк

Следующая Статья

Laravel: Получение информации о пользователе