Всем привет! Я Тимур — iOS разработчик в платформенной команде hh.ru. Сегодня я расскажу о нестабильных UI-тестах в iOS, и как мы с ними справляемся.

Мы уделяем массу внимания UI-тестам, ведь именно они обеспечивают качество и стабильность в наших iOS-приложениях. Сейчас у нас включено около 600 UI-тестов: они гоняются утром, вечером и на каждом PR в develop. О том, как мы обеспечиваем качество мобильной разработки есть отдельная статья.

Рано или поздно большое количество UI-тестов скорее всего начнут тормозить разработку, потому что их стабильность зависит от множества факторов: стенды (API), инфраструктура (обновление Xcode, машин, СI), кодовая база. Даже из‑за проблем в самом XCUITest тесты могут начать выдавать аномалии.

Бесконечный ретрай

Чтобы отдать готовую задачу в тестирование QA, необходимо пройти следующий процесс:

Процесс от PR до ручного тестирования
Процесс от PR до ручного тестирования

После завершения задачи, нужно открыть Pull Request в develop. На этом этапе выполняется сборка AdHoc приложений и отправка их в TestFlight, а также запуск регресса (Unit + UI-тесты). QA может взять задачу в тестирование, не дожидаясь окончательного прогона UI-теста, но попасть в develop, разумеется, можно только с зеленым регрессом.

Здесь можно очутиться в неудобной ситуации — падает UI-тест на функциональность, который никак не был затронут в задаче. Что делает разработчик в такой ситуации? Запускает UI-тесты повторно в надежде на флакование, а если и это не помогает, то пишет в чат и ищет коллег с аналогичной проблемой.

Сообщения из нашего чата за разный период времени
Сообщения из нашего чата за разный период времени

Чаще всего мы решаем отключить UI-тест, потому что его функциональность можно проверить вручную. Это позволит не блокировать продуктовые задачи и пропускать их в develop.

«Если робот‑пылесос ломается, я отдаю его в ремонт и использую ручной, а не живу в грязи, пытаясь его перезагрузить».

Алмаз из hh.ru

На сломанный UI-тест заводится задача и попадает в бэклог QA на исследование, исправление и включение теста обратно.

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

Карантин UI тестов

Итак, нам нужен карантин UI-тестов, в который помещались бы сломанные тесты, падающие в develop n-раз. По сути, карантинный UI-тест — это то же самое, что и просто отключенный:

  • Сломанный или сильно флакующий

  • Не используется нигде в прогонах. Если тест требует отключения, то нужно вырубить его во всех тест-планах, где он используется

Хранение карантинных UI тестов

У каждого приложения есть набор тест‑планов, основной состав которого на примере соискательского приложения выглядит так:

  • ApplicantHH‑Tests: все UI- и Unit-тесты приложения, включая тесты фичей с выключенной параллелизацией. Используются для локального запуска тестов и для сборки тестовых «продуктов» на стороне CI

  • ApplicantHH‑UI‑Tests: UI‑тесты основного издания приложения. Используется для параллельного запуска UI‑тестов на стороне CI в регрессах и проверках PR в основную ветку

  • ApplicantHH‑UI‑Tests‑Smoke: ограниченный набор UI‑тестов основного издания приложения. Используется для запуска UI‑тестов на стороне CI в проверках релизов

  • ApplicantHH‑Unit‑Tests: Unit‑тесты приложения, включая тесты фичей. Используется для запуска Unit‑тестов на стороне CI в регрессах и проверках PR

Если требуется отключить какой‑то тест, то отключить его нужно в двух местах — ApplicantHH‑UI‑Tests и ApplicantHH‑UI‑Tests‑Smoke. Еще у приложения могут быть отдельные издания, и его тесты тоже попадают в основной тест-план, но по умолчанию они выключены. Таким образом, у нас нет единого источника с отключенными тестами. Поэтому мы решили сделать под карантин отдельные тест-планы, где карантинные тесты будут наоборот включены.

Карантинный тест-план ApplicantHH-UI-Tests-Quarantine, где включены тесты, которые попали в карантин
Карантинный тест‑план ApplicantHH‑UI‑Tests‑Quarantine, где включены тесты, которые попали в карантин

На каждом прогоне, где используются UI-тесты, мы будем исключать из прогона все тесты, которые включены в карантинном тест‑плане.

Исключение карантинных тестов

