Приложениями Badoo и Bumble пользуются миллионы людей по всему миру, и мы стремимся доставлять им новую функциональность как можно быстрее. Но важно, чтобы высокий темп нашей работы не сказывался негативно на качестве работы приложений. В этой статье мы расскажем о роли автоматизации в наших процессах и поделимся практиками, которые позволяют быстро писать стабильные тесты. 

Меня зовут Дмитрий Макаренко, я Mobile QA Engineer в Badoo и Bumble: занимаюсь тестированием новой функциональности вручную и покрытием её автотестами. В подготовке этой статьи мне помогал коллега Виктор Короневич: вместе мы делали доклад на конференции Heisenbug

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

Начнём с короткого рассказа о процессах тестирования и фреймворке автоматизации в нашей компании.

Место автоматизации в наших процессах

Раньше за тестирование новой функциональности у нас отвечали команды мобильного тестирования: отдельно iOS- и Android-, и проводили тесты они вручную. А создание и поддержка end-to-end-тестов (именно о них и пойдёт речь в этой статье) находились в зоне ответственности выделенной команды автоматизации. При этом ручным тестированием занимались около 30 человек, а автоматизацией — десять. Последним, конечно, было сложно всё успевать: улучшать фреймворк, инфраструктуру и автоматизировать то, что тестируют руками больше 20 человек. 

За последние два года процессы сильно изменились. Сегодня релиз новой функциональности невозможен без покрытия её тестами разных уровней, в том числе end-to-end-тестами (про это можно подробнее узнать из доклада Катерины Спринсян). Их теперь у нас создают и поддерживают команды мобильного тестирования. А команда автоматизации уделяет больше времени работе с фреймворком и инфраструктурой. 

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

Прежде чем перейти к их описанию, скажу пару слов о нашем фреймворке, чтобы контекст примеров был понятен. 

Фреймворк

Наши приложения Badoo и Bumble — нативные. То есть Android-приложения разрабатываются одной командой на Kotlin и Java, а iOS- — другой командой на Swift и Objective C. 

При этом функциональность Android- и iOS-приложений во многом схожа. Именно поэтому одним из главных критериев выбора фреймворка автоматизации для нас была возможность переиспользования сценариев. Руководствуясь этим, мы выбрали кросс-платформенный фреймворк для автоматизации Calabash. Мы пишем тесты на Ruby, а для написания сценариев пользуемся фреймворком Cucumber. 

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

Практика №1. Где и как писать проверки

Чтобы понять, где можно писать проверки, рассмотрим структуру тестов. Она состоит из трёх уровней.

Первый уровень – сценарий. Здесь сам тест написан на человекочитаемом языке Gherkin, описаны все необходимые действия и проверки.

Второй уровень — определение шагов. В соответствии с действиями или проверками с первого уровня ставится Ruby-код, который их выполняет. 

Третий уровень — уровень страниц. Здесь описываются экраны нашего приложения и действия, которые мы можем на них совершить — например, получить текст выбранного элемента, нажать на выбранную кнопку и т. д.

Переходим к практике. У нас есть задача: создать проверку какого-либо элемента. Это слишком общая формулировка, поэтому рассмотрим конкретный пример. Это будет проверка сообщения о пропущенном видеозвонке. 

Диалог с тестовым пользователем

Начнём со сценария для проверки этого сообщения. 

В тексте сценария вы можете заметить аббревиатуру QAAPI. Это API, с помощью которого мы можем менять состояние системы во время тестирования. Например, можно отправлять сообщения или загружать фото профиля тестовым пользователям, используя обычные клиент-серверные запросы. Более подробно про QAAPI мы рассказывали в этой статье и докладе.

Сфокусируемся на шаге для проверки сообщения (последний шаг в примере кода). 

Scenario: Missed Video Call message is displayed in Chat
  Given users with following parameters
    | role            | name |
    | primary_user    | Dima |
    | video_call_user | Lera |
  And   primary_user logs in
  And   primary_user receives a missed Video Call message from video_call_user via QaApi
  When  primary_user opens Chat with video_call_user
  # Шаг для проверки сообщения о пропущенном видеозвонке
  Then  primary_user should have missed Video Call message from "Lera"

Определим, что нам необходимо проверить. Это текст сообщения и кнопка «Перезвонить». Разберём первый возможный подход.

