Я часто слышу среди обсуждений в сообществе мнение, что unit тестирование в Laravel неправильное, сложное, а сами тесты долгие и не дающие никакой пользы. Из-за этого эти тесты мало кто пишет, ограничиваясь лишь feature тестами, а польза unit тестов стремится к 0.
Я тоже так считал когда-то, но, однажды я задумался и спросил себя — может быть я не умею их готовить?
Некоторое время я разбирался и на выходе у меня сформировалось новое понимание о unit тестах, а тесты стали понятными, дружелюбными, быстрыми и стали мне помогать.
Я хочу поделиться с сообществом своим пониманием, и еще лучше разобраться в этой теме, сделать мои тесты еще лучше.
Немного философии и ограничений
Laravel — местами своеобразный фреймворк. Особенно в части фасадов и Eloquent. Я не буду касаться обсуждений или осуждений этих моментов, но покажу, как я совмещаю их с юнит тестами.
Я пишу тесты после (или одновременно) написания основного кода. Возможно мой подход не будет совместим с подходом TDD или потребуют частичных корректировок.
Самый главный вопрос, который я задаю себе перед написанием теста — «что именно я хочу протестировать?». Это важный вопрос. Именно эта мысль позволила мне пересмотреть взгляды на написание unit тестов и самого кода проекта.
Тесты должны быть стабильными и минимально зависеть от окружения. Если при внесении мутаций, ваши тесты падают, скорее всего они хорошие. И наоборот, если не падают, вероятно они не очень хорошие.
Из коробки Laravel поддерживает 3 типа тестов:
- Browser
- Feature
- Unit
Я буду говорить преимущественно о Unit тестах.
Я не тестирую весь код через unit тесты (возможно, это не правильно). Некоторый код я не тестирую совсем (об этом ниже подробнее).
Если в тестах используются моки, не забывайте делать Mockery::close() на tearDown.
Некоторые примеры тестов «взяты из интернета».
Как я тестирую
Ниже сгруппирую примеры тестов по группам классов и постараюсь привести примеры тестов под каждую группу классов. Для большинства групп классов я не буду приводить примеры самого кода.
Middleware
Для unit теста middleware я создаю объект класса Request, объект нужного Middleware, далее вызываю метод handle и выполняю нужные asserts. Middleware по выполняемым действиям можно разделить на 3 группы:
- меняющие объект request (меняющие body request, либо сессии)
- делающие редирект (меняющие статус ответа)
- ничего не делающие с объектом request
Попробуем привести пример теста для каждой группы:
Предположим, что у нас есть следующий Middleware, задачей которого является модификация поля title:
class TitlecaseMiddleware
{
public function handle($request, Closure $next)
{
if ($request->title) {
$request->merge([
'title' => title_case($request->title)
]);
}
return $next($request);
}
}
Тест на подобный Middleware может выглядеть следующим образом:
public function testChangeTitleToTitlecase()
{
$request = new Request;
$request->merge([
'title' => 'Title is in mixed CASE'
]);
$middleware = new TitlecaseMiddleware;
$middleware->handle($request, function ($req) {
$this->assertEquals('Title Is In Mixed Case', $req->title);
});
}
Тесты для 2 и 3 группы будут такого плана соответственно:
$response = $middleware->handle($request, function () {});
$this->assertEquals($response->getStatusCode(), 302); // для редиректа
$this->assertEquals($response, null); // ничего не делаем с объектом request
Request class
Основная задача этой группы классов — авторизация и валидация запросов.
Я не тестирую данные классы через unit тесты (допускаю, что это может быть не верно), только через feature тесты. На мой взгляд, unit тесты избыточны для этих классов, но я нашел несколько интересных примеров, как это можно делать. Возможно, они помогут вам, если вы решите протестировать свой request класс unit тестами:
- https://medium.com/@daaaan/a-guide-to-unit-testing-laravel-form-requests-in-a-different-way-f1bdb6d86053
- https://stackoverflow.com/questions/36978147/unit-test-laravels-formrequest
Controller
Контроллеры я также не тестирую через unit тесты. Но при их тестировании я использую одну особенность, о которой я хотел бы рассказать.
Контроллеры, на мой взгляд, должны быть легкими. Их задача — получить правильный запрос, вызвать нужные сервисы и репозитории (так как оба этих термина для Laravel являются «чуждыми», ниже я дам пояснение по моей терминологии), вернуть ответ. Иногда вызвать событие, Job и т.п.
Соответственно, при тестировании через feature тесты нам нужно не просто вызвать контроллер с нужными параметрами и проверить ответ, но и замокать нужные сервисы и проверить, что они действительно вызываются (или не вызываются). Иногда — создать запись в БД.
Пример теста контроллера с моком класса сервиса:
public function testProductCategorySync()
{
$service = Mockery::mock(\App\Services\Product::class);
app()->instance(\App\Services\Product::class, $service);
$service->shouldReceive('sync')->once();
$response = $this->post('/api/v1/sync/eventsCallback', [
"eventType" => "PRODUCT_SYNC"
]);
$response->assertStatus(200);
}
Пример теста контроллера с моком фасадов (в нашем случае, событие, но по аналогии делается и для других фасадов Laravel):
public function testChangeCart()
{
Event::fake();
$user = factory(User::class)->create();
Passport::actingAs(
$user
);
$response = $this->post('/api/v1/cart/update', [
'products' => [
[
// our changed data
]
],
]);
$data = json_decode($response->getContent());
$response->assertStatus(200);
$this->assertEquals($user->id, $data->data->userId);
// and assert other data from response
Event::assertDispatched(CartChanged::class);
}
Service и Repositories
Данных типов классов «из коробки» нет. Я стараюсь контроллеры держать тонкими, поэтому выношу всю дополнительную работу в одну из этих групп классов.
Разницу между ними я определил следующим образом:
- Если мне требуется реализовать некоторую бизнес логику, то я выношу это в соответствующий сервисный слой (класс).
- Во всех остальных случаях я выношу это в группу классов репозитория. Как правило, туда уходит фунционал работы с Eloquent. Я понимаю, что это не совсем верное определение уровня репозитория. Также я слышал, что некоторые выносят все, что связано с Eloquent в модели. Мой подход является неким компромиссом, на мой взгляд, хотя и «академически» не совсем верен.
Для классов Repository я почти не пишу тестов.
Пример теста Service класса ниже:
public function testUpdateCart()
{
Event::fake();
$cartService = resolve(CartService::class);
$cartRepo = resolve(CartRepository::class);
$user = factory(User::class)->make();
$cart = $cartRepo->getCart($user);
// set data
$data = [
];
$newCart = $cartService->updateForUser($user, $data);
$this->assertEquals($data, $newCart->toArray());
Event::assertDispatched(CartChanged::class, 1);
}
Event-Listener, Jobs
Данные классы тестируются практически по общему принципу — мы готовим данные, необходимые для тестирования; вызываем нужный класс из фреймворка и проверяем результат.
Пример для Listener:
public function testHandle()
{
$user = factory(User::class)->create();
$cart = Cart::create([
'userId' => $user->id,
// other needed data
]);
$listener = new CreateTaskForSyncCart();
$listener->handle(new CartChanged($cart));
$job = // get our job
$this->assertSame(json_encode($cart->products), $job->payload);
$this->assertSame($user->id, $job->user_id);
// some additional asserts. Work with this data simplest for example
$this->assertTrue($updatedAt->equalTo($job->last_updated_at));
}
Console Commands
Консольные команды я рассматриваю как некоторый контроллер, который дополнительно умеет выводить (и производить более сложные манипуляции с консольным вводом-выводом, описанным в документации) данные. Соответственно, тесты получаются аналогичные контроллеру: мы проверяем, что нужные методы сервисов вызываются, срабатывают (или нет) события, а также проверяем взаимодействие с консолью (вывод или запрос данных).
Пример подобного теста:
public function testSendCartSyncDataEmptyJobs()
{
$service = m::mock(CartJobsRepository::class);
app()->instance(CartJobsRepository::class,
$service);
$service->shouldReceive('getAll')
->once()->andReturn(collect([]));
$this->artisan('sync:cart')
->expectsOutput('Get all jobs for sending...')
->expectsOutput('All count for sending: 0')
->expectsOutput('Empty jobs')
->assertExitCode(0);
}
Отдельные внешние библиотеки
Как правило, если отдельные библиотеки имеют особенности для unit тестов, то они описаны в документации. В остальных случаях, работа с этим кодом тестируется аналогично сервисному слою. Сами библиотеки покрывать тестами смысла нет (только если вы хотите отправить PR в эту библиотеку) и следует их рассматривать как некоторый black box.
На многих проектах мне приходится взаимодействовать через АПИ с другими сервисами. В Laravel для этих целей часто используется библиотека Guzzle. Мне показалось удобным вынести всю работу с другими сервисами в отдельный класс сервиса NetworkService. Это упростило мне написание и тестирование основного кода, помогло стандартизировать ответы и обработку ошибок.
Привожу примеры нескольких тестов для моего класса NetworkService:
public function testSuccessfulSendNetworkService()
{
$mockHandler = new MockHandler([
new Response(200),
]);
$handler = HandlerStack::create($mockHandler);
$client = new Client(['handler' => $handler]);
app()->instance(\GuzzleHttp\Client::class, $client);
$networkService = resolve(NetworkService::class);
$response = $networkService->sendRequestToSite('GET', '/');
$this->assertEquals('200', $response->getStatusCode());
}
public function testUnsupportedMethodSendNetworkService()
{
$networkService = resolve(NetworkService::class);
$this->expectException('\InvalidArgumentException');
$networkService->sendRequestToSite('PUT', '/');
}
public function testUnsetConfigUrlNetworkService()
{
$networkService = resolve(NetworkService::class);
Config::shouldReceive('get')
->once()
->with('app.api_url')
->andReturn('');
Config::shouldReceive('get')
->once()
->with('app.api_token')
->andReturn('token');
$this->expectException('\InvalidArgumentException');
$networkService->sendRequestToApi('GET', '/');
}
Выводы
Данный подход позволяет мне писать более качественный и понятный код, использовать преимущества подходов SOLID и SRP при написании кода. Мои тесты стали быстрее, а главное — они начали приносить мне пользу.
При активном рефакторинге при расширении или изменении функционала, мы сразу видим, что именно падает и можем быстро и точечно исправлять ошибки не выпуская их из локального окружения. Это делает исправление ошибок максимально дешевом.
Я надеюсь, что описанные мною принципы и подходы помогут вам разобраться с unit тестированием в Laravel и сделают unit тесты вашими помощниками в разработке кода.
Пишите ваши дополнения и комментарии.