Исключить тесты достаточно просто — берем список тестов из запускаемого тест‑плана и исключаем оттуда все те, которые включены в карантинном тест‑плане. Получить список тестов из тест‑плана можно через xcodebuild, передав ему аргумент -enumerate-tests, который появился с Xcode 15,  и указав выходной формат и путь. С этим параметром вместо запуска тестов, xcodebuild выдаст файл в указанном формате. Пример, как это может выглядеть в Fastlane с командой scan:

Пример кода в Fastlane с использование scan
scan(
  workspace: 'HH.xcworkspace',
  configuration: 'Debug',
  scheme: 'ApplicantHH',
  testplan: 'ApplicantHH-UI-Tests',
  device: 'iPhone 11',
  skip_detect_devices: true,
  ensure_devices_found: true,
  code_coverage: false,
  skip_package_dependencies_resolution: true,
  disable_package_automatic_updates: true,
  derived_data_path: 'Output/DerivedData',
  output_types: '',
  output_directory: 'Output/Build',
  xcargs: '-enumerate-tests ' \
    '-test-enumeration-style flat ' \
    '-test-enumeration-format json ' \
    '-test-enumeration-output-path Output/DerivedData/Build/Products/Enumeration.json'
)

Пример выходного файла в формате json
{
  "errors" : [

  ],
  "values" : [
    {
      "disabledTests" : [
        {
          "identifier" : "ApplicantHHUITests/AuthorizedBlacklistVacanciesFromOtherScreensTestSuit/test_CompanyVacanciesScreen_hideVacancies_VacanciesInBlacklist"
        },
        {
          "identifier" : "ApplicantHHUITests/SecondarySearchFiltersTestSuit/testScheduleFilter"
        }
      ],
      "enabledTests" : [
        {
          "identifier" : "ApplicantHHUITests/AboutMeTestSuit/test_ProfileAboutMe_AddNewTextAndSave_AssertSubtitleEqualsChanging"
        },
        {
          "identifier" : "ApplicantHHUITests/ZeroScreenTestSuit/testResumeListZeroscreenButtonLogIn"
        }
      ],
      "testPlan" : "ApplicantHH-UI-Tests"
    }
  ]
}

Теперь мы получаем следующий профит:

  • Единый список карантинных тестов, которые нужно пофиксить и вернуть в работу

  • Тест достаточно включить в одном месте: это удобно для автоматизации и ручного добавления в карантин

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

Попадание в карантин

Общая схема работы

Рассмотрим принцип работы карантина и по каким критериям UI-тесты могут в него попасть:

Общая схема работы карантина
Общая схема работы карантина

Регрессы

До карантина у нас был только один ночной регресс, который запускался в 23:00 и собирал AdHoc сборки, прогонял Unit- и UI-тесты. В случае каких‑либо падений, дежурный с утра разбирался, что пошло не так и какие были причины.

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

  • Наши UI-тесты выполняются со стендами, которые сбрасываются теперь только перед утреннем регрессом. То есть ночной использует стенд прошлого дня, а утренний — свежий. Это позволяет в некоторых случаях отследить падения UI-тестов из‑за изменений на стенде, которые подтягиваются вместе со сбросом в начале дня

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

  • AdHoc-сборки собираются только на утреннем регрессе. Ночные AdHoc-сборки нет смысла собирать, их никто смотреть не будет, лучше получить свежие утром

Анализ упавших тестов

Если на утреннем регрессе упал хотя бы один тест, то мы повторно прогоним каждый по 12 раз. Это позволяет исключить ошибочные флакования. Если тест 12 раз прошел без единого падения, то он не попадает в карантин. В ином случае, тест является потенциальным кандидатом (дальше будет понятно, почему именно потенциальным) на добавление в карантин, так как он упал 6 раз на регрессе + минимум один раз из 12 повторных прогонов.

Включение в карантинный тест-план

Включить тест в соответствующем тест‑плане не особо сложно, потому что xctestplan по факту обычный JSON, но правда, со своим специфическим форматированием.

Пример добавления новых тестов в xctestplan на Ruby
test_plan_json = JSON.parse(File.read(test_plan_path)) # Парсим файл в JSON

selected_tests = test_plan_json['testTargets'][0]['selectedTests'] # Текущие включенные тесты
test_plan_json['testTargets'][0]['selectedTests'] = selected_tests
  .concat(quarantine_class_names) # Добавляем к включнным тестам новые карантинные тесты в формате "#{test_case.class_name}/#{test_case.name}"
  .map { |test| test.gsub('/', '\/') } # Экранируем слэш в название тестов, потому что он теряется при парсинге в JSON из файла
  .sort # После добавляния новых тестов, сортируем по алфавиту (как по умолчанию в xctestplan)