Then(/^(?:[^"]*) should have missed Video Call message from "(.+)"$/) do |name|
  Pages::ChatPage.new.await.verify_missed_video_call(name)
end

В этом примере на уровне определения шагов мы создаём объект класса ChatPage и вызываем метод await для ожидания загрузки нужного экрана. Потом вызываем метод verify_missed_video_call, чтобы проверить отображение необходимых элементов. 

На уровне страницы реализация этого метода выглядит следующим образом. Сначала мы формируем ожидаемый результат лексемы. Его мы берём из статического метода, объявленного в модуле CallLexemes, используя class << self технику: 

expected = CallLexemes.missed_video_call_message(name)

Далее берём фактический результат с экрана:

actual = ui.element_text(VIDEO_CALL_MESSAGE)

ui — объект, который предоставляет нам доступ к методам фреймворка

И сравниваем ожидаемое значение с фактическим:

Assertions.assert_equal(expected, actual, "Missed Video Call message is incorrect")

В итоге у нас получается следующий метод:

def verify_missed_video_call(name)
  expected = CallLexemes.missed_video_call_message(name)
  actual = ui.element_text(VIDEO_CALL_MESSAGE)
  Assertions.assert_equal(expected, actual, "Missed Video Call message is incorrect")
end

Вероятно, вы заметили ошибку. Разработчик метода для одной из платформ пропустил важную проверку кнопки «Перезвонить». Разработчик тестов для другой платформы в свою очередь эту проверку добавил и получил следующий метод:

def verify_missed_video_call(name)
  expected = CallLexemes.missed_video_call_message(name)
  actual = ui.element_text(VIDEO_CALL_MESSAGE)
  Assertions.assert_equal(expected, actual, "Missed Video Call message is incorrect")
  
  expected = CallLexemes::CALL_BACK_BUTTON
  actual =  ui.element_text(CALL_BACK_BUTTON)
  Assertions.assert_equal(expected, actual, "Call back button text is incorrect")
end

Недостатки первого подхода:

  1. Высокий риск ошибки. Конечно, сложно представить, что какие-то проверки могут быть пропущены в случае с двумя элементами, подобном нашему примеру. Но, когда речь идёт о проверке пяти или даже десяти и более элементов, риск того, что разработчик теста пропустит или неправильно реализует какую-либо проверку, увеличивается.   

  2. Дубликация кода. Как вы могли заметить, реализация проверок дублируется на разных платформах. Это замедляет разработку тестов.  

Как же с этим бороться? Вместо того чтобы реализовывать проверки на уровне страниц, мы переносим их на уровень определения шагов: 

Then(/^(?:[^"]*) should have missed Video Call message from "(.+)"$/) do |name|
  page = Pages::ChatPage.new.await
    
  expected = CallLexemes.missed_video_call_message(name)
  actual = page.video_call_message_text
  Assertions.assert_equal(expected, actual, "Missed Video Call message is incorrect")
  
  expected = CallLexemes::CALL_BACK_BUTTON
  actual =  page.call_back_button_text
  Assertions.assert_equal(expected, actual, "Call back button text is incorrect")
end

И выделяем два новых метода на страницах. Один будет возвращать текст сообщения:

def video_call_message_text
  ui.element_text(VIDEO_CALL_MESSAGE)
end

 А другой — текст кнопки:

def call_back_button_text
  ui.element_text(CALL_BACK_BUTTON)
end

Согласитесь, в подобных методах на странице уже очень сложно сделать ошибку, даже если элементов много. Кроме того, проверки, реализованные на уровне определения шагов, переиспользуются платформами — и мы избегаем дубликации кода тестов.

Подведём итоги этой практики. Мы рекомендуем:

  • создавать проверки на уровне определения шагов;

  • делать страницы простыми. То есть такими, чтобы классы страниц возвращали информацию о состоянии экрана приложения и не содержали реализаций проверок.

Обобщённые рекомендации:

  • создавайте проверки на самом высоком уровне — там, где вы пишете тело теста;

  • делайте объект тестирования простым (не смешивайте логику тестирования с самим объектом). 

Практика №2. Автоматизация тестирования нескольких приложений

Поговорим о том, как подход из предыдущего примера помогает нам не только в работе с двумя платформами, но и в работе с двумя приложениями. 

Рекомендации из этого примера актуальны и при работе с одним приложением.

Задача, решение которой мы разберём в этом разделе: создание общей страницы чата для двух приложений, на примере Badoo и Bumble. Возникла она, когда разработчики создали общий компонент чата и использовали его в обоих приложениях. В тестах нам нужно было сделать так же. 

Начали мы с того, что разделили экран чата на логические модули, а именно:

  • верхнюю панель (toolbar);

  • зону диалога (conversation);

  • поле ввода (input source).

Диалог с тестовым пользователем

Зону диалога мы разделили на модули поменьше, например для разных типов сообщений: текстовых, аудио, GIF-картинок и фото.

Диалог с тестовым пользователем

Модуль поля ввода также состоит из более мелких модулей, таких как поля для отправки фотографий, текста, GIF-картинок и аудиосообщений. 

Диалог с тестовым пользователем

После этого мы создали структуру модулей, подобную той, которую вы видите на скриншоте ниже, для Android и для iOS. 

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

Теперь мы просто подключаем к странице каждого приложения необходимые модули. Причём речь идёт только об актуальных для приложения модулей. В Badoo, например, это может быть сообщение о подарке, которого нет в Bumble: 

Зато в Bumble есть другой тип сообщений, которого нет в Badoo, — реакции:

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

Для обоих приложений мы также используем одни и те же шаги. Здесь мы возвращаемся к методам получения текста сообщения о пропущенном видеозвонке и кнопки «Перезвонить». Они находятся в модуле для типа сообщения о видеозвонке. 

module Chat
  module Android
    module Conversation
      module Message
        module VideoCall
          VIDEO_CALL_MESSAGE = Locator.builder.id('notification_title').build
          CALL_BACK_BUTTON   = Locator.builder.id('notification_accept').build

          def video_call_message_text
            ui.element_text(VIDEO_CALL_MESSAGE)
          end

          def call_back_button_text
            ui.element_text(CALL_BACK_BUTTON)
          end
        end
      end
    end
  end
end

Этот модуль подключается как для Bumble, так и для Badoo. То есть он написан один раз для каждой из платформ, а проверить сообщение в четырёх наших приложениях (Badoo для iOS и Android и Bumble для iOS и Android) можно с использованием всего одного шага. Так что написать новые тесты для проверки чата в разных приложениях не составляет большого труда.

Мы создаём подобные компоненты и в тех случаях, когда нам не требуется их переиспользовать в разных приложениях, например для экранов с большим количеством UI-элементов. Такой подход в одном приложении позволяет избежать появления в коде так называемых God objects (больше информации здесь и здесь) классов с большим количеством свойств и методов. Поддерживать разбитые на модули страницы гораздо проще, так же как расширять их с появлением новой функциональности. 

Подводя итоги этого примера, отметим, что использование компонентов помогает нам:

  • создавать тесты для разных приложений, используя уже существующие шаги, и экономить на этом много времени;

  • поддерживать и расширять классы страниц для экранов с большим количеством UI-элементов

Обобщённая рекомендация: для объектов с большим количеством свойств и методов создавать компоненты, состоящие из логически разделённых модулей. 

Практика №3. Шаги для базовых действий

Начнём разбирать новый пример с того, что обозначим, какие действия мы считаем базовыми. Это, например, ожидание открытия страницы, верификация страницы, закрытие страницы или переход назад с какой-либо страницы. Мы называем их базовыми, потому что они актуальны практически для любой страницы наших приложений. 

Задача, решение которой мы будем рассматривать: реализовать базовые действия для разных страниц

Представим ситуацию: два тестировщика создали шаги для ожидания открытия страниц чата и профиля: 

Then(/^primary_user waits for Chat Page$/) do
  Pages::ChatPage.new.await
end

Then(/^primary_user is on Own Profile page$/) do
  Pages::OwnProfile.new.await
end

Мы видим здесь следующие проблемы:

  • шаги называются немного по-разному, поэтому их сложно будет найти, когда нам понадобится использовать их в новых сценариях; 

  • реализация шагов отличается только классами объектов: ChatPage и OwnProfile. Учитывая, что экранов в наших приложениях намного больше, чем два, создание разных шагов для ожидания открытия каждого из них приведёт к дубликации большого количества кода.

Рассмотрим ещё пример с реализацией шагов для перехода назад с какой-либо страницы:

When(/^primary_user taps back on Connections Page$/) do
  Pages::ConnectionsPage.new.await.tap_back
end

...

When(/^primary_user returns from Encounters page$/) do
  Pages::EncountersPage.new.await.go_back
end

...

When(/^primary_user goes back from Chat Page$/) do
  Pages::ChatPage.new.await.press_back
end

Тут можно заметить ещё одну проблему: на страницах появились методы (tap_back, go_back, press_back), которые отвечают за одно и то же действие, но называются по-разному. Это произошло потому, что разные люди добавили их в разные места репозитория.

Таким образом, общая проблема этих примеров — дубликация кода, которая существенно замедляет разработку тестов. Мы видим дублирование как в самих шагах, так и в реализации действий на страницах. Это затрудняет переиспользование шагов для базовых действий в разных тестах, так как запомнить все возможные вариации таких шагов практически невозможно. 

Как решить эту проблему? Перейдём от простого подхода к более сложному, общему. Мы можем создать объект класса страницы, используя строку из параметра шага. Для этого мы сделали метод page_object_by. 

Then(/^primary_user waits for (.*) [P|p]age$/) do |page_name|
  page_object_by(page_name).await
end

Реализация метода page_object_by выглядит следующим образом:

def page_object_by(page_name)
  page_class = "#{page_name} page".split(' ').collect(&:capitalize).join
  
  return Pages.const_get(page_class).new if Pages.const_defined?(page_class)
  
  raise(NameError, "Unknown class #{page_class}. Actual page name: '#{page_name}'")
end

Мы формируем имя класса из названия страницы, переданного в шаги. Потом, если у нас такой класс описан, то возвращаем объект этого класса. В противном случае — выбрасываем ошибку. 

Таким образом, для ожидания открытия любой страницы нам нужно написать всего один шаг. Аналогичный подход мы используем и для всех остальных базовых действий. И это сильно упрощает разработку. 

От дубликации методов для базовых действий на страницах мы также избавились, выделив общие методы, например press_back.

When(/^primary_user goes back from (.*) [P|p]age$/) do |page_name|
  page_object_by(page_name).await.press_back
end

Реализация этого метода находится на страницах AndroidBumbleBase, IOSBumbleBase, AndroidBadooBase и IOSBadooBase, от которых наследуются классы остальных страниц в наших тестах. Стоит отметить, что это не совсем правильно. При таком подходе страницы, на которых нет кнопки «Назад», могут использовать метод нажатия на эту кнопку. Правильнее было бы выделить нажатие на кнопку «Назад» в отдельный модуль, например Navigation::Back, и добавить его на все необходимые страницы. Но мы не использовали подобный модуль, потому что нам пришлось бы добавлять его практически на все страницы в нашем проекте, — мы решили немного упростить себе жизнь.

Итак, в контексте этого примера мы рекомендуем:

  • создавать общие шаги для базовых действий;  

  • создавать общие методы для реализации базовых действий на страницах (переход назад и т. д.).

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

Если у вас действительно много методов на странице или если вам нужно переиспользовать код лишь на некоторых страницах, имеет смысл выделить модули и добавить их на необходимые страницы. 

Если же у вас есть действие, актуальное для всех страниц, следует выделить его в отдельный метод и определить в классе, являющемся основой для всех страниц в ваших тестах.

Обобщённая рекомендация: если у вас много однотипного кода, выделяйте общие методы. 

Эта рекомендация может показаться очевидной, но мы хотим отметить важность её применения. Напомню, что у нас около 40 человек активно занимаются разработкой end-to-end-тестов. Нам было важно с самого начала выбрать правильный подход, чтобы избежать рефакторинга огромного количества однотипных шагов и методов в будущем. 

Что мы узнали

Мы рассмотрели три примера решения различных задач, возникающих при создании автотестов. На их основании мы сформулировали следующие обобщенные рекомендации:

  • выделяйте общие методы для переиспользования однотипного кода — как в шагах, так и в методах на страницах;

  • создавайте проверки на самом высоком уровне — там, где вы пишете тело теста;

  • делайте объект тестирования простым;

  • создавайте компоненты, состоящие из логически разделённых модулей, для объектов с большим количеством свойств и методов. 

Возможно, эти советы кому-то покажутся очевидными. Но мы хотим обратить ваше внимание на то, что применять их можно (и нужно) в разных ситуациях. 

Во второй части статьи мы разберём ещё четыре примера решения других задач, дополним список наших рекомендаций и поделимся доступом к тестовому проекту со всеми практиками. Оставайтесь с нами!