Что такое плавающий тест на самом деле
Плавающим называют тест, который иногда падает без изменений в коде. Отсюда обычно делают вывод, что виноват тест, и приписывают ему retry. Вывод неверный: в подавляющем большинстве случаев тест поймал настоящую гонку и не смог её повторить.
Асинхронный код не выполняется в одном порядке. Когда несколько колбэков готовы в один и тот же момент, кто-то должен решить, какой из них пойдёт первым, — и решает это рантайм вместе с операционной системой. Вариантов тысячи, ломает вас один. На вашей машине он не выпадает никогда, на сборочном сервере в три часа ночи — один раз, и больше не повторяется.
Сколько это стоит в проде
Гонка, которую заглушили ретраем, не исчезает — она переезжает в продакшн, где расписание выбирает не ваш ноутбук, а нагрузка. И там она стоит денег.
Первая строка — самая дорогая и самая частая. Это ровно тот случай, ради которого в платёжных системах существует идемпотентность операций, и ровно тот, который обычный тест не ловит: брошенный запрос доезжает уже после того, как тест ушёл.
Почему retry(3) — не решение
Ретрай меняет вопрос «почему упало» на вопрос «сколько раз перезапустить, чтобы прошло». Это работает ровно до того дня, когда красная сборка перестаёт кого-либо удивлять, — и с этого дня набор тестов больше не защищает.
- Ретрай прячет не флейк, а класс ошибок: любая настоящая гонка выглядит так же и точно так же «чинится».
- Время сборки растёт, а доверие падает: команда начинает перезапускать до того, как прочитает лог.
- Баг остаётся в коде и ждёт нагрузки, при которой редкое расписание станет частым.
Отобрать расписание у операционной системы
Идея детерминированной симуляции простая: если недетерминизм приходит из того, что вы не контролируете, — заберите это себе. Внутри прогона подменяются setTimeout, setInterval, setImmediate, Date, performance.now и Math.random. Все способы подождать сходятся в одну виртуальную очередь таймеров, и вопрос «что дальше» получает единственного владельца.
Когда в один виртуальный момент готовы несколько колбэков, порядок выбирается из сида и записывается. Один сид — один и тот же прогон, побайтово, на любой машине. Между шагами перепроверяются инварианты, поэтому нарушение ловится на том расписании, которое его вызвало, а не тогда, когда тест в следующий раз решил проверить.
Побочный эффект, который переоценить трудно: виртуальное время бесплатно. Политика повторов с отступом в шесть часов проверяется за микросекунды, а зависшая система отличается от медленной — потому что симулятор знает, что проснуться уже некому.
Как это выглядит в коде
Мы написали для этого открытый инструмент — unflake. Тест выглядит как обычный, только тело исполняется в контролируемом мире, а условие проверяется после каждого шага планировщика.
import { check } from 'unflake';
import { it } from 'vitest';
it('соединение не выдаётся дважды', async () => {
await check('одно соединение — один держатель', async (sim) => {
const pool = createPool(sim, { size: 2 });
const held = new Map<string, number>();
// Перепроверяется после каждого шага планировщика, а не в конце теста.
sim.invariant('нет соединения у двоих', () =>
[...held.values()].every((n) => n <= 1),
);
await sim.parallel(4, async () => {
const conn = await pool.acquire();
held.set(conn, (held.get(conn) ?? 0) + 1);
await sim.io('query', { latency: [1, 6] });
held.set(conn, (held.get(conn) ?? 0) - 1);
pool.release(conn);
});
}, { runs: 200 });
});Если одно из двухсот расписаний ломает инвариант, падение сжимается до минимального расписания, которое всё ещё ломает, и печатается вместе с таймлайном: кто что держал, кто чего ждал и в какой момент стало некому просыпаться. Воспроизводится оно не сидом, а записанным планом — то есть на чужой машине тоже.
Выборка и доказательство — разные вещи
Двести случайных расписаний — это фаззинг: чистый результат означает «не нашли», а не «не бывает». Для достаточно маленького теста можно получить больше — перебрать дерево решений целиком.
const report = await explore('одно соединение — один держатель', body);
// { ok: true, schedules: 24, exhaustive: true }exhaustive: true означает, что пространство обойдено полностью, и тогда прохождение читается как «нарушения не существует» — для каждого исполнения, которое модель способна породить. Ограничение честное: дерево растёт мультипликативно, широкий разброс задержек [1, 25] даёт двадцатипятикратное ветвление на каждой операции ввода-вывода, и полный перебор остаётся уделом небольших тестов.
Что показал прогон по чужому коду
Инструмент, который ничего не нашёл, вызывает подозрения — поэтому мы прогнали его по чужим пакетам и опубликовали результат целиком: 19 задокументированных контрактов в семи популярных библиотеках (p-limit, p-queue, async-mutex, async-sema, generic-pool, p-retry, bottleneck), сотни расписаний на каждый.
Ни один контракт не нарушился. Это честный заголовок, и его стоит произнести вслух, а не тихо не упомянуть. Это же и ожидаемый результат: у этих пакетов миллионы загрузок в неделю, а их основные гарантии проверяет каждый пользователь ежедневно. Инструмент, который в первый же вечер «ломает» ограничение параллелизма в p-limit, рассказывает о своей калибровке, а не о p-limit.
Одна находка выглядела настоящей: bottleneck запускал две задачи с разрывом 9 мс при заявленных minTime: 10. Она воспроизводилась и вне симулятора — но разрыв составлял ровно 1 мс во всех четырёхстах расписаниях, то есть это стоимость старта, а не дефект планирования. Отправить такой отчёт — отнять у мейнтейнера вечер из-за одной миллисекунды. Мы его не отправили и записали почему.
Чего метод не умеет
Инструмент тестирования, который переоценивает свои гарантии, хуже его отсутствия. Границы здесь такие.
- Видит только то, чем управляет. Настоящий сокет, файл или нативный драйвер планировщику не видны, и прогон, где ничего не осталось выполнять, выглядит как взаимная блокировка. Реальный ввод-вывод нужно пропускать через симулятор или подменять.
- Не сокращает пространство перебора. Инструмент не знает, какие операции трогают общее состояние, поэтому не может доказать эквивалентность двух порядков и пропустить один. В Rust это умеет loom — потому что код под тестом использует его собственные примитивы.
- Не про параллелизм. Node однопоточный, симулятор тоже: гонки между воркерами, процессами и внутри нативных модулей вне области.
- Порядок микрозадач не трогает. Разрешение промисов и так детерминировано; переставлять его — значит выдумывать гонки, которых движок породить не может, а ложное срабатывание стоит дороже пропущенного бага.
С чего начать за час
- Найдите в истории сборок тест, который отмечен ретраем или падал без изменений в коде.
- Сформулируйте инвариант, который он на самом деле проверяет: «одно соединение — один держатель», «списание происходит один раз».
- Перепишите тело теста так, чтобы ожидания шли через симулятор, а не через настоящие таймеры и сеть.
- Прогоните двести расписаний. Упало — у вас есть минимальное расписание и воспроизведение; не упало — у вас есть основание снять ретрай.
- Оставьте прогон в CI: гонка, которую вы починили, теперь не вернётся молча.
Если система, о которой идёт речь, считает деньги, начинать стоит не с тестов, а с архитектуры операций: подробности — в разборе про идемпотентность платежей и на странице финтех-разработки. Остальные инженерные разборы собраны в рубрике «Инженерия».
Частые вопросы
Чем это отличается от подмены таймеров вроде fake-timers?
Подмена таймеров управляет временем, но не решает, какой из нескольких готовых колбэков пойдёт первым. Именно в этом выборе живут гонки. Детерминированная симуляция управляет и временем, и порядком, и записывает сделанный выбор, чтобы его можно было повторить.
Это заменит наши обычные тесты?
Нет. Это отдельный слой для конкурентного кода: пулы, очереди, повторы, транзакции, всё, где несколько задач трогают общее состояние. Юнит-тесты и интеграционные никуда не деваются.
Сколько расписаний нужно прогонять?
Двести — разумное значение по умолчанию: прогон дешёвый, потому что время виртуальное. Для маленьких тестов имеет смысл полный перебор — он даёт не «не нашли», а «не существует»; чем это отличается по существу, разобрано в материале про проверку моделей.
Мы пишем не на TypeScript. Что делать?
Подход не привязан к языку: в Rust это loom и shuttle, в распределённых системах — Jepsen, в базах данных — собственные симуляторы. Общее правило одно: сделайте источник недетерминизма своим и записывайте сделанный выбор.
Источники
Утверждения из статьи можно проверить: ниже первоисточники, а не пересказ.
- 01FoundationDB: Testing — База данных, корректность которой держится на детерминированной симуляции
- 02TigerBeetle: VOPR — Тот же подход для финансового регистра
- 03loom (Rust) — Полный перебор с сокращением пространства — то, чего в JavaScript пока нет
- 04unflake — Инструмент из этой статьи: MIT, ноль зависимостей, аудит семи пакетов внутри

Этот материал подписан именем, в отличие от остальных: за ним не стоит клиент. Код, о котором идёт речь, открыт целиком и лежит под тем же именем — его можно прочитать, запустить и проверить утверждения статьи, не поверив ни одному из них на слово.