Привет, Хабр! Меня зовут Надежда Буртелова, я ведущая тестировщица в музыкальном сервисе Звук. В тестировании с 2014 года, с 2022 года работаю в Звуке: тестирую backend и менторю коллег. Последние два года активно пишу автотесты. 

Закончила МФТИ: факультет аэрофизики и космических исследований. 

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

Организация работы в QA-backend сервиса Звук

Команда тестирования на бэкенде состоит из “ручных” и “авто” тестировщиков. “Ручные” тестировщики пишут кейсы и автотесты, команда автоматизации пишет автотесты по ручным кейсам и развивает наш проект автотестов.

Мы пишем автотесты на Kotlin.

На сентябрь 2025 у нас больше 15 тысяч автотестов без учёта параметризации и свыше 39 тысяч — с ней.

Диаграмма покрытия сервисов автотестами
Диаграмма покрытия сервисов автотестами

Как ТМС мы используем Testops. Он позволяет просматривать данные по запускам, хранит данные запросов и ответов из тестов неделю, а статистику прохождения тестов условно вечно.

Мы ежедневно следим за актуальностью наших тестов:

  • каждую ночь на тестовое окружение накатываются мастер-ветки всех сервисов

  • каждое утро запускаются все автотесты

  • в начале рабочего дня бот рассылает по командам уведомления о новых падениях тестов

  • при пуше в гит запускаются все тесты, которые были задеты, отчет о прохождении прикрепляется к МРу

Флакающий тест

Для начала давайте разберемся, что такое флакающий тест.

Если мы посмотрим на абстрактную тестовую модель в любой момент жизни, то там будут стабильно зеленые тесты (которые каждый раз проходят успешно), стабильно красные тесты (которые каждый раз падают) и те, которые никуда стабильно не попадают - флакающие.

Флакающий (нестабильный) тест – это такой тест, который в условно одинаковых обстоятельствах выдает разный результат. Если говорить бытовым языком, то это тот тест, который надо перезапустить 3 раза, чтобы быть уверенным в его результате. 

Тест может быть нестабильным как из-за плавающего бага, так и из-за того, что в тесте что-то не учтено. 

Абстрактная тестовая модель в разрезе успешности выполнения тестов
Абстрактная тестовая модель в разрезе успешности выполнения тестов

Как определить к какой категории относится тест:

  • Запустить тест много раз подряд (например, директива @RepeatedTest(100))

  • Запустить тест на разных окружениях. Тест может быть стабильным на одном окружении и флакать на другом.

  • Посмотреть историю запусков

Классификация тестов в запуске

Теперь взглянем на тесты в разрезе конкретного запуска.

Абстрактный запуск автотестов
Абстрактный запуск автотестов

Если посмотреть на любой прогон автотестов, все тесты можно разделить:

  • Упавшие тесты, которые не выполнились

    • Отрицательные тесты проверили свой функционал и упали по делу, они подсвечивают баги или проблемы с инфраструктурой. 

    • Сломанные тесты не проверили свой функционал и упали из-за некорректной работы.

  • Выполненные тесты

    • Положительные тесты проверили свой функционал и сказали, что всё хорошо.

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

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

Состав автотеста

Дальше я буду локализовать причины нестабильности по этапам теста, поэтому рассмотрим состав типичного автотеста:

  1. Подготовка данных

  2. Запрос в проверяемый сервис

  3. Очистка данных

  4. Проверка кода ответа

  5. Проверка схемы ответа

  6. Явные проверки ответа

Типичный состав автотеста: подготовка и очистка данных внутри теста
Типичный состав автотеста: подготовка и очистка данных внутри теста

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

Второй вариант типичного автотеста: подготовка и очистка данных вынесены за пределы теста.

Типичный состав автотеста: подготовка и очистка данных вынесены за пределы теста
Типичный состав автотеста: подготовка и очистка данных вынесены за пределы теста

Обе конфигурации имеют право на существование. Мы используем обе и даже их комбинацию. Теперь рассмотрим некоторые этапы чуть подробнее.

Способы подготовки данных

