Badoo — это сервис знакомств, который доступен в виде сайта и мобильных приложений под основные платформы. В начале прошлого года мы глобально переработали сайт, в результате чего он превратился в «толстого клиента» и стал работать так же, как и мобильные приложения: вызывать команды на сервере и получать от него ответы согласно протоколу, описывающему взаимодействие клиентской и серверной частей. Эти две части делаются разными разработчиками, и, как правило, клиентская часть делается уже после того, как серверная будет готова. При этом есть проблема: как разработчик новой фичи может убедиться, что серверная часть работает корректно, если клиента для нее пока нет и проверить ее не на чем?
Для решения этой проблемы в любой серверной задаче у нас обязательно должны быть написаны интеграционные тесты, про которые я расскажу в этой статье.
Что это такое и как это работает?
В нашем случае эти тесты представляют собой надстройку над PHPUnit, благодаря которой тест становится приложением-клиентом, которое обращается к серверу по протоколу. При этом есть возможность настроить, к какому именно серверу мы хотим обратиться. Это может быть:
- площадка разработчика;
- «шот» — специальная площадка с «боевой» базой, на которую выкладывается код создаваемой фичи;
- «стейджинг».
В первом случае и клиент, и сервер работают в рамках одного процесса PHP, а в остальных это будет полноценный клиент-сервер, когда тест отправляет запросы на другие сервера.
Вот пример подобного теста, который проверяет, что пользователь, подаривший подарок другому пользователю, увидит этот подарок в его профиле:
class ServerGetUserGiftsTest extends BmaFunctionalTestCase
{
public function testGiftsSending()
{
// Given
$ClientGiftSender = $this->getLoginedConnection(
\BmaFunctionalConfig::USER_TYPE_NEW,
[
'app_build' => 'Android',
'supported_features' => [
\Mobile\Proto\Enum\FeatureType::ALLOW_GIFTS,
],
]
);
$ClientGiftReceiver = $this->getLoginedConnection();
$gift_type = 1;
$gift_add_result = $ClientGiftSender->QaApiClient->addGiftToUser(
$ClientGiftReceiver->getUserId(),
$ClientGiftSender->getUserId(),
$gift_type
);
$this->assertGiftAddSuccess($gift_add_result, "Precondition failed: cannot add gift from sender to receiver");
// When
$Response = $ClientGiftSender->ServerGetUser(
[
'user_id' => $ClientGiftReceiver->getUserId(),
'client_source' => \Mobile\Proto\Enum\ClientSource::OTHER_PROFILE,
'user_field_filter' => [
'projection' => [\Mobile\Proto\Enum\UserField::RECEIVED_GIFTS],
],
]
);
// Then
$this->assertResponseHasMessageType(\Mobile\Proto\Enum\MessageType::CLIENT_USER, $Response);
$user_received_gifts = $Response->CLIENT_USER['received_gifts'];
$this->assertArrayHasKey('gifts', $user_received_gifts, "No gifts list at received_gifts field");
$this->assertCount(1, $user_received_gifts['gifts'], "Unexpected received gifts count");
$gift_info = reset($user_received_gifts['gifts']);
$this->assertEquals($ClientGiftSender->getUserId(), $gift_info['from_user_id'], "Wrong from_user_id value");
}
}
Давайте разберем этот пример по частям.
Каждый тест наследуется от класса BmaFunctionalTestCase — наследника PHPUnit_Framework_TestCase. В нем реализовано несколько вспомогательных методов, главным из которых является возможность получения объекта клиента, через который можно отправлять запросы к серверу:
$ClientGiftSender = $this->getLoginedConnection(
\BmaFunctionalConfig::USER_TYPE_MALE,
[
'app_build' => 'Android',
'supported_features' => [\Mobile\Proto\Enum\FeatureType::ALLOW_GIFTS],
]
);
Здесь мы можем «представиться» конкретной версией клиента со своим набором поддерживаемых фич. После выполнения этого метода у нас появляется объект, который позволяет отправлять запросы от имени зарегистрированного пользователя, использующего определенное приложение.
Этого зарегистрированного пользователя мы берем из специального пула тестовых пользователей. В нем есть некоторое количество «чистых» пользователей, т.е. все они имеют одно и то же начальное состояние. Когда в тесте вызывается метод getLoginedConnection(), выбирается один из этих пользователей, и он блокируется для использования другими тестами. Блокировка нужна для того, чтобы мы всегда имели дело с пользователями в известном нам состоянии. После блокировки с этим пользователем можно проводить любые манипуляции, а после окончания работы теста запускается механизм очистки, который приведет пользователя в исходное «чистое» состояние, и тот снова будет доступен для использования в тестах. Все тестовые пользователи находятся в одной локации, в которой нет реальных пользователей. Поэтому, с одной стороны, мы в тестах имеем дело с предсказуемым окружением, а с другой — реальные пользователи не видят тестовых.
Как правило, мы не можем запускать проверку сразу после получения объекта клиента: нужно создать окружение, необходимое тесту (в данном примере — отправить подарок другому пользователю). Делать это мы можем «честно», отправляя запросы серверу через объект клиента, но это не всегда возможно. В случае подарка «честный» путь был бы слишком сложным: нам нужно пополнить счет пользователя, получить список доступных подарков, отправить его и дождаться, пока он будет обработан скриптом отправки. Все это усложнит тест и увеличит время его разработки и выполнения.
Чтобы это упростить, мы используем внутренний инструмент под названием QaAPI (про него уже рассказывал мой коллега Дмитрий Марущенко, презентацию и видео можно найти на «Хабре»). Он состоит из множества небольших методов, каждый из которых позволяет совершать отдельные действия над пользователями в обход стандартных механизмов или получить какие-то сведения о пользователе. С его помощью можно добавить пользователю фотографии и сразу отмодерировать их, минуя очереди и проверку модераторами; изменить значения отдельных полей в его профиле, проголосовать за других пользователей в «Знакомствах» и т.д.
В данном примере мы просто дарим подарок без пополнения счета и в обход очередей:
$gift_add_result = $ClientGiftSender->QaApiClient->addGiftToUser(
$ClientGiftReceiver->getUserId(),
$ClientGiftSender->getUserId(),
$gift_type_id
);
$this->assertGiftAddSuccess($gift_add_result, "Precondition failed: cannot add gift from sender to receiver");
Очень важно проверять ответы QaAPI, ведь в случае ошибки пользователь будет совсем не в том состоянии, которое мы ожидаем получить, и дальнейшие проверки будут бессмысленны. Если говорить о нашем примере, то было бы странно проверять наличие подарка в профиле, если мы не смогли его подарить.
Если мы по каким-то причинам не хотим «честно» приводить пользователя в нужное состояние, то мы можем использовать удаленные mock-объекты. В отличие от локальных, они бывают одноразовые (действующие только на одну команду) и постоянные (работающие до конца выполнения теста).
Технически mock-объекты реализованы с помощью другого нашего решения, SoftMocks. Оно используется либо напрямую (на площадке разработчика, когда тест работает в рамках одного процесса), либо через «прокладку» в виде memcache (на удаленной площадке). Во втором случае во время работы теста мы кладем информацию о новом mock-объекте в массив одноразовых или постоянных mock-объектов, а перед отправкой запроса на сервер объединяем эти два массива и кладем их в memcache, откуда их сможет забрать серверная часть.
Мы часто используем такие mock-объекты для проверок лексем, когда нужно убедиться, что в ответе придет нужный нам текст. Это можно сделать «честно», но это будет не очень удобно: тексты могут меняться со временем (и это будет ломать тест), плюс на разных языках они могут быть разные. Чтобы избежать этих проблем, мы заменяем лексемы на какие-то предопределенные значения или даже на пути к текстам.
В целом использование mock-объектов делает тест более быстрым, т.к. позволяет избавиться от одного или нескольких удаленных вызовов, но добавляет зависимости от серверного кода и делает их менее надежными: они чаще ломаются и больше «врут».
После создания нужного окружения мы можем отправить серверу запрос и получить ответ:
$Response = $ClientGiftSender->ServerGetUser(
[
'user_id' => $ClientGiftReceiver->getUserId(),
'user_field_filter' => [
'projection' => [\Mobile\Proto\Enum\UserField::RECEIVED_GIFTS],
],
]
);
В таких тестах код сервера представляет для нас черный ящик: мы не знаем, что там происходит и какой именно код обрабатывает наш запрос. Все, что мы можем сделать — это проверить соответствие ответа сервера нашим ожиданиям.
Наш протокол позволяет серверу возвращать разные типы ответов на одну и ту же команду. Команды могут возвращать ответ разных типов. Например, ошибку может вернуть практически любая команда. По этой причине мы начинаем проверку ответа с того, есть ли там сообщение ожидаемого типа:
$this->assertResponseHasMessageType(\Mobile\Proto\Enum\MessageType::CLIENT_USER, $Response);
После того как мы убедились в наличии нужного сообщения, можно более детально проверить ответ и убедиться, что в нем есть наш подарок:
$user_received_gifts = $Response->CLIENT_USER['received_gifts'];
$this->assertArrayHasKey('gifts', $user_received_gifts, "No gifts list at received_gifts field");
$this->assertCount(1, $user_received_gifts['gifts'], "Unexpected received gifts count");
$gift_info = reset($user_received_gifts['gifts']);
$this->assertEquals($ClientGiftSender->getUserId(), $gift_info['from_user_id'], "Wrong from_user_id value");
Для команд, которые модифицируют состояние пользователя, недостаточно проверить ответ сервера. Например, если мы отправляем команду на удаление подарка, то мало получить Success в ответе — нужно еще проверить, что подарок действительно удален. Для этого можно либо вызвать другие команды и проверить их ответы, либо воспользоваться тем же QaAPI, вызвав метод, возвращающий состояние параметра, который мы хотим проверить. В примере с удалением подарка мы могли бы вызвать QaAPI-метод, возвращающий список подарков и проверить, что в нем нет только что удаленного.
Какие достоинства?
Главное достоинство таких тестов — понимание того, что новый функционал работает так, как мы ожидаем. Если мы описали сценарий в виде такого теста и он прошел, то мы понимаем, что весь функционал работает и может быть использован реальным приложением-клиентом.
Другой важный плюс: мы можем провести регрессионное тестирование и убедиться, что внесенные изменения не сломают старых клиентов, для которых новый функционал будет недоступен. Данные тесты позволяют нам это сделать через указание разных версий приложения (это старый путь, который мы использовали для версионирования раньше) и определенного набора фич, поддерживаемых клиентом (это новый путь, который мы используем сейчас).
Какие недостатки?
Главными недостатками этих тестов является долгое время работы и нестабильность, вытекающие из их высокого уровня. Хотя тесты обычно проверяют результаты одной команды протокола, для них создается полновесное окружение, работающее с теми же базами и сервисами, что и у обычных клиентов. Все это, а так же «честное» воссоздание окружения, требующее других запросов (часто не одного-двух) к серверу, требует времени.
Некоторые фичи требуют сложной инициализации, которая увеличивает размер тестовых методов. Ведь перед вызовом тестируемого метода нужно не только отправить запросы для инициализации, но и проверить, что они отработали так, как вы ожидали. К примеру, если вы хотите проверить работу чата, то вам нужно получить двух клиентов, дать им возможность «чатиться» друг с другом, отправить сообщение и проверить, что оно действительно отправилось. Бывает, что некоторые вещи происходят с задержкой и вам нужно дождаться доставки данных.
Из-за этой сложности тесты становятся очень «хрупкими»: поломка в воссоздании окружения сломает вам тест, и хотя проблема не относится к тому, что вы проверяете, ваш тест падает. Такие тесты не укажут вам, что именно сломалось, вы только поймете, что что-то не работает. Конкретный метод, изменение которого поломало тест, придется искать самостоятельно, а иногда сделать это бывает очень непросто.
Заключение
Несмотря на перечисленные минусы, эти тесты решают свои задачи и позволяют разработчикам писать тесты в том же виде, что и привычные всем unit-тесты.
Виктор Пряжников, разработчик отдела Features