Нестабильный тест (flaky test) - автотест, который на одном и том же коде то проходит, то падает: продукт, тест и конфигурация не меняются, а результат меняется. Падение такого теста не показывает, есть ли в продукте дефект, а прохождение не показывает, что его нет. Нестабильные тесты называют также недетерминированными (non-deterministic) или плавающими (intermittent).
Термин пришёл из инженерной практики. В 2011 году Мартин Фаулер описал проблему в статье «Eradicating Non-Determinism in Tests». В 2016 году в Google Testing Blog вышла статья Джона Микко «Flaky Tests at Google and How We Mitigate Them», которая определяет нестабильный результат как тест, показывающий и успешный, и неуспешный результат на одном и том же коде. Первое крупное эмпирическое исследование причин, «An Empirical Analysis of Flaky Tests» Луо, Харири, Элусси и Маринова, представлено на конференции FSE в 2014 году. Программа ISTQB Advanced Level Test Automation Engineering (версия 2.0, 2024 год) не использует слово flaky: она говорит о тест-кейсах, которые не дают надёжного результата, например из-за состояния гонки, и о плавающих сбоях. Средства запуска тестов используют flaky как статус: Playwright и Maven Surefire помечают тест, который упал и затем прошёл при повторе, как flaky.
Какую проблему создают нестабильные тесты
Автотест полезен, когда его падение означает проблему в продукте. Нестабильный тест разрывает эту связь. После нескольких ложных падений команда начинает перезапускать пайплайн вместо того, чтобы читать сообщение об ошибке. Фаулер отмечает следствие: научившись не обращать внимания на падения одного теста, разработчики перестают замечать и настоящие падения других.
Масштаб проблемы в большой кодовой базе Google опубликовал в 2016 году:
- около 1,5 процента всех запусков тестов давали нестабильный результат;
- почти 16 процентов тестов были в той или иной мере нестабильными;
- около 84 процентов наблюдаемых переходов от прохождения к падению при тестировании после отправки изменений (post-submit) приходились на нестабильный тест.
В проекте из 1000 тестов при доле нестабильных результатов 1,5 процента около 15 тестов упадут в прогоне по причинам, не связанным с изменением. Каждое такое падение блокирует слияние, отнимает время на анализ и снижает доверие к результатам всего набора.
Причины нестабильности
Тест нестабилен, когда его результат зависит от того, чем тест не управляет. Обычные источники таких зависимостей описаны ниже.
Асинхронные ожидания и тайминги
Тест выполняет действие и проверяет результат раньше, чем закончилась асинхронная операция: запрос к серверу, отрисовка страницы, сообщение в очереди. Фиксированная пауза в тесте скрывает проблему, пока операция быстрее паузы. На нагруженном агенте CI операция длится дольше, и тест падает. Программа ISTQB CTAL-TAE называет жёстко заданные ожидания причиной многих падений тестов.
Зависимость от порядка тестов и общее состояние
Один тест оставляет состояние, на которое опирается другой: запись в базе данных, файл, значение в кеше, статическую переменную. Второй тест проходит, когда выполняется после первого, и падает, когда порядок меняется, например при распределении тестов между параллельными исполнителями (workers). Фаулер рекомендует изолировать тесты, чтобы выполнение одного теста не влияло на другие.
Тестовые данные
Тесты с общими учётными записями или записями в базе мешают друг другу. Два теста, которые параллельно переименовывают одного и того же клиента, видят изменения друг друга. Тест, который ожидает пустой список, падает, когда другой тест добавил элемент. Данные с ограниченным сроком - токен, пробный период, забронированный слот - делают тест зависимым от даты запуска.
Окружение и внешние сервисы
Тест зависит от сети, стороннего API, почтового сервера, общего тестового окружения. Таймаут или временная ошибка такого сервиса роняет тест без изменения продукта. Фаулер предлагает заменять нестабильные удалённые сервисы тестовыми двойниками (test doubles) - заменителями, которые ведут себя как настоящий сервис. Роль играют и различия агентов: число ядер, объём памяти, разрешение экрана, установленные шрифты.
Параллельное выполнение
У продукта или у самого теста несколько потоков или процессов, и результат зависит от порядка их работы. Состояние гонки проявляется только при некоторых чередованиях операций. Такие дефекты часто оказываются настоящими дефектами продукта, а не только теста.
Случайность, время и часовые пояса
Случайные входные данные, случайные идентификаторы и обход неупорядоченных коллекций дают разное выполнение при каждом запуске. Тесты, которые читают системные часы, падают в конце месяца, в полночь, при переходе на летнее время или на агенте в другом часовом поясе. Фаулер рекомендует всегда оборачивать системные часы, чтобы тест мог их подменить.
Утечки ресурсов
Тест не освобождает соединение, файловый дескриптор, порт, процесс браузера или память. Следующие тесты падают, когда ресурс заканчивается, и падение проявляется в тесте, который не имеет отношения к утечке. Фаулер описывает пул ресурсов размером один как способ заставить упасть первый тест после утечки и тем указать на причину.
Данные исследований
Луо, Харири, Элусси и Маринов изучили коммиты в репозиториях Apache Software Foundation, в сообщениях которых упоминаются плавающие или нестабильные тесты. Они нашли 486 коммитов, которые, вероятно, исправляют разные нестабильные тесты, не меньше чем в 51 проекте, и подробно разобрали 201 из них. Для 161 коммита удалось определить причину. Распределение причин:
| Причина | Коммиты | Доля от 161 |
|---|---|---|
| Асинхронное ожидание | 74 | 45% |
| Параллельное выполнение | 32 | 20% |
| Зависимость от порядка тестов | 19 | 12% |
| Утечка ресурсов | 11 | 7% |
| Сеть | 10 | 6% |
| Время | 5 | 3% |
| Ввод и вывод | 4 | 2% |
| Случайность | 4 | 2% |
| Операции с плавающей точкой | 3 | 2% |
| Неупорядоченные коллекции | 1 | 1% |
Исследование также показывает, как тесты исправляли. 42 из 74 тестов с асинхронным ожиданием исправлены явным ожиданием условия; 20 «исправлены» добавлением или увеличением паузы, что лишь снизило вероятность падения. Все исправления тестов, зависящих от порядка, устранили нестабильность, чаще всего за счёт подготовки или очистки общего состояния. В 38 случаях из 161 (24 процента) исправление затронуло и тестируемый код, и в большинстве таких случаев устранило настоящий дефект. 126 из 161 теста были нестабильными с момента написания.
Обнаружение нестабильных тестов
Повторные запуски на одном коммите
Прямой способ показать нестабильность - запустить тест несколько раз на одном коммите без изменений. Если результаты различаются, тест нестабилен по определению. Playwright запускает каждый тест несколько раз с опцией --repeat-each, а упавшее задание можно повторить с --last-failed, которая запускает только упавшие тесты. Повторный запуск подтверждает нестабильность, но не находит причину.
История прогонов
Каждый прогон набора автотестов даёт результат по каждому тесту. Когда результаты хранятся как история, нестабильность видна как переключения между passed и failed без значимого изменения:
Test "checkout > pay with saved card", branch main
commit run result
a41f2c #812 passed
a41f2c #813 failed rerun of the same commit
b7d903 #814 passed
c1e5aa #815 passed
c1e5aa #816 failed rerun of the same commit
d90b4e #817 passedДва падения на коммитах, на которых тест также проходил, - признак нестабильного теста, а не регрессии. Тест, который падает в каждом запуске начиная с одного коммита, указывает на изменение. Результаты прогонов и их история в системе управления тестированием описаны в статье Тестовый прогон. Из такой истории получают метрики: долю прогонов с переключением и число переключений по тесту за период.
Отдельные задания
Некоторые причины проявляются только в определённых условиях. Чтобы их выявить, команды запускают отдельные задания:
- ночное задание, которое повторяет каждый тест много раз;
- задание, которое запускает тесты в другом или случайном порядке, чтобы найти зависимости от порядка;
- задание, которое запускает каждый тест изолированно, чтобы отделить тест от соседей;
- задание на агенте с ограниченными ресурсами, где проблемы таймингов проявляются чаще.
Программа CTAL-TAE рекомендует при анализе неожиданного результата выполнить несколько изолированных тестов и отмечает, что мониторинг системных ресурсов может подсказать причину плавающих сбоев.
Карантин
Карантин - практика, при которой нестабильный тест выносят из наборов, результаты которых определяют слияние или релиз, а сам тест продолжает выполняться отдельно. Фаулер описывает это как хранение недетерминированных тестов в отдельном наборе, отдельно от здоровых тестов. Статья Google описывает инструмент, который отправляет в карантин тесты со слишком высокой нестабильностью и заводит на каждый задачу (bug); там же сказано, что так можно скрыть настоящие дефекты.
Правила карантина обычно определяют:
- критерий входа: например, два падения и прохождения на одном коммите за неделю;
- владельца: команду, которая отвечает за тест, и дефект или задачу на исправление;
- ограничение: Фаулер предлагает ограничить число тестов в карантине (например, восемью) или срок пребывания теста в нём (например, неделей);
- критерий выхода: исправление влито, и тест проходит серию повторных запусков;
- видимость: тесты в карантине по-прежнему запускаются, а их результаты попадают в отчёт, чтобы настоящую регрессию в них заметили.
Тест, который остаётся в карантине без владельца, больше ничего не проверяет. Его удаление тогда - явное решение, а не побочный эффект.
Повторы и их риски
Повтор (retry) - автоматический перезапуск упавшего теста в том же задании. Средства запуска тестов поддерживают его напрямую. В Playwright опция retries задаёт максимальное число повторов, а тест, который сначала упал и прошёл при повторе, получает статус flaky:
import { defineConfig } from '@playwright/test';
export default defineConfig({
retries: process.env.CI ? 2 : 0,
use: {
trace: 'on-first-retry',
},
});Трассировка записывается при первом повторе, поэтому нестабильное падение оставляет данные для анализа. С версии 1.52 опция failOnFlakyTests (в командной строке --fail-on-flaky-tests) делает прогон неуспешным, если хотя бы один тест нестабилен. В pytest плагин pytest-rerunfailures добавляет опции --reruns, --reruns-delay и --only-rerun; последняя повторяет только падения, которые соответствуют шаблону:
pytest --reruns 2 --reruns-delay 1 --only-rerun TimeoutErrorВ Maven Surefire есть свойство surefire.rerunFailingTestsCount; тест, который прошёл при повторе, учитывается в сводке как нестабильный (flake), а его неуспешные попытки записываются в XML-отчёт как элементы flakyFailure.
Повторы уменьшают число заблокированных слияний, но у них есть цена:
- настоящий плавающий дефект, например состояние гонки в продукте, проходит при повторе и попадает в продакшен; по данным Луо и соавторов, четверть исправлений нестабильных тестов затронула тестируемый код;
- набор работает дольше, потому что каждое нестабильное падение выполняется несколько раз;
- обнаружение задерживается: Google описывает правило, по которому падение засчитывается только после трёх падений подряд, и 15-минутный тест превращается в 45 минут ожидания;
- без отчётности нестабильность незаметно накапливается.
Поэтому повторы сочетают с отчётностью: статус flaky записывается в историю, подсчитывается и считается поводом для исправления. Плавающий сбой, который оказался дефектом продукта, оформляют со всем, что нужно для воспроизведения; см. статью Отчёт о дефекте. Как повторы настраивают в пайплайне, описано в статье Автотесты в CI.
Исправление нестабильных тестов
Ожидание условия вместо фиксированной паузы
Фиксированная пауза либо тратит время впустую, либо слишком коротка. Тест ниже после сохранения ждёт две секунды, а затем один раз считает строки:
import { test, expect } from '@playwright/test';
test('order appears in the list after saving', async ({ page }) => {
await page.goto('https://example.com/orders/new');
await page.getByLabel('Customer').fill('Anna');
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForTimeout(2000);
expect(await page.getByRole('row').count()).toBe(2);
});Документация Playwright помечает page.waitForTimeout как нерекомендуемый метод и указывает, что тесты, которые ждут заданное время, нестабильны по своей природе. Исправленная версия использует проверку, которая повторяется, пока условие не выполнится или не истечёт таймаут:
import { test, expect } from '@playwright/test';
test('order appears in the list after saving', async ({ page }) => {
await page.goto('https://example.com/orders/new');
await page.getByLabel('Customer').fill('Anna');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('row')).toHaveCount(2);
});Playwright ждёт и перед действиями: перед нажатием он проверяет, что элемент видим, неподвижен, получает события и доступен. Программа CTAL-TAE ранжирует три вида ожиданий: жёстко заданное ожидание - худшее, опрос изменения состояния с таймаутом - лучше, подписка на событие тестируемой системы - самое надёжное. Для состояния за пределами страницы в Playwright есть expect.poll:
import { test, expect } from '@playwright/test';
test('payment is confirmed by the back end', async ({ request }) => {
await expect.poll(async () => {
const response = await request.get('https://example.com/api/orders/1042');
return (await response.json()).status;
}, { timeout: 10000 }).toBe('PAID');
});Собственные данные для каждого теста
Тест создаёт нужные ему данные и потом удаляет их. Уникальные значения не дают параллельным тестам столкнуться:
import uuid
import pytest
@pytest.fixture
def customer(api):
created = api.post("/customers", json={"name": f"test-{uuid.uuid4()}"}).json()
yield created
api.delete(f"/customers/{created['id']}")
def test_rename_customer(api, customer):
api.patch(f"/customers/{customer['id']}", json={"name": "Renamed"})
assert api.get(f"/customers/{customer['id']}").json()["name"] == "Renamed"Здесь api - фикстура проекта с HTTP-клиентом. Тест не зависит от записей, оставленных другими тестами, и его собственная запись не влияет на них.
Управляемое время и внешние сервисы
Время задаёт тест, а не агент. В Playwright page.clock.setFixedTime устанавливает время, которое видит страница, а опция timezoneId - часовой пояс контекста браузера:
import { test, expect } from '@playwright/test';
test.use({ timezoneId: 'Europe/Berlin' });
test('shows the end of the billing period', async ({ page }) => {
await page.clock.setFixedTime(new Date('2026-01-31T23:30:00+01:00'));
await page.goto('https://example.com/billing');
await expect(page.getByText('Billing period ends today')).toBeVisible();
});Нестабильные внешние сервисы заменяют тестовыми двойниками. В Playwright page.route перехватывает запросы страницы и возвращает подготовленный ответ, и тест перестаёт зависеть от доступности настоящего API.
Нестабильный тест, плавающий дефект и сломанный тест
Таблица показывает три ситуации, которые в отчёте выглядят похоже.
| Нестабильный тест | Плавающий дефект | Сломанный тест | |
|---|---|---|---|
| Где причина | В тесте или его окружении | В продукте | В тесте |
| Результат на одном коде | Меняется | Меняется | Всегда падение |
| Повторный запуск | Обычно проходит | Иногда проходит | Снова падает |
| Что исправлять | Тест, данные, окружение | Код продукта | Тест |
| Запись | Задача на тест, карантин | Отчёт о дефекте | Задача на тест |
Нестабильный тест и плавающий дефект нельзя различить только по результату. Оба дают прохождения и падения на одном коде. Разницу находят анализом: журналы, трассировки, воспроизведение под нагрузкой. Повтор, который превращает падение в прохождение, одинаково убирает сигнал в обоих случаях.
Сломанный тест падает детерминированно, например после изменения интерфейса, под которое тест не обновили. Он не нестабилен: его падение воспроизводится и указывает на конкретное изменение.
Термины
| Термин | Значение |
|---|---|
| Нестабильный тест (flaky test) | Автотест, который на одном и том же коде то проходит, то падает без изменений продукта, теста и конфигурации |
| Зависимость от порядка тестов (test order dependency) | Зависимость результата теста от других тестов, которые выполняются раньше и оставляют общее состояние |
| Карантин (quarantine) | Вынос нестабильного теста из наборов, которые определяют слияние или релиз; тест продолжает выполняться отдельно до исправления |
| Повтор (retry) | Автоматический перезапуск упавшего теста в том же задании; тест, прошедший при повторе, отмечается как нестабильный |
| Плавающий дефект (intermittent defect) | Дефект продукта, который вызывает сбой только в части выполнений на одном и том же коде |
Источники
- Flaky Tests at Google and How We Mitigate Them, John Micco, Google Testing Blog, 27 May 2016(откроется в новой вкладке) (на английском)
- Eradicating Non-Determinism in Tests, Martin Fowler, 14 April 2011(откроется в новой вкладке) (на английском)
- Luo, Hariri, Eloussi, Marinov. An Empirical Analysis of Flaky Tests, FSE 2014(откроется в новой вкладке) (на английском)
- ISTQB Certified Tester Advanced Level Test Automation Engineering Syllabus v2.0, ISTQB, 2024(откроется в новой вкладке) (на английском)
- Retries, документация Playwright(откроется в новой вкладке) (на английском)
- Auto-waiting, документация Playwright(откроется в новой вкладке) (на английском)
- Assertions, документация Playwright(откроется в новой вкладке) (на английском)
- Clock, документация Playwright(откроется в новой вкладке) (на английском)
- TestConfig, документация Playwright(откроется в новой вкладке) (на английском)
- pytest-rerunfailures, pytest-dev(откроется в новой вкладке) (на английском)
- Rerun failing tests, Maven Surefire Plugin(откроется в новой вкладке) (на английском)