В подготовке данных можно выделить четыре подхода:

  • Генерация данных. Обращение в функцию, которая создает данные для теста. Например, создать пользователя. 

  • Получение из БД. Например, найти подходящего пользователя в базе.

  • Хардкод. Например, считать что пользовательский id = 4.

  • Манипуляции кэшом и Kafka. Очистка или добавление данных перед тестом.

В принципе, эти паттерны могут использоваться и на других этапах теста.

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

Проверки ответа

Теперь рассмотрим внимательнее проверки ответов.

Этапы проверки ответа
Этапы проверки ответа

Для начала обратим внимание на порядок проверок:

  1. Код ответа

  2. Схема ответа

  3. Явные проверки ответа

Мы движемся от простой проверки к более сложным. Грубо говоря, если код ответа не ожидаемый, то дальше проверять смысла нет.

Проверка json-схемы ответа

На этапе проверки схемы проверяются следующие вещи

  1. Иерархия полей внутри ответа

  2. Типы данных полей ответа 

  3. Обязательность полей

Явные проверки ответа

  1. Проверка данных полученного json-ответа 

  2. Проверка влияния на другие сервисы (запись или очистка кэша, данных в БД, сообщений в Kafka)

Основные причины нестабильности тестов

Можно выделить три группы причин нестабильности тестов.

Место

Подготовка и очистка данных

Проверка ответа

Бэкенд и инфраструктура

Причина

Захардкоженные данные устарели 

Из БД приходят не подходящие данные

Функция по подготовке данных работает некорректно 

Данные приходящие в тест не соответствуют сценарию 

Слишком жесткая схема 

Излишние проверки 

Устарел сценарий теста

Неактуальный кэш сервиса 

Несколько тестов используют  и модифицируют одни и те же данные

Сломался другой сервис 

Инфраструктурные проблемы 

Плавающий баг 

Гонка состояний 

Сломалась интеграция с партнерами

Далее мы подробнее рассмотрим причины непосредственно связанные с автотестами.

Места нестабильности автотестов

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

Этапы автотеста, на которых возникают нестабильности
Этапы автотеста, на которых возникают нестабильности

Некорректно обозначенные шаги

Для начала обсудим проблему, которая может быть на любом этапе теста: некорректно обозначенные шаги.

Симптом

Мы смотрим на упавший тест через ТМС или через ИДЕ при локальном запуске: тест падает на определенном шаге. Мы погружаемся в код и проблема оказывается гораздо выше.

Пример некорректного и корректного обозначения шагов

Приведу пример кода с некорректно обозначенными шагами

  1. Создается тестовая категория вне шага

  2. Название шага говорит, что мы создаем категорию и добавляем в неё пользователя

  3. На деле внутри шага мы только добавляем пользователя в тестовую категорию

пример теста с некорректно обозначенными шагами
пример теста с некорректно обозначенными шагами

А как бы следовало обозначить шаги:

  1. Шаг называется: “создаем тестовую категорию”

  2. Внутри шага мы создаем тестовую категорию

  3. Шаг называется: “Добавляем в тестовую категорию пользователя”

  4. Внутри шага мы добавляем пользователя в категорию

пример теста с корректно обозначенными шагами
пример теста с корректно обозначенными шагами

Решение:

  • Каждое значимое действие в тесте нужно помещать в шаг.

  • Шаги должны быть атомарными, то есть каждое отдельное действие в отдельном шаге.

  • Понятное название шага, чтобы из ТМС можно было понять, что там происходит.

Обозначенные шаги позволяют:

  • Определить место падения из ТМС. Это сокращает время на диагностику проблем.

  • Пройти автотест руками. Не на каждом окружении можно запустить автотест.

Пример упавшего теста с хорошо обозначенными шагами
Пример упавшего теста с хорошо обозначенными шагами

Проблемы на этапе подготовки и очистки данных

Теперь рассмотрим внимательнее проблемы, которые происходят на этапах подготовки и очистки данных.

Отсутствие ленивой инициализации данных

Начнем с конфигурации, где подготовка и очистка данных вынесены за пределы теста.

Схема теста, где подготовка вынесена за пределы теста
Схема теста, где подготовка вынесена за пределы теста

Проблема

