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

Поделиться с друзьями
-->

Комментарии (7)


  1. Miraage
    28.09.2016 15:00

    Правильно ли я понимаю, что метод assertGiftAddSuccess — обертка через __call над встроенным assertSmth? Если да, то не пробовали ли вы убирать магию и запускать методы напрямую?

    Как никак, это же целый performance-hit — magic method + extra function call(s).


    1. bikutoru
      28.09.2016 15:13

      Нет, assertGiftAddSuccess — это обычный метод класса теста, который делает серию проверок другими assert-методами. Вот его код:

          private function assertGiftAddSuccess($response, $fail_message)
          {
              $this->assertInternalType('array', $response, "{$fail_message}: an array expected");
              $this->assertArrayHasKey('gift_id', $response, "{$fail_message}: gift id expected!");
          }
      


      У нас есть целая пачка подобных asssertSomething, которые мы используем для упрощения самих тестовых методов.

      Magic method здесь — это вызов серверной команды ($ClientGiftSender->ServerGetUser), который собирает из аргументов запрос, отправляет его серверу и разбирает полученный ответ. Тут «магию» мы убирать не пробовали, но думаю, что теряем на ней мы сравнительно немного — ведь в тесте мы работаем с реальной базой и реальными сервисами.


  1. YemSalat
    29.09.2016 10:25

    Честно говоря не очень понял о чем именно статья.

    «Смоук-тесты» для апи должны быть, даже если компания не занимается разработкой клиентов для своего апи.
    Кроме того, есть существующие инструменты для автоматизации и конфигурации таких тестов (напр. Cucumber), зачем писать свои велосипеды?


  1. bikutoru
    29.09.2016 10:55

    Существующие средства (конкретно Cucumber) используют QA-инженеры, занимающиеся тестированием самих приложений. Тесты, про которые я рассказал, пишут сами разработчики. Нам удобно писать такие тесты на php, т.к. мы получаем за счет этого несколько плюсов:


    • использование уже имеющихся в коде констант (в приведённом коде есть несколько примеров). Благодаря этому мы можем использовать в тесте семантические значения и нам не нужно делать какой-то маппинг;
    • использование привычного разработчикам инструмента — работать с phpunit умеют чуть ли не все разработчики. За счёт этого снижается порог входа для нового человека;
    • ускорение прогона тестов: при использовании phpunit и локальной площадки разработчика тесты работают в рамках одного процесса без http-запросов. Это важно, ведь таких тестов очень много и они не очень быстрые.


    1. YemSalat
      29.09.2016 14:06
      +1

      Спасибо за ответ. Я тоже работаю в компании, которая занимается разработкой апи и клиентов для него, было бы интересно поделиться опытом, если вы не против.

      Например, мы пишем спеки для апи, которые лежат в одном репозитории с основным кодом (в стиле «BDD»)
      Код выглядит примерно так:
      (мы пишем не на ПХП, а на ноде)

      describe('User', () => {
      
          let user
      
          // создаем нового пользователя перед всеми тестами
          before(() => user = helper.createUser({ balance: 1000, channel: 'Android' }))
      
          // удаляем пользователя после тестов
          after(() => helper.removeUser(user))
         
          // тестируем покупку двух конфет
          it('buys two candies', done => {
      
              config.enableCandies = true // задаем необходимые настройки для апи
              
              // то что мы отправим нашему апи
              let payload = {
                  type: 'buy-candy',
                  amount: 2
              }
      
              api().post('/api/v1/buy') // POST запрос на URL покупки
                  .set('authtoken', user.getToken()) // задаем заголовки авторизации
                  .send(payload) // отправляем запрос
                  .end((err, res) => {
      
                      // проверяем что вернулся статус 200
                      expect( res.status ).to.be( 200 )
                  
                      // проверяем что у пользователя теперь есть 2 конфеты
                      expect( user.getCandies() ).to.be( 2 )
                  
                      done()
                  })
          })
      })
      


      Можете пожалуйста объяснить в чем заключается отличие вашего подхода?
      Вроде так же задаются необходимые настройки для сервера и проверяется что запрос отработал правильно.
      Но ничего не отправляется по сети и отсутствуют недостатки, описанные в вашей статье.

      В моем понимании интеграционные тесты для апи должны иметь минимальный контроль над окружением (а лучше никакого)
      Они отправляют запросы так же, как это бы делал клиент. Таким образом мы получаем возможность проверить работает ли приложение в данном окружении (включая новые фичи), при этом какие фичи тестировать в каком окружении можно настраивать.

      После блокировки с этим пользователем можно проводить любые манипуляции, а после окончания работы теста запускается механизм очистки, который приведет пользователя в исходное «чистое» состояние, и тот снова будет доступен для использования в тестах.
      Почему вместо этого не создавать для тестов нового пользователя с нужными настройками перед каждым прогоном?

      И если не трудно, можете пожалуйста в двух словах описать как это автоматизируется?

      PS
      getLoginedConnection()
      getLoggedInConnection :)


      1. bikutoru
        29.09.2016 20:15

        В принципе это похоже на то, что делаем мы. Разница только в том, что создание пользователя в начале теста и удаление в конце мы делаем неявно — оно происходит "само" и по мере необходимости. У нас есть пул тестовых пользователей, каждый из которых может быть взят любым тестом. За счет этого:


        • нам не нужно тратить время на создание пользователя и его чистку в момент прогона тестов => тесты работают быстрее
        • мы экономим место на дисках: законодательства некоторых стран требуют хранить данные даже удаленных пользователей, поэтому штатно удаление пользователя не означает DELETE записей о нём из базы. У нас несколько тысяч тестов и мы часто их запускаем, поэтому база бы очень быстро росла (особенно это критично на площадках разработчиков, которые используют общие базы)

        После использования пользователя тестом его нужно будет очистить. Это произойдет не сразу, а через какое-то время, когда станет понятно, что "чистых" пользователей осталось мало.


        Еще одно отличие, которое я заметил — вы работаете с локальным объектом (например в вызове user.getCandies()). Мы так не делаем потому, что это не даёт возможность запускать тесты на удалённых площадках.


        Периодически у нас возникает проблема, что тест проходит на площадке разработчика, изменения мержатся в ветку билда, выкладываются на стейджинг (на котором используются боевые базы данных и сервисы) и там тест начинает падать, хотя на девел окружении проходит. В такой ситуации разработчик может сделать checkout ветки билда на своей площадке (в девел окружении) и запускать свой тест в стейджинг окружении (через отправку серверных команд поверх http). При этом можно менять сам тест, добавлять какие-то дебаги, дополнительные вызовы QaApi, которые покажут, что происходит на сервере.


        P.S. Не совсем понял: вы говорите, что у вас по сети ничего не отправляется, но это выглядит как отправка http-запроса


                api().post('/api/v1/buy') // POST запрос на URL покупки
                    .set('authtoken', user.getToken()) // задаем заголовки авторизации
                    .send(payload) // отправляем запрос


        1. galk_in
          02.10.2016 22:20

          Это Node.js там можно проворачивать такие трюки.