...

четверг, 27 июня 2019 г.

Unit тестирование в Laravel

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


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 тесты вашими помощниками в разработке кода.

Пишите ваши дополнения и комментарии.

Let's block ads! (Why?)

Комментариев нет:

Отправить комментарий