Если данные готовятся в начале класса и не используются ленивая инициализация данных (паттерн by lazy), то пока все данные не подготовятся, тесты не запустятся. А если данные не подготовятся, то все тесты класса упадут.

Разметка тестопса для всего класса тестов, которые упали из-за подготовки данных в начале класса без ленивой инициализации
Разметка тестопса для всего класса тестов, которые упали из-за подготовки данных в начале класса без ленивой инициализации

Решение

Использовать паттерн by lazy, если в начале класса данные запрашиваются или создаются запросом в БД или генерацией данных.

Для тестов из класса, которые не зависят от этих данных:

  • Ускоряет запуск: не нужно ждать, пока инициализируются все данные для всего класса

  • Защищает от падений. Если данные, которые не используются в тесте не смогут инициализироваться, то тест всё равно пройдёт. Упадут только зависимые тесты.

Теперь перейдем к проблемам, которые не зависят от места подготовки данных.

Использование хардкода

На мой взгляд, основная проблема хардкода в автотестах – потеря контекста. Пока ты писал тест, ты помнил, почему захардкодил этот idшник. Но через время контекст данных теряется, а тест с этими данными почему-то перестает выполняться.

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

  • Избежим парадокс пестицида – так данные будут разнообразные.

  • Запрос в БД или генерация сами по себе описывает суть данных. Даже если в какой-то момент тесты с этими данными перестанут выполняться, мы имеем направление поиске данных. 

Но бывают ситуации, когда хардкод неизбежен.

Пример такой ситуации у нас – “горячий пользователь”. Это пользователь, который имеет много лайков и прослушиваний на регулярной основе. Быстро такого пользователя не создать.

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

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

  • Почему тут хардкод 

  • Как создать новые данные для хардкода

Пока я писала статью, нашла исполнителя Hardcode. Звучит приятно. 

Неточный запрос в БД

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

Пример ситуации

У нас была задача от бизнеса: если у артиста нет фото, то отображать обложку релиза. 

Мы запрашивали из БД любого артиста без фотографии. А тест периодически падал.

Я стала изучать данные, с которыми тест проходил и падал.

В итоге разделила один флакающий тест, на три стабильных:

  • Артист без фотографии и доступный релиз с обложкой 

  • Артист без фотографии и доступный релиз без обложки

  • Артист без фотографии и без доступных релизов

Решение:

  • Уточнить запрос в БД. Если личное изучение данных не дает результатов, то можно привлекать аналитиков и разработчиков.

  • Проверить, покрыты ли тестами исключенные данные. В примере мы не только убрали из теста релизы без обложки и артистов без релизов, но и написали на эти данные новые тесты.

Модификация одних и тех же данных в разных тестах

Собаки модифицируют макаронину
Собаки модифицируют макаронину

Симптом:

Тест хорошо работает, когда запускается один, но падает при массовом запуске

Пример ситуации:

У нас был класс,  где пользователь создается в начале класса. В классе была два конкурирующих теста:

  • В тесте №1 пользователю добавляют подписку

  • В тесте №2 нужен пользователь без подписки

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

Решение:

  • Разделять данные между тестами, например, искусственно сужать запросы в БД. То есть, помимо функциональных условий, добавлять искусственные, например, id<1000.

  • Генерировать отдельные данные для каждого теста.

  • Отключать параллельный запуск тестов или регулировать очередность их выполнения.

Отсутствие проверки генерации данных

Начну с ситуации, которая проиллюстрирует эту проблему. У нас был функционал, который превращал профиль обычного пользователя в профиль артиста. Функционал убрали, тесты на него удалили. Но где-то в закромах проекта остался метод, который “продолжал использовать этот функционал”. А этот метод использовал тест на другой сервис. Этот тест “успешно” выполнялся до тех пор, пока мы не добавили проверку кода во все сервисные функции.

Из данной ситуации мы можем выделить несколько проблем:

  • не знаем, работает ли функция

  • при поломке функции нет способа локализации проблемы

  • при изменении бизнес-логики забываем обновить или удалить функцию

Во все сервисные функции необходимо добавлять:

  • проверку кода для архитектуры Rest

  • проверку схемы для архитектуры GraphQL

