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


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

Комментарии (7)


  1. greabock
    27.06.2019 13:24
    +2

    Молодцом. Только пара замечаний по терминологии:


    1. Настоящие репозитории в связке с AR (Eloquent) невозможны — уже обсуждалось не раз. Любые репозитории с AR — это на самом деле сервисы или просто макросы запросов. За исключением тех случаев, когда AR используется как мета-маппинг для DM (Analogue ORM, например).


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



    1. yushkevichv Автор
      27.06.2019 13:26
      +1

      Спасибо. Я согласен, что это не настоящие репозитории. По сути, я 2 Namespace использую. В один складываю бизнес логику и тестирую. В другой работу с Eloquent и не практически никогда не тестирую. Как раз поэтому я и сделал сноску о моей терминологии и что «академически» это не верно называть репозиториями.


  1. RA_ZeroTech
    27.06.2019 15:21

    Тест лучше писать до написания кода. Так больше контроля во время разработки.

    Написание тестов после написания кода грозит тем, что требования теста подгоняются под готовый код. И как следствие, можно пропустить кейс с ошибкой в коде. Которая проявится потом.


    1. yushkevichv Автор
      27.06.2019 15:27

      Подход TDD имеет место быть. Я делюсь своим опытом. У меня пока нет понятного опыта работы с TDD, но хочу попробовать в будущем детальнее посмотреть сюда.


      Умение составлять тест-кейсы и задавать вопрос «что именно я хочу протестировать» помогает снизить риски пропустить ошибку. TDD подход не исключает этих рисков. Я бы даже добавил, что 100% coverage через TDD не является гарантией отсутствия багов.


      Также рефактор кода во время написания тестов для меня является «нормальной» практикой. Я это делаю достаточно часто.


  1. Nikolino
    29.06.2019 15:39

    Ничего не сказано про тестирование моделей. А ведь туда уходит часть логики связанная именно с моделью: асессоры, мутаторы, кастинги ($casts) определенных полей, наличие полей в $fillable, relations и т.д.

    Дополню тему этой ссылкой: github.com/framgia/laravel-test-guideline
    По моделям там вполне исчерпывающе.

    Вижу, что используется camelCase, но на мой взгляд, читается он хуже, чем snake_case, особенно в feature тестах, где тестим какую-то бизнес-логику. Авторы Laracasts используют snake_case.

    Сравним:
    public function test_if_total_spent_kcals_changed_after_adding_exercise() {}
    public function testIfTotalSpentKcalsChangedAfterAddingExercise() {}

    Вроде есть PSR-2, но по-моему очевидно, что для длинных названий методов тестов лучше не следовать camelCase.


    1. yushkevichv Автор
      29.06.2019 15:47

      Спасибо за дополнение.
      Я стараюсь не тестировать фреймворк своими тестами. Может быть и есть некоторый смысл в тестах состава полей в переменой fillable, но мне кажется это не оправданным.
      У меня в моделях нет бизнес логики, я все выношу ее в сервисный слой. Поэтому сами модели у меня достаточно легкие.
      Писать тесты на связи — можно, конечно, но зачем? У вас были случаи, когда эти тесты вам помогли? Тесты ради тестов или coverage никому не нужны.

      Насчет стиля именования тестов.
      Вам очевидно одно, мне другое. Авторы laracast и ряда пакетов используют snake_case, авторы Laravel и ряда пакетов следуют PSR-2 и используют camelCase. В каждой команде есть свой style-guide написания кода. Это вкусовщина, а моя статья о другом. Давайте не будем отвлекаться и спорить о вкусах.


    1. VolCh
      29.06.2019 18:03

      Как раз в случае длинных многословных имён у camelCase есть преимущество: больше слов помещается в ширину экрана.