# Данная настройка позволяет избежать лишних диффов при генерации итогового JSON
new_test_plan_json = JSON
  .pretty_generate(test_plan_json, { space: ' ', space_before: ' ' })
  .gsub('\\\\', '\\')
  .concat("\n")

Создание задач в Jira

Чтобы можно было отслеживать прогресс и историю включения/выключения тестов, мы создаем две Jira-задачи — первая на добавление в карантин, вторая на исправление. В описании задачи есть следующие данные:

  • Список тестов, которые упали

  • Ссылка на CI сборку

  • Ссылка на отчет c повторными прогонами

Пример того, как выглядят итоговые задачи в Jira:

Упал 1 тест соискательского приложения JTB издания
Упал 1 тест соискательского приложения JTB издания
Упало несколько тестов соискательского приложения HH издания
Упало несколько тестов соискательского приложения HH издания

Создание PR

Добавленные в тест‑план тесты пока находятся локально на CI-джобе. Чтобы изменения вступили в силу для всех, необходимо чтобы новые включенные тесты попали в develop. Для этого создаем ветку с номером Jira-задачи на добавление в карантин и открываем PR в develop. Описание PR такое же, как и в Jira-задаче.

Пример PR в develop
Пример PR в develop

Дифф максимально простой и не требует особого ревью:

Не зря танцевали с JSON в Ruby
Не зря танцевали с JSON в Ruby

Принятие решения

После создания PR отправляет сообщение в канал iOS-уведомлений о том, что были найдены тесты, которые потенциально могут попасть в карантин. Потенциально, потому что итоговое решение — включать ли тест в карантин, принимает дежурный QA, так как UI-тесты и develop это зона ответственности QA.

Пример сообщения для дежурного QA
Пример сообщения для дежурного QA

Уже по данным в PR дежурный QA может оценить ситуацию и принять решение. Если вдруг срабатывание было ошибочным, то PR и задачи просто закрываются. В ином случае задача в Jira берется в работу, и новые карантинные тесты вливаются в develop, чтобы не блокировать другие продуктовые задачи в течение дня. После этого можно либо сразу браться за ремонт сломанных тестов, если причина достаточно очевидная, либо передать отвечающему за тест QA и вернуть тест по освобождению ресурсов.

Дополнительные кейсы

После создания PR мы также записываем данные об упавших тестах в БД:

Тесты и им соответствующая Jira задача на добавление в карантин
Тесты и им соответствующая Jira задача на добавление в карантин

Сегодня мы используем это только для защиты от дублирования: когда на утреннем регрессе упали те же самые тесты, но уже есть созданная для этого задача. В таком случае задача в Jira и PR не будет создаваться повторно, а отправится лишь повторное напоминание сообщением в канал, чтобы дежурный QA обратил внимание на сломанные тесты.

Выход из карантина

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

Процесс возвращения теста обратно на регрессы
Процесс возвращения теста обратно на регрессы

После исправления теста QA прогоняет его повторно n-раз на CI, чтобы убедиться, что тест стабильный и не флакует. У нас под это есть отдельный план на CI, где можно указать название теста и минимальное количество прогонов.

Остается только выключить тест в карантинном тест‑плане и сделать PR в develop. После этого тест вновь будет выполняться на всех регрессах.

Следующие шаги

В процессе проработки идеи с карантином было много крутых идей, которые мы бы хотели сделать в обозримом будущем:

  • Проверка стабильности тестов на каждом PR в develop. Ретраев у каждого теста не должно быть больше чем в develop. Если какой‑то тест стал чаще флаковать, то стоит обратить на это внимание раньше, чем код попадет в develop

  • Автоматизация выхода из карантина. Ручные шаги также можно автоматизировать, например запускать повторный прогон, а в случае успеха автоматом создавать PR

  • Уведомления о долгом пребывании теста в карантине. Иногда тесты могут оставаться отключенными довольно долго. Неплохо было бы напоминать о том, что пора взяться за очередной тест из карантина. Но пока с этим у нас проблем нет, QA всегда находят ресурс по возвращению тестов в строй

  • Не блокировать PR в develop, если на утреннем прогоне упал тот же тест. Вместо блокировки выводить предупреждение в PR и ссылаться на PR с включением теста в карантин. Может быть полезно, если дежурный QA не смог оперативно среагировать утром или передать тест другому QA.

Итоги

С новым процессом карантина тестов сообщения о том, что фича не может попасть в develop, на данный момент не появлялись, так как дежурные QA оперативно реагируют утром, просматривают проблемные тесты и отключают их по необходимости.

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

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