Пример функции с проверкой кода
Пример функции с проверкой кода

Проблемы на этапе проверки схемы

На моменте проверки схемы могут возникнуть две проблемы

  1. Приходящие данные не соответствуют тесту 

  2. Схема слишком жесткая

    • типы данных ограничены

    • опциональные поля обозначены обязательными

Проблему неожиданных данных мы разобрали выше. Поэтому теперь обсудим слишком жесткую схему.

Рассмотрим пример с типом данных. Мы написали тест на получение профиля пользователя и ожидаем, что у него в профиле будут телефон и почта. То есть, в схеме мы ожидаем:

  • email:string

  • phone:string

Однако в реальности у нас есть пользователи, у которых указаны только телефон или только почта. Но не существует пользователей без телефона и без почты.

Что можно сделать, чтобы получить стабильный тест?

  1. Разделить тест на несколько: с разными жесткими схемами. В текущем примере нужно сделать три схемы:

    1. Пользователь со всеми данными: email:string и phone:string

    2. Пользователь только с почтой: email:string и phone:null

    3. Пользователь только с телефоном: email:null и phone:string

  2. Разделить тест на несколько: с одной ослабленной схемой и явными проверками. В текущем примере: общая схема email[:string, null] и phone:[string, null], плюс дополнительные проверки:

    1. Пользователь со всеми данными: явно проверяем наличие почты и телефона

    2. Пользователь только с почтой: явно проверяем наличие почты и отсутствие телефона

    3. Пользователь только с телефоном: явно проверяем отсутствие почты и наличие телефона

Ситуация с обязательными и опциональными полями решается аналогичным способом.

Проблемы на этапе основных проверок

Теперь перейдём к проблемам, которые возникают на этапе основных проверок

Тест не готов к состоянию кэша

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

Решение:

- Чистить кэш сервиса по ключу перед запуском теста. Сделать это этапом подготовки данных.

- Отключать параллельный запуск тестов и регулировать очередность запуска тестов.

Тест со слишком большим числом проверок

Эта проблема возникает, когда в маниакальном эпизоде вы написали тест, который проверяет ВСË.

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

Что же можно сделать с таким тестом?

  • Разделить тест на несколько атомарных

  • Посмотреть другие тесты и удалить дублирующиеся проверки 

  • Использовать soft assert, чтобы все проверки запускались

Конструкция soft assert не останавливает прохождение теста, если какая-то проверка падает. Тест доходит до конца и подсвечивает упавшие проверки.

Для иллюстрации разберем пример: при первом запросе профиля пользователя происходит кэширование, кэш хранится час. Если профиль обновить, то кэш очищается.

Тест-монстр

Атомарные тесты

1. Запрашиваем профиль

2. Проверяем, что профиль вернулся

3. Проверяем, что профиль закэшировался

4. Проверяем, что время жизни кэша – час

5. Запрашиваем профиль повторно

6. Проверяем, что не появился второй кэш

7. Обновляем профиль

8. Проверяем, что кэш удалился

1. Запрашиваем профиль

2. Проверяем, что профиль вернулся

1. Запрашиваем профиль

2. Проверяем, что профиль закэшировался

3. Проверяем, что время жизни кэша – час

1. Запрашиваем профиль

2. Проверяем, что профиль закэшировался

3. Запрашиваем профиль повторно

4. Проверяем, что не появился второй кэш

1. Запрашиваем профиль

2. Проверяем, что профиль закэшировался

3. Обновляем профиль

4. Проверяем, что кэш удалился

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

Слишком жесткие проверки

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

Что можно сделать в этой ситуации?

  • Написать более мягкие проверки

  • Использовать в тесте одну такую проверку или их комбинацию

В таблице я предложила варианты более мягких проверок для нашего примера:

Слишком жесткая проверка

Варианты мягких проверок

val currentData = now().to0ffsetDateTime().toString() step( "Проверяем точное соответствие registered") {assertThatJson(response).inPath("registered").isEqualTo(currentData) }

val currentData = now().toOffsetDateTime().toString() val currentDataCut = currentData.substringBefore('.') step ("Проверяем, что registered содержит текущую дату") {assertThatJson(responseBody).inPath("registered").toString().contains(currentDataCut) }

step("Проверяем, что registered возвращается в часовом поясе по UTC") {assertThatJson(responseBody).inPath("registered").toString().contains("+00:00") }

step("Проверяем, что registered нe null") {assertThatJson(responseBody).inPath("registered").isNotNull }

val currentData = now().toOffsetDateTime().minusSeconds(2).toString() val currentDataFuture = now().toOffsetDateTime().plusSeconds(2).toString() val registered = responseBody.get("registered").toString() step ("Проверяем, что registered диапазоне 2 секунд") {assertThat(registered).isBetween(currentData, currentDataFuture) }

Стоит ли автоматизировать нестабильные сценарии

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

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

Иллюстрация гонки состояний в автотесте
Иллюстрация гонки состояний в автотесте

В этот промежуток времени не так легко попасть, и поэтому такой тест изначально нестабильный. 

Когда нужно автоматизировать нестабильные сценарии?

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

  • Ручная проверка занимает существенно больше времени, чем несколько перезапусков. 

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

Тест из примера можно сделать стабильнее следующим способом:

  • подобрать время ожидания, начиная с которого можно вычитывать сообщение

  • использовать цикл при вычитывании сообщения

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

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

Также можно внутри теста можно сохранять данные сообщения и потом допроходить его руками.

Ложноположительный тест

Найдите ложноположительную собаку
Найдите ложноположительную собаку

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

Ложноположительный тест – тест, который не проверяет ничего или проверяет не то, что от него ожидалось.

Обычно ложноположительный тест обнаруживается двумя способами: 

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

  • при рефакторинге

Рассмотрим, как он может проявиться:

  1. На этапе подготовки данных: данные не такие, как мы думаем. Как пример: в тесте используется функция генерации данных без проверки кода/схемы ответа.

  2. На этапе проверки схемы: схема слишком мягкая. Как пример, мы размягчили схему, чтобы её могли использовать несколько тестов, но не добавили явных проверок ответа.

  3. На этапе основных проверок:

    1. Проверка ничего не проверяет. Написали такую проверку, которая, вне зависимости от данных, отдает true.

    2. Проверка не соответствует ожиданию от теста.

    3. Нарушена последовательность проверок и шагов. Например, сначала делаем проверку, а следующим шагом модифицируем данные.

При большом тестом парке вычислить все существующие ложноположительные тесты невозможно. Поэтому основной метод борьбы – не писать такие тесты.

При написании и отладке тестов можно использовать следующие самопроверки:

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

  • Запускайте тест с неправильными данными, чтобы он упал. Например, у вас тест для пользователя с подпиской, запустите этот тест на пользователей без подписки. Если тест не упадет, то в нем проблемы. 

  • Если нашел ложноположительную конструкцию, то проверь, не повторяется ли она в проекте. Мы копируем код у коллег, коллеги копируют код у нас, поэтому “ложноположительное решение” может расползтись по коду.

Выводы

  1. Техники, предложенные в статье, можно использовать при написании, ревью и рефакторинге тестов. Что должно быть в проекте:

    • атомарные и понятные тесты

    • безопасные тесты

      a) Очистка данных за тестом

      b) Очистка раньше проверок

      c) Исключить модификацию чувствительных данных

      d) Ленивая инициализация данных

    • проверка результата работы сервисных функций

      a) Код ответа для rest

      b) Схема ответа для graphQl

    • корректные шаги

      a) Каждое значимое действие обернуто в шаг

      b) Шаги должны быть атомарными

    • кэш сервиса должен соответствовать ожиданиям теста 

    • убирать ложноположительные конструкции по всему проекту

    • соблюдать баланс между жесткими и мягкими проверками

    • читабельность кода

      a) Понятные названия теста, шагов, переменных

      b) Комментарии при необходимости

  2. Нестабильные сценарии можно автоматизировать, но максимально стабилизируя их.

  3. Ремонт сломанных и флакающих тестов нужно проводить в кратчайшие сроки, так как они могут маскировать баги.

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

Диаграмма роста количества тестов
Диаграмма роста количества тестов

Спасибо, что прочитали статью. Буду рада вашим вопросам и комментариям!

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