...

суббота, 25 января 2020 г.

PHPUnit. «Как мне протестировать мой чёртов контроллер», или тестирование для сомневающихся

Привет хабр.

image

Да, это очередной пост на тему тестирования. Казалось бы, что тут уже можно обсуждать? Все кому надо — пишут тесты, кому не надо — не пишут, все счастливы! Факт же в том, что большинство постов о юнит-тестировании имеют… как бы так никого не обидеть… идиотские примеры! Нет, ну правда! Сегодня я попытаюсь это исправить. Прошу под кат.

И так, быстрый гуглёж на тему тестов находит просто уйму статей, которые в своей основной массе делятся на две категории:

1) Счастье копирайтера. Сначала мы видим долгое вступление, потом историю юнит-тестирования на Древней Руси, потом десять лайфхаков с тестами, и в конце-концов пример. С тестированием кода вроде этого:

<?php

class Calculator
{
    public method plus($a, $b)
    {
         return $a + $b;
    }
}
И я сейчас не шучу. Я правда видел статьи с «калькулятором» в роли учебного пособия. Да-да, я понимаю что для начала надо всё упростить, абстракции, туда-сюда… Но ведь на этом всё и заканчивается! А дальше дорисуйте сову, как говорится

2) Чрезмерно переусложнённые примеры. А давайте напишем тест, и запихнём его в Gitlab CI, а потом будем ещё автодеплоить если тест прошёл, а на тесты ещё PHP Infection намажем, да с Hudson всё соеденим. И так далее в таком стиле. Вроде и полезно, а вроде и совсем не то что ты ищешь. А ведь хочется просто чуток увеличить стабильность своего проекта. А все эти непрерывности — ну потом, не всё же сразу…

В итоге люди сомневаются, «а надо ли оно мне». Я же, в свою очередь, хочу попытаться рассказать о тестировании понятнее. И оговорюсь сразу — я разработчик, я не тестировщик. Я уверен, что я сам многого не знаю, а моим первым в жизни словом не было слово «мок». Я даже никогда не работал по TDD! Зато я точно знаю, что даже мой текущий уровень навыков позволил мне покрыть несколько проектов тестами, а эти самые тесты уже отловили свой десяток багов. А если мне это помогло — значит и кому-то ещё может помочь. Некоторые пойманные баги было бы сложно выловить вручную.

Для начала, краткий ликбез в формате вопрос-ответ:

Q: Я обязан использовать какой-то фреймворк? А что если у меня Yii? А если Kohana? А если %one_more_framework_name%?
А: Нет, PHPUnit это самостоятельный фреймворк для тестирования, вы можете его прикрутить хоть к легаси-коду на самопальном фреймворке.

Q: А я сейчас руками сайт по-быстрому прохожу, и нормально. Зачем оно мне?
А: «Прогон» нескольких десятков тестов длится несколько секунд. Автоматическое тестирование всегда быстрее мануального, а при качественных тестах ещё и надёжнее, так как покрывает все сценарии.

Q: У меня легаси-код с функциями по 2000 строк. Я могу это тестировать?
A: И да, и нет. В теории — да, любой код можно покрыть тестом. На практике, код должен писаться с заделом под будущее тестирование. Функция на 2000 строк будет иметь слишком много зависимостей, ветвлений, пограничных случаев. Может и получится её в итоге всю покрыть, но скорее всего это займёт у вас непозволительно много времени. Чем качественнее код — тем легче его тестировать. Чем лучше соблюдается принцип Single Responsibility — тем проще будут тесты. Для тестирования старых проектов чаще всего придётся сначала здорово отрефакторить их.
image

Q: У меня очень простые методы (функции), что там тестировать? Там всё надёжно, там нет места ошибке!
А: Следует понимать, вы не тестируете правильность реализации функции (если у вас не TDD), вы просто «фиксируете» её текущее состояние работы. В будущем, когда вам понадобится её изменять, вы сможете с помощью теста быстро определять не сломали ли вы её поведение. Пример: есть функция, которая валидирует email. Делает она это регуляркой.

function isValid($email)
{
    $regex = "very_complex_regex_here";

    if (is_array($email)) {
        $result = true;

        foreach ($email as $item) {
            if (preg_match($regex, $item) === 0) {
                $result = false;
            }
        }
    } else {
        $result = preg_match($regex, $emai) ==! 0;
    }

    return $result;
}
Весь ваш код расчитывает на то, что если передать в эту функцию валидный имейл — она вернёт true. Массив валидных имейлов — тоже true. Массив хотя бы с одним невалидным имейлом — false. Ну и так далее, по коду суть понятна. Но настал день, и вы решили заменить монструозную регулярку внешним API. Но как гарантировать, что переписанная функция не поменяла принцип работы? Вдруг она плохо обработает массив? Или вернёт не boolean? А тесты смогут это всё держать под контролем. Хорошо написанный тест сразу укажет на поведение функции отличное от ожидаемого.

Q: Когда я начну видеть толк от тестов?
А: Во-первых, как только покроете значительную часть кода. Чем ближе покрытие к 100% — тем надёжнее тестирование. Во-вторых, как только придётся делать глобальные изменения, либо же изменения в сложной части кода. Тесты могут отловить такие проблемы, которые вручную могут быть легко упущены (пограничные случаи). Во-третьих, при написании самых тестов! Часто возникает ситуация, когда при написании теста выявляются недостатки кода, которые на первый взгляд незаметны.

Q: Ну вот, у меня сайт на laravel. Сайт это не функция, сайт это хренова гора кода. Как тут тестировать?
А: Именно об этом пойдёт речь дальше. Вкратце: отдельно тестируем методы контроллеров, отдельно middleware, отдельно сервисы, и т. д.

Одна из идей Unit-тестирования заключается в изоляции тестируемого участка кода. Чем меньше кода проверяется одним тестом — тем лучше. Посмотрим пример максимально приближённый к реальной жизни:

<?php

class Controller
{
    public function __construct($userService, $emailService)
    {
        $this->userService = $userService;
        $this->emailService = $emailService;
    }

    public function login($request)
    {
        if (empty($request->login) || empty($request->password)) {
            return "Auth error";
        }

        $password = $this->userService->getPasswordFor($request->login);

        if (empty($password)) {
            return "Auth error - no password";
        }

        if ($password !== $request->password) {
            return "Incorrect password";
        }

        $this->emailService->sendEmail($request->login);

        return "Success";
    }
}

// ....

/* somewhere in project core */
$controller = new Controller($userService, $emailService);

$controller->login($request);
Это очень типичный метод логина в систему на небольших проектах. Всё что мы ожидаем — это правильные сообщения о ошибках, и отправленный имейл в случае успешного логина. Как же протестировать данный метод? Для начала, надо выявить внешние зависимости. В нашем случае их две — $userService и $emailService. Они передаются через конструктор класса, что здорово облегчает нам задачу. Но, как говорилось ранее, чем меньше кода мы тестим за один проход — тем лучше.

Эмуляция, подмена объектов называется моканьем (от англ. mock object, буквально: «объект-пародия»). Никто не мешает писать такие объекты вручную, но всё уже придумано до нас, поэтому на помощь приходит такая чудесная библиотека как Mockery. Давайте создадим моки для сервисов.

$userService = Mockery::mock('user_service');
$emailService = Mockery::mock('email_service');

Теперь создадим объект $request. Для начала, протестируем логику проверки полей login и password. Мы хотим быть уверенны, что если их не будет — наш метод корректно обработает этот случай, и вернёт нужное (!) сообщение.
function testEmptyLogin()
{
    $userService = Mockery::mock('user_service');
    $emailService = Mockery::mock('email_service');

    $controller = new Controller($userService, $emailService);

    $request = (object) [];

    $result = $controller->login($request);
}
Ничего сложного, не так ли? Мы создали заглушки для необходимых параметров класса, создали экземпляр нужного класса, и «дёрнули» нужный метод, передавая заведомо неправильный запрос. Получили ответ. Но как теперь его проверить? Это и есть самая важная часть теста — так называемое утверждение, assertion. PHPUnit имеет десятки готовых assertions. Просто используем одну из них
function testEmptyLogin()
{
    $userService = Mockery::mock('user_service');
    $emailService = Mockery::mock('email_service');

    $controller = new Controller($userService, $emailService);

    $request = (object) [];

    $result = $controller->login($request);
    
    // vv assertion here! vv
    $this->assertEquals("Auth error", $result);
}

Данный тест гарантирует следующее — если в метод логин прилетит аргумент-объект у которого не найдётся поля login или password — то метод вернёт строку «Auth error». Вот, в общем-то, и всё. Так просто — но так полезно, ведь теперь мы можем редактировать метод login без страха сломать что-то. Наш frontend может быть уверенным, что в случае чего — он получит именно такую ошибку. И если кто-то сломает это поведение (например, решит изменить текст ошибки) — то тест сразу же об этом просигнализирует! Допишем остальные проверки, чтобы покрыть как можно больше возможных сценариев.
function testEmptyPassword()
{
    $userService = Mockery::mock('user_service');

    // $userService->getPasswordFor(__any__arg__); // ''
    $userService->shouldReceive('getPasswordFor')->andReturn('');

    $emailService = Mockery::mock('email_service');

    $request = (object) [
        'login' => 'john',
        'pass' => '1234'
    ];

    $result = (new Controller($userService, $emailService))->login($request);

    $this->assertEquals("Auth error - no password", $result);
}

function testUncorrectPassword()
{
    $userService = Mockery::mock('user_service');

    // $userService->getPasswordFor(__any__arg__); // '4321'
    $userService->shouldReceive('getPasswordFor')->andReturn('4321');

    $emailService = Mockery::mock('email_service');

    $request = (object) [
        'login' => 'john',
        'pass' => '1234'
    ];

    $result = (new Controller($userService, $emailService))->login($request);

    $this->assertEquals("Incorrect password", $result);
}

function testSuccessfullLogin()
{
    $userService = Mockery::mock('user_service');

    // $userService->getPasswordFor(__any__arg__); // '1234'
    $userService->shouldReceive('getPasswordFor')->andReturn('1234');

    $emailService = Mockery::mock('email_service');

    $request = (object) [
        'login' => 'john',
        'pass' => '1234'
    ];

    $result = (new Controller($userService, $emailService))->login($request);

    $this->assertEquals("Success", $result);
}

Заметили методы shouldReceive и andReturn? Они позволяют нам создавать методы в заглушках, которые вернут только то, что нам надо. Надо протестировать ошибку неправильного пароля? Пишем заглушку $userService которая всегда возвращает неверный пароль. И всё.

А что же по-поводу зависимостей, спросите вы. Их то мы «заглушили», а вдруг они сломаются? А вот именно для этого и надо максимальное покрытие кода тестами. Мы не будем проверять работу этих сервисов в контексте логина — мы будем тестировать логин рассчитывая на правильную работу сервисов. А потом напишем такие же, изолированные тесты для этих сервисов. А потом тесты для их зависимостей. И так далее. В итоге каждый отдельный тест гарантирует только правильную работу маленького куска кода, при условии что все его зависимости работают правильно. А так как все зависимости тоже покрыты тестами — то их правильная работа тоже гарантируется. В итоге, любое изменение в систему ломающее логику работы даже малейшего участка кода — сразу же отобразится в том или ином тесте. Как конкретно запустить прогон тестов — рассказывать не буду, документация у PHPUnit вполне хорошая. А в Laravel, например, достаточно выполнить vendor/bin/phpunit с корня проекта, чтобы увидеть сообщение вроде этогоimage — все тесты прошли успешно. Или вроде этогоimageОдин из семи assert-ов провалился.

«Это, конечно, классно, но что из этого я не поймаю руками?» — спросите вы. А давайте для этого представим следующий код

<?php
function getInfo($infoApi, $userName)
{
    $response = $infoApi->getInfo($userName);

    if ($response->status === "API Error") {
        return null;
    }

    return $response->result;
}

// ... somewhere in system

$api = new ExternalApi();

$info = getInfo($api, 'John');

if ($info === null) {
    die('Api is down');
}

echo $info;
Мы видим упрощённую модель работы с внешним API. Функция использует какой-то класс для работы c API, и в случае ошибки — возвращает null. Если же при использовании этой функции мы получаем null — следует «поднять панику» (отправить сообщение в слак, или имейл разработчику, или кинуть ошибку в кибану. Да куча вариантов). Вроде всё просто, не так ли? Но представим что через некоторое время другой разработчик решил «поправить» эту функцию. Он решил что возвращать null — это прошлый век, и следует кидать исключение.
function getInfo($infoApi, $userName): string
{
    $response = $infoApi->getInfo($userName);

    if ($response->status === "API Error") {
        throw new ApiException($response);
    }

    return $response->result;
}
И он даже переписал все участки кода, где вызывалась эта функция! Все, кроме одного. Его он упустил. Отвлёкся, устал, просто ошибся — да мало ли. Факт лишь в том, что один участок кода всё ещё ожидает старого поведения функции. А PHP это у нас не Java — мы не получим ошибку компиляции на основании того, что throwable функция не завёрнута в try-catch. В итоге в одном из 100 сценариев использования сайта, в случае падения API — мы не получим сообщение от системы. Более того, при ручном тестировании мы скорее всего не отловим этот вариант события. API у нас внешнее, от нас не зависит, работает хорошо — и скорее всего мы не попадём «руками» на случай отказа API, и неверной обработки исключения. Зато будь у нас тесты — они отлично отловят данный кейс, потому что класс ExternalApi в ряде тестов у нас «заглушен», и эмулирует как нормальное поведение, так и падение. И следующий тест у нас упадёт
function testApiFail()
{
    $api = Mockery::mock('api');

    $api->shouldReceive('getInfo')->andReturn((object) [
        'status' => 'API Error'
    ]);

    $result = getInfo($api, 'name');

    $this->assertNull($result);
}

Этой информации, на самом деле, достаточно. Если у вас не легаси лапша, уже спустя минут 20-30 вы сможете написать свой первый тест. А спустя несколько недель — узнать что-то новое, крутое, вернуться в комментарии под этот пост, и написать какой автор говнокодер, и не знает о %framework_name%, и тесты хреновые пишет, а надо делать %this_way%. И я буду очень рад в таком случае. Это будет значит что моя цель достигнута: кто-то ещё открыл для себя тестирование, и немножечко повысил общий уровень профессионализма в нашей сфере!

Аргументированная критика приветствуется.

Let's block ads! (Why?)

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

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