Я часто слышу среди обсуждений в сообществе мнение, что 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 тесты вашими помощниками в разработке кода.
Пишите ваши дополнения и комментарии.
