Да, это очередной пост на тему тестирования. Казалось бы, что тут уже можно обсуждать? Все кому надо — пишут тесты, кому не надо — не пишут, все счастливы! Факт же в том, что большинство постов о юнит-тестировании имеют… как бы так никого не обидеть… идиотские примеры! Нет, ну правда! Сегодня я попытаюсь это исправить. Прошу под кат.
И так, быстрый гуглёж на тему тестов находит просто уйму статей, которые в своей основной массе делятся на две категории:
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 — тем проще будут тесты. Для тестирования старых проектов чаще всего придётся сначала здорово отрефакторить их.
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 с корня проекта, чтобы увидеть сообщение вроде этого — все тесты прошли успешно. Или вроде этогоОдин из семи 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%. И я буду очень рад в таком случае. Это будет значит что моя цель достигнута: кто-то ещё открыл для себя тестирование, и немножечко повысил общий уровень профессионализма в нашей сфере!
Аргументированная критика приветствуется.
Комментариев нет:
Отправить комментарий