Рефакторинг тестов

Рефакторинг тестов

Простота — один из главных факторов, почему люди выбирает Laravel. Это касается и тестов — фреймворк позволяет их делать проще, быстрее и лучше, в чем я убеждаюсь изо дня в день. Если вы погрузитесь в Laravel-сообщество, то найдете удивительные вещи, такие как Test Driven Laravel от Adam Wathan, серия Testing skill от Jeffrey Way, Confident Laravel и Tests Generator Shift от Jason McCreary.

Лично я большой поклонник подхода Сверху-вниз (Top-down) TDD (Test-Driven Development — Разработка через тестирование), и я подталкиваю многих разработчиков/команд к его использованию, но сам я все еще не до конца понимаю, как организовать свои тесты, что использовать модульные или функциональные тесты, и как нужно писать сами тесты, могу ли я рассматривать тесты в качестве документацию для себя и для других разработчиков?

Последние пару дней я трачу большую часть своего времени на рефакторинг комплекта тестов и открываю для себя интересные приёмы и хитрости, которые помогли мне сделать мои тесты лучше, особенно для пользователей, работающих с API. Надеюсь, вы также найдете их полезными. Я сфокусировался на стандартизации, но максимально сохраняя их простоту и адаптируемость.

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

public function test_users_can_get_their_categories_list()
{
    factory(Category::class, 5)->create();
    $user = factory(User::class)->create();
    Passport::actingAs($user);

    $response = $this->get('api/categories');
    
    $response->assertSuccessful();
    $response->assertJsonCount(5);
    $response->assertJsonStructure([
        [
            'id',
            'name',
            'type',
        ],
    ]);
    foreach ($response->json() as $category) {
        $this->assertEquals($user->id,$category['user_id']);
    }
}

Очень простой и понятный тест, но тут я спросил себя, зачем мне возвращать $response, если я знаю, что это цепочный класс? Я понял, что обычно делаю это, так как у меня есть кастомные ассерты (команды assert), например кнопка с тремя строками, или иногда мне нужно продебажить ответ и посмотреть JSON.

public function test_users_can_get_their_categories_list()
{
    // ...

    $response = $this->get('api/categories')
            ->assertSuccessful()
            ->dump()
            ->assertJsonCount(5)
            ->assertJsonStructure([
                [
                    'id',
                    'name',
                    'type',
                ],
            ]);

    foreach ($response->json() as $category) {
        $this->assertEquals($user->id,$category['user_id']);
    }
}

TestResponse поставляется с хелпером dump, которая позволяет вам дебажить JSON в цепочном формате, единственное, что нас может беспокоить — это кастомные ассерты.
И снова тут нам поможет TestResponse, потому что для него можно делать макросы. Поэтому я продолжаю работать с цепочками, подставляя новый макрос.

// добавьте этот макрос в ваш сервис-провайдер
TestResponse::macro('assertJsonPaths', function ($path, $expected) {
    foreach ($this->json($path) as $real) {
        PHPUnit::assertEquals($expected, $real);
    }

    return $this;
});

// рефакторим тест
public function test_users_can_get_their_categories_list()
{
    factory(Category::class, 5)->create();
    $user = factory(User::class)->create();
    Passport::actingAs($user);

    $this->get('api/categories')
        ->assertSuccessful()
        ->assertJsonCount(5)
        ->assertJsonPaths('*.user_id', $user->id)
        ->assertJsonStructure([
            [
                'id',
                'name',
                'type',
            ],
        ]);
}

В этом примере работа с Passport выглядит круто, потому что он находится прямо над get-запросом, но это не всегда так, и, к сожалению, хелпер actingAs в TestCase не поддерживает все функции Passport. Поэтому я решил добавить новый метод в TestCase и заменить им любое использование Passport::acting, действующий как часть цепочного формата.

// добавьте этот метод в ваш TestCase
public function passportAs($user, $scopes = [], $guard = 'api')
{
    Passport::actingAs($user, $scopes, $guard);

    return $this;
}

// снова рефакторим этот тест
public function test_users_can_get_their_categories_list()
{
    factory(Category::class, 5)->create();
    $user = factory(User::class)->create();
    
    $this->passportAs($user)
        // если вы не используйте passport-скоуп , то можете тогда сделать так
        // ->actingAs($user,'api')
        ->get('api/categories')
        ->assertSuccessful()
        ->assertJsonCount(5)
        ->assertJsonPaths('*.user_id', $user->id)
        ->assertJsonStructure([
            [
                'id',
                'name',
                'type',
            ],
        ]);
}

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

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

Если мы хотим писать тесты для людей, в том числе, в качестве проектной документации, а не только для CI/CD (Continuous integration / Continuous delivery — «Непрерывная интеграция / Непрерывное развертывание»), то нам нужно избавляться от любых магических эффектов, четко упоминая их в тесте. Большинство из этих сценариев будут в фазе подготовки и из-за событийного-ориентированного подхода к разработке. Итак, как мы можем это решить?

// Вариант 1, написать простой комментарий
public function test_users_can_get_their_categories_list()
{
    factory(Category::class, 5)->create();
    // как только пользователь будет создан, у нас будет копия дефолтных категорий
    $user = factory(User::class)->create();
    
    // ...
}

// Вариант 2, используйте предоставляемую PHPUnit функцию зависимостей

/**
 * @depends test_user_have_a_copy_of_the_default_categories
 */
public function test_users_can_get_their_categories_list()
{
    factory(Category::class, 5)->create();
    $user = factory(User::class)->create();
    
    // ...
}

Первый вариант самый простой и может использоваться в любом случае, но он не даст нам solid-зависимости между самими тестовыми примерами. Второй вариант является абсолютным победителем, поскольку он позволяет разработчикам и компьютерам знать, какие тесты зависят друг от друга, но сейчас он поддерживает только одноклассовую зависимость — черновой pull-request для поддержки многоклассовых зависимостей пока в процессе — но вы пока можете использовать смесь их обоих прямо сейчас.

Финальная версия теста:

/**
 * @depends test_user_have_a_copy_of_the_default_categories
 */
public function test_users_can_get_their_categories_list()
{
    factory(Category::class, 5)->create();

    $this->passportAs($user = factory(User::class)->create())
        ->get('api/categories')
        ->assertSuccessful()
        ->assertJsonCount(5)
        ->assertJsonPaths('*.user_id', $user->id)
        ->assertJsonStructure([
            [
                'id',
                'name',
                'type',
            ],
        ]);
}

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

Автор: Mohammed S. Shurrab
Перевод: Алексей Широков

Наш Телеграм-канал — следите за новостями о Laravel.