Привет, Хабр! Как-то раз на нашем внутреннем семинаре мой руководитель – глава отдела тестирования – начал свою речь со слов «тестирование не нужно». В зале все притихли, некоторые даже пытались упасть со стульев. Он продолжил свою мысль: без тестирования вполне возможно создать сложный и дорогостоящий проект. И, скорее всего, он будет работать. Но представьте, насколько увереннее вы будете себя ощущать, зная, что продукт работает как надо.
В Badoo релизы происходят довольно часто. Например, серверная часть наравне с desktop web релизится дважды в день. Так что мы не понаслышке знаем, что сложное и медленное тестирование – камень преткновения разработки. Быстрое же тестирование – это счастье. Итак, сегодня я расскажу о том, как в компании Badoo устроено smoke-тестирование.
Что такое smoke-тестирование
Первое своё применение этот термин получил у печников, которые, собрав печь, закрывали все заглушки, затапливали её и смотрели, чтобы дым шёл только из положенных мест. Википедия
В оригинальном своём применении smoke-тестирование предназначено для проверки самых простых и очевидных кейсов, без которой любой другой вид тестирования будет неоправданно излишним.
Давайте рассмотрим простой пример. Предпродакшн нашего приложения находится по адресу bryak.com (любые совпадения с реальными сайтами случайны). Мы подготовили и залили туда новый релиз для тестирования. Что стоит проверить в первую очередь? Я бы начал с проверки того, что приложение всё ещё открывается. Если web-сервер нам отвечает «200», значит, всё хорошо и можно приступать к проверке функционала.
Как автоматизировать такую проверку? В принципе, можно написать функциональный тест, который будет поднимать браузер, открывать нужную страницу и убеждаться, что она отобразилась как надо. Однако, у этого решения есть ряд минусов. Во-первых, это долго: процесс запуска браузера займёт больше времени, чем сама проверка. Во-вторых, это требует поддержания дополнительной инфраструктуры: ради такого простого теста нам потребуется где-то держать сервер с браузерами. Вывод: надо решить задачу иначе.
Наш первый smoke-тест
В Badoo серверная часть написана по большей части на PHP. Unit-тесты по понятным причинам пишутся на нём же. Итого у нас уже есть PHPUnit. Чтобы не плодить технологии без необходимости, мы решили писать smoke-тесты тоже на PHP. Помимо PHPUnit, нам потребуется клиентская библиотека работы с URL (libcurl) и PHP extension для работы с ней – cURL.
По сути, тесты просто делают нужные нам запросы на сервер и проверяют ответы. Всё завязано на методе getCurlResponse() и нескольких типах ассертов.
Сам метод выглядит примерно так:
public function getCurlResponse(
$url,
array $params = [
‘cookies’ => [],
‘post_data’ => [],
‘headers’ => [],
‘user_agent’ => [],
‘proxy’ => [],
],
$follow_location = true,
$expected_response = ‘200 OK’
)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_HEADER, 1);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
if (isset($params[‘cookies’]) && $params[‘cookies’]) {
$cookie_line = $this->prepareCookiesDataByArray($params[‘cookies’]);
curl_setopt($ch, CURLOPT_COOKIE, $cookie_line);
}
if (isset($params[‘headers’]) && $params[‘headers’]) {
curl_setopt($ch, CURLOPT_HTTPHEADER, $params[‘headers’]);
}
if (isset($params[‘post_data’]) && $params[‘post_data’]) {
$post_line = $this->preparePostDataByArray($params[‘post_data’]);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $post_line);
}
if ($follow_location) {
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
}
if (isset($params[‘proxy’]) && $params[‘proxy’]) {
curl_setopt($ch, CURLOPT_PROXY, $params[‘proxy’]);
}
if (isset($params[‘user_agent’]) && $params[‘user_agent’]) {
$user_agent = $params[‘user_agent’];
} else {
$user_agent = USER_AGENT_DEFAULT;
}
curl_setopt($ch, CURLOPT_USERAGENT, $user_agent);
curl_setopt($ch, CURLOPT_AUTOREFERER, 1);
$response = curl_exec($ch);
$this->logActionToDB($url, $user_agent, $params);
if ($follow_location) {
$this->assertTrue(
(bool)$response,
'Empty response was received. Curl error: ' . curl_error($ch) . ', errno: ' . curl_errno($ch)
);
$this->assertServerResponseCode($response, $expected_response);
}
curl_close($ch);
return $response;
}
Сам метод умеет по заданному URL возвращать ответ сервера. На вход принимает параметры, такие как cookies, headers, user agent и прочие данные, необходимые для формирования запроса. Когда ответ от сервера получен, метод проверяет, что код ответа совпадает с ожидаемым. Если это не так, тест падает с ошибкой, сообщающей об этом. Это сделано для того, чтобы было проще определить причину падения. Если тест упадёт на каком-нибудь ассерте, сообщив нам, что на странице нет какого-то элемента, ошибка будет менее информативной, чем сообщение о том, что код ответа, например, «404» вместо ожидаемого «200».
Когда запрос отправлен и ответ получен, мы логируем запрос, чтобы в дальнейшем при необходимости легко воспроизвести цепочку событий, если тест упадёт или сломается. Я об этом расскажу ниже.
Самый простой тест выглядит примерно так:
public function testStartPage()
{
$url = ‘bryak.com’;
$response = $this->getCurlResponse($url);
$this->assertHTMLPresent('<body>', $response, 'Error: test cannot find body element on the page.');
}
Такой тест проходит менее чем за секунду. За это время мы проверили, что стартовая страница отвечает «200», и на ней есть элемент body. С тем же успехом мы можем проверить любое количество элементов на странице, продолжительность теста существенно не изменится.
Плюсы таких тестов:
- скорость – тест можно запускать так часто, как это необходимо. Например, на каждое изменение кода;
- не требуют специального софта и железа для работы;
- их несложно писать и поддерживать;
- они стабильные.
По поводу последнего пункта. Я имею в виду – не менее стабильные, чем сам проект.
Авторизация
Представим, что с момента, как мы написали наш первый smoke-тест, прошло три дня. Само собой, за это время мы покрыли все неавторизованные страницы, какие только нашли, тестами. Немного посидели, порадовались, но потом осознали, что всё самое важное в нашем проекте находится за авторизацией. Как бы получить возможность это тоже тестировать?
Чем отличается авторизованная страница от неавторизованной? С точки зрения сервера всё просто: если в запросе есть информация, по которой пользователя можно идентифицировать, нам вернётся авторизованная страница.
Самый просто вариант – авторизационная cookie. Если добавить её к запросу, то сервер нас «узнает». Такую cookie можно захардкодить в тесте, если её время жизни довольно большое, а можно получать автоматически, отправляя запросы на страницу авторизации. Давайте подробнее рассмотрим второй вариант.
На нашем сайте страница авторизации выглядит так:
Нас интересует форма, куда надо ввести логин и пароль пользователя.
Открываем эту страницу в любом браузере и открываем инспектор. Вводим данные пользователя и сабмитим форму.
В инспекторе появился запрос, который нам надо имитировать в тесте. Можно посмотреть, какие данные, помимо очевидных (логин и пароль), отсылаются на сервер. Для каждого проекта по-разному: это может быть remote token, данные каких-либо cookies, полученных ранее, user agent и так далее. Каждый из этих параметров придётся предварительно получить в тесте, прежде чем сформировать запрос на авторизацию.
В инструментах разработчика любого браузера можно скопировать запрос, выбрав пункт copy as cURL. В таком виде команду можно вставить в консоль и рассматривать там. Там же её можно опробовать, поменяв или добавив параметры.
В ответ на такой запрос сервер вернёт нам cookies, которые мы будем добавлять в дальнейшие запросы, чтобы тестировать авторизованные страницы.
Поскольку авторизация – довольно долгий процесс, авторизационную cookie я предлагаю получать только один раз для каждого пользователя и сохранять где-то. У нас, например, такие cookies хранятся в массиве. Ключом является логин пользователя, а значением – информация о них. Если для следующего пользователя ключа ещё нет, авторизуемся. Если есть – делаем интересующий нас запрос сразу.
Пример кода теста, проверяющего авторизованную страницу, выглядит примерно так:
public function testAuthPage()
{
$url = ‘bryak.com’;
$cookies = $this->getAuthCookies(‘employee@bryak.com’, ‘12345’);
$response = $this->getCurlResponse($url, [‘cookies’ => $cookies]);
$this->assertHTMLPresent('<body>', $response, 'Error: test cannot find body element on the page.');
}
Как мы видим, добавился метод, который получает авторизационную cookie и просто добавляет её в дальнейший запрос. Сам метод реализуется довольно просто:
public function getAuthCookies($email, $password)
{
// check if cookie already has been got
If (array_key_exist($email, self::$known_cookies)) {
return self::$known_cookies[$email];
}
$url = self::DOMAIN_STAGING . ‘/auth_page_adds’;
$post_data = [‘email’ => $email, ‘password’ => $password];
$response = $this->getCurlResponse($url, [‘post_data’ => $post_data]);
$cookies = $this->parseCookiesFromResponse($response);
// save cookie for further use
self::$known_cookies[$email] = $cookies;
return $cookies;
}
Метод сначала проверяет, есть ли для данного e-mail (в вашем случаем это может быть логин или что-то ещё) уже полученная ранее авторизационная cookie. Если есть, он её возвращает. Если нет, он делает запрос на авторизационную страницу (например, bryak.com/auth_page_adds) с необходимыми параметрами: e-mail и пароль пользователя. В ответ на этот запрос сервер присылает заголовки, среди которых есть интересующие нас cookies. Выглядит это примерно так:
HTTP/1.1 200 OK
Server: nginx
Content-Type: text/html; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
Set-Cookie: name=value; expires=Wed, 30-Nov-2016 10:06:24 GMT; Max-Age=-86400; path=/; domain=bryak.com
Из этих заголовков нам при помощи несложного регулярного выражения надо получить название cookie и её значение (в нашем примере это name=value). У нас метод, который парсит ответ, выглядит так:
$this->assertTrue(
(bool)preg_match_all('/Set-Cookie: (([^=]+)=([^;]+);.*)\n/', $response, $mch1),
'Cannot get "cookies" from server response. Response: ' . $response
);
После того, как cookies получены, мы можем смело добавлять их в любой запрос, чтобы сделать его авторизованным.
Разбор падающих тестов
Из вышесказанного следует, что такой тест – это набор запросов к серверу. Делаем запрос, совершаем манипуляцию с ответом, делаем следующий запрос и так далее. В голову закрадывается мысль: если такой тест упадёт на десятом запросе, может оказаться непросто разобраться в причине его падения. Как упростить себе жизнь?
Прежде всего я бы хотел посоветовать максимально атомизировать тесты. Не стоит в одном тесте проверять 50 различных кейсов. Чем тест проще, тем с ним проще будет в дальнейшем.
Ещё полезно собирать артефакты. Когда наш тест падает, он сохраняет последний ответ сервера в HTML-файлик и закидывает в хранилище артефактов, где этот файлик можно открыть из браузера, указав название теста.
Например, тест у нас упал на том, что не может найти на странице кусочек HTML:
<span class=”link”>Link<span>
Мы заходим на наш коллектор и открываем соответствующую страницу:
С этой страницей можно работать так же, как с любой другой HTML-страничкой в браузере. Можно при помощи CSS-локатора попытаться разыскать пропавший элемент и, если его действительно нет, решить, что либо он изменился, либо потерялся. Возможно, мы нашли баг! Если элемент на месте, возможно, мы где-то ошиблись в тесте – надо внимательно посмотреть в эту сторону.
Ещё упростить жизнь помогает логирование. Мы стараемся логировать все запросы, которые делал упавший тест, так, чтобы их легко можно было повторить. Во-первых, это позволяет быстро руками совершить набор аналогичных действий для воспроизведения ошибки, во-вторых – выявить часто падающие тесты, если такие у нас имеются.
Помимо помощи в разборе ошибок, логи, описанные выше, помогают нам формировать список авторизованных и неавторизованных страниц, которые мы протестировали. Глядя на него, легко искать и устранять пробелы.
Последнее, но не по важности, что могу посоветовать – тесты должны быть настолько удобными, насколько это возможно. Чем проще их запустить, тем чаще их будут использовать. Чем понятнее и лаконичнее отчет о падении, тем внимательнее его изучат. Чем проще архитектура, тем больше тестов будет написано и тем меньше времени будет занимать написание нового.
Если вам кажется, что тестами пользоваться неудобно – скорее всего вам не кажется. С этим необходимо бороться как можно скорее. В противном случае вы рискуете в какой-то момент начать обращать меньше внимания на эти тесты, а это уже может привести к пропуску ошибки на продакшн.
На словах мысль кажется очевидной, согласен. Но на деле всем нам есть куда стремиться. Так что упрощайте и оптимизируйте свои творения и живите без багов. :)
Итоги
На данный момент у нас *открываю Тимсити* ого, уже 605 тестов. Все тесты, если их запускать не параллельно, проходят чуть меньше, чем за четыре минуты.
За это время мы убеждаемся, что:
- наш проект открывается на всех языках (которых у нас более 40 на продакшене);
- для основных стран отображаются корректные формы оплаты с соответствующим набором способов оплаты;
- корректно работают основные запросы к API;
- корректно работает лендинг для редиректов (в том числе и на мобильный сайт при соответствующем юзер-агенте);
- все внутренние проекты отображаются правильно.
Тестам на Selenium WebDriver для всего этого потребовалось бы в разы больше времени и ресурсов.
Конечно, это не замена Selenium. Нам всё равно придётся проверять корректное поведение клиента и кросс-браузерные кейсы. Мы можем заменить лишь те тесты, которые проверяют поведение сервера. Но, помимо этого, мы можем осуществлять предварительное тестирование, быстрое и простое. Если на этапе smoke-тестирования нашлись ошибки и «дым идёт не оттуда», возможно, запускать долгий набор тяжеловесных Selenium-тестов до фиксов смысла нет? Это уже на ваше усмотрение! :)
Спасибо за внимание.
Виталий Котов, QA-инженер по автоматизации.
Комментарии (16)
jced
06.12.2016 11:23Мне почему-то показалось, что это статья преимущественно мануал по cUrl для новичков.
И, возможно, есть смысл вместо
использоватьisset($params[‘headers’]) && $params[‘headers’]
?!empty($params[‘headers’])
nizkopal
06.12.2016 11:54Я скорее старался рассказать о своем опыте создания определенного вида тестов, в основе которых, как Вы верно заметили, лежит cURL. Я написал о том, зачем эти тесты нужны и как их стоит использовать, с какими сложностями можно столкнуться и как я их решил для себя. Это чуть более готовый рецепт, чем мануал. :)
По поводу empty. В самом начале, когда метод только создавался, для какого-то параметра было удобнее использовать isset, но я не помню для какого, к сожалению. Возможно follow_location был в общем списке параметров и там проверка выглядела как только isset, без второго условия через &&. В итоге остальные тоже проверялись через isset. В целом Ваш код будет работать верно для моего примера и выглядит локанично, спасибо!
Yeah
06.12.2016 14:08Почему cURL, а не Symfony Crawler? Не пробовали ли Codeception? Если нет, то почему? Если да, то как оно?
nizkopal
06.12.2016 14:40Добрый день.
В зачаточном состоянии наши тесты существуют уже очень давно, тогда Codeception еще не было или он не был популярен. Использовали уже имеющийся для unit-тестов phpunit. На нем же мы сейчас запускаем selenium-тесты, так что выносить smoke-тесты из списка использующих phpunit нет смысла.
xotey83
06.12.2016 14:40Спрашиваю просто из интереса: а вы рассматривали такой инструмент: Codeception (http://codeception.com/)?
ruFog
06.12.2016 15:13Спасибо за статью. Было бы интересно узнать почему используете TeamCity, а не другой CI.
nizkopal
06.12.2016 16:15+1Изначально мы выбирали CI для более сложных целей, чем просто запуск тестов: Teamcity используется у нас для сборки демонов и мобильных приложений, и в этих сборках были важны разные функции, вроде шаблонов для конфигураций. Начинали мы с Jenkins, но из чисто субъективных соображений (скорость, которая казалась хуже, чем в Teamcity, внешний вид и сложность/количество настроек) мы перешли на Teamcity. Мы подумываем о том, чтобы посмотреть на альтернативы, возможно, даже выпустим статью с обзором, если наберется достаточно информации.
Было бы интересно услышать о Вашем опыте. :)
slayerhabr
08.12.2016 01:11-4Из статьи сделал один вывод — какое счастье иметь rspec и capybar'у
nizkopal
08.12.2016 17:49+3Неконструктивный комментарий.
Если он означает: "почему вы не использовали rspec и capybar?", то я уже написал об этом. У нас изначально проект на php и unit-тесты используют phpunit. Не имело смысла писать тесты на другом языке и использовать другую пускалку.
К тому же мы покрываем функциональными тестами только ту фичу, которая уже на продакшене. У нас очень гибкая система АБ-тестирования, мой коллега о ней рассказывает тут. Мы редка покрываем фичи, которые не прошли АБ-тестирование. Подход BDD нам не очень подойдет.
Если он означает: "а я программирую на ruby", то я рад. :)slayerhabr
15.12.2016 01:25Он означает лишь то, что означает. )
Конкретнее — личное оценочное мнение — что Ваши тесты из середины 2000х годов.
Жутко не удобные и слишком «шумные»
Я даже вспомнил libcurl — лет 12 назад писал на C++ с его использованием.
mkevac
Шутки отличные! Спасибо!
NIKOSV
Это не шутки, это реальная жизнь, к сожалению. Некоторые вот вообще тесты не пишут, потому что «их нужно поддерживать».