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

в 23:30, , рубрики: php, 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%. И я буду очень рад в таком случае. Это будет значит что моя цель достигнута: кто-то ещё открыл для себя тестирование, и немножечко повысил общий уровень профессионализма в нашей сфере!

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

Автор: vlreshet

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js