open atlas
↑ К треку
Node.js с нуля до senior NODE · 06 · 04

Изоляция тестов и флакающие тесты: добиваемся детерминизма

Флакающий тест — это скрытая гонка, а не невезение. Убей недетерминизм у источника: зафиксируй часы, посей рандом, заморозь TZ, дёшево изолируй состояние сбросами beforeEach или откатом транзакции, а флак помещай в карантин, а не маскируй ретраями реальную долю отказов.

NODE Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

На твоём ноутбуке набор зелёный, каждый прогон, двадцать раз подряд. CI красный — но лишь иногда. Тот же коммит проходит, ретраится, падает, проходит. Ты читаешь падающий тест: он проверяет, что скидка истекает «завтра», и строка expiresAt: new Date(Date.now() + 86_400_000) сравнивается с фикстурой, записанной в момент создания файла теста. Он падает примерно один прогон из семи — те семь, где CI случайно попадает на границу перехода на летнее время или на полночь по UTC, которую твоя машина никогда не видит, потому что работает в одной таймзоне. Код в порядке. Тест задал стенным часам вопрос, у которого больше одного ответа.

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

Если тест проходит у тебя и падает в CI, ты смотришь не на загадку, а на одну из двух точно названных корневых причин — и зная их, чинишь проблему за минуты, а не за часы git-bisect.

Детерминированный тест выдаёт один и тот же вердикт в каждом прогоне, в любом порядке, на любой машине. Флак — это отсутствие такого свойства, и у него ровно два корня. Первый — общее изменяемое состояние: два теста трогают одно и то же, и один оставляет его грязным. Классические виновники: синглтон уровня модуля (кэш, пул соединений, объект конфига, загруженный один раз на import), общая строка БД, которую оба теста читают и пишут, или глобальные часы, которые все читают. Второй — неуправляемый недетерминизм: Date.now(), Math.random(), свежесгенерированные UUID, таймзона и локаль хоста, и тайминг — спать фиксированное число миллисекунд и надеяться, что работа закончилась.

Общее состояние порождает зависимость от порядка: тест B проходит только потому, что тест A отработал первым и случайно засеял синглтон. Переставь их — или запусти B один — и B падает. Вот почему набор, зелёный при запуске целиком, может стать красным, когда ты запускаешь один файл через .only.

// shared-state leak: a module-level counter both tests mutate
import { test, expect } from "vitest";
import { idCounter } from "../src/ids.js"; // module-level singleton: let n = 0

test("A: first id is 1", () => {
  expect(idCounter.next()).toBe(1); // passes only if it ran first
});
test("B: ids are sequential", () => {
  const a = idCounter.next();        // 2 if A ran, 1 if B ran alone
  expect(idCounter.next()).toBe(a + 1);
});
// Запусти B через --testNamePattern="B" — и "A: first id is 1" тихо сломается
// при следующем прогоне всего набора: сбой переехал.

Раннеры борются с этим изоляцией. Единица изоляции в дефолтном пуле Vitest и в node:test — это файл: каждый файл теста получает свежий воркер (отдельный процесс или поток-воркер), так что состояние уровня модуля рождается чистым на файл. Это изоляция процессов — герметичная, потому что протёкший синглтон в файле X не может дотянуться до файла Y. Но внутри одного файла тесты делят граф модулей и тот же глобальный объект: это изоляция в общем контексте, и именно тут живут протечки. Понимание, на какой границе ты находишься, говорит, достаточно ли beforeEach или протечка пересекает файлы.

Детерминизм: контролируй время, рандом и окружение

Тест делается детерминированным удалением каждого входа, которым тест явно не владеет. Зафиксируй часы фейковыми таймерами (vi.useFakeTimers() / vi.setSystemTime(...)) или, что лучше для продакшен-кода, внедри часы, чтобы юнит под тестом вызывал clock.now(), а тест подавал замороженные — внедрение зависимостей бьёт глобальный патч, потому что у него нет тирдауна, который можно забыть. Сей рандом так же: внедряй генератор id или сидированный PRNG вместо обращения к Math.random() или crypto.randomUUID() внутри бизнес-логики. Заморозь таймзону, запуская CI с TZ=UTC — единственная переменная окружения, убирающая целый класс флаков с датами, потому что арифметика дат, пересекающая границу перехода на летнее время, ведёт себя иначе в America/New_York, чем в UTC. И никогда не спи на стенных часах, ожидая асинхронную работу — жди настоящий промис или прокручивай фейковые таймеры; setTimeout(50), работающий на твоём ноутбуке, проигрывает гонку на нагруженной машине CI.

// flaky: reads the real clock and a real random source
test("token expires in one hour", () => {
  const t = issueToken();
  expect(t.expiresAt).toBe(Date.now() + 3_600_000); // both sides drift
});

// deterministic: freeze time, inject the id
import { vi, test, expect, afterEach } from "vitest";
afterEach(() => vi.useRealTimers());

test("token expires one hour after issue", () => {
  vi.useFakeTimers();
  vi.setSystemTime(new Date("2026-06-05T00:00:00Z"));
  const t = issueToken({ now: () => Date.now(), id: () => "fixed-id" });
  expect(t.expiresAt).toBe(Date.parse("2026-06-05T01:00:00Z")); // exact, always
});

Компромисс лежит между двумя режимами изоляции. Полная изоляция процессов на файл герметична, но медленна: запуск воркера стоит примерно десятки миллисекунд, так что набор из тысячи файлов платит секунды-десятки секунд чистого старта процессов до первого ассерта. Общий контекст (один поток, --no-isolate / pool: 'threads' с выключенной изоляцией) быстр, но протекает состоянием между тестами в одном файле. Сеньорский баланс — изолировать состояние дёшево, а не дорого: сбрасывай синглтон в beforeEach, откатывай БД в afterEach, а дорогую изоляцию процессов держи для тех немногих наборов, что реально мутируют глобальное состояние модулей и не чистятся на месте.

Почему это работает

Почему сброса в beforeEach обычно достаточно, когда изоляция процессов «безопаснее»? Потому что почти всё общее состояние достижимо и сбрасываемо: можно заново new кэша, очистить map, восстановить часы. Изоляция процессов — кувалда, что заодно сбрасывает состояние, которое ты не можешь назвать, — но ты платишь за запуск на каждом файле, всегда. Исключение — состояние, которое реально нельзя сбросить изнутри процесса: нативный аддон с глобалом, модуль, выполнивший необратимые побочные эффекты на импорте, или сторонний синглтон без явного метода. Там оплата изоляции процессов покупает корректность, которую иначе не получить. Везде ещё дешёвый сброс и быстрее, и лучше как диагностика — если beforeEach не чинит флак, ты нашёл протечку, а не спрятал её.

Параллельная изоляция БД: откат, схема-на-воркер или контейнеры

Тесты, бьющие в настоящую БД параллельно, — сложнейшая задача изоляции, потому что БД по определению есть общее изменяемое состояние. Есть три стратегии, меняющие скорость на верность. Транзакция на тест, откатываемая в конце: каждый тест открывает транзакцию, делает свои записи, а фикстура откатывает в тирдауне, так что ничего не сохраняется — это самое быстрое, стоит примерно 1–3 мс настройки БД на тест, и тесты не видят строк друг друга. Его предел: нельзя протестировать ничего, зависящего от настоящего коммита — триггеры, срабатывающие на COMMIT, отложенные ограничения, видимость между соединениями или код, открывающий свою транзакцию. Схема или база на воркер: каждый параллельный воркер получает своё пространство имён и обрезает таблицы между тестами; truncate-all бежит за десятки миллисекунд, медленнее отката, но способен тестировать настоящие коммиты, при полной изоляции воркеров друг от друга. Эфемерные контейнеры (testcontainers): поднять свежую БД на воркер или на набор — максимальная верность (точная версия движка, настоящие расширения), но старт меряется секундами, так что их запускают куда меньше и амортизируют по многим тестам.

// transaction-per-test fixture (Postgres + a pool): fast, auto-clean, no leaks
import { test as base } from "vitest";
import { pool } from "../src/db.js";

export const test = base.extend({
  db: async ({}, use) => {
    const client = await pool.connect();
    await client.query("BEGIN");
    await use(client);          // the test runs inside this open transaction
    await client.query("ROLLBACK"); // every write vanishes — next test sees a clean DB
    client.release();
  },
});

// usage: each test is hermetic without truncating or restarting anything
test("inserts a user", async ({ db }) => {
  await db.query("INSERT INTO users(email) VALUES('a@x.io')");
  const { rows } = await db.query("SELECT count(*) FROM users");
  expect(rows[0].count).toBe("1"); // 1, regardless of parallel tests
});
Выбери лучший вариант

Набор CI прогоняет 800 тестов с БД параллельно на 4 воркерах. Большинство проверяют результаты запросов; горстка проверяет триггеры аудит-лога, срабатывающие на коммите. Нужно быстро И верно. Какую изоляцию выбираешь?

Режим отказа: ретраи, которые маскируют флак

Вот как флакающий тест убивает деплой. Тест проходит в 80% случаев — реальная гонка 1 к 5, скажем, неотожданный промис, чья запись садится после ассерта, или протёкший таймер, держащий цикл событий живым в следующий тест. Кто-то, устав от красного CI, ставит retries: 3. Теперь тест зелёный: вероятность, что все четыре попытки упадут, — 0.2⁴ ≈ 0.16%, так что CI «починен». Это не так — эффективная доля отказов в 4% (один прогон из ~625 всё ещё красный) теперь невидима, и хуже того, лежащая под ней гонка тоже невидима. Месяцами набор зелёный. Потом гонка, симптомом которой был флак — неотожданная запись, зависимость от порядка, — проявляется в проде на деплое, в худший возможный момент, и ни один тест на неё не указал. Ретраи не починили флак; они спрятали баг, о котором флак докладывал.

Вариант с асинхронной протечкой острее: тест резолвится и докладывает «зелёно» до того, как его фоновая работа кончилась — fire-and-forget void doStuff(), таймер, который он не очистил. Эта работа потом бежит во время следующего теста, мутируя общее состояние и портя несвязанный ассерт. Сбой всплывает не в том тесте, и потому за ними так бесит гоняться. Детект механичен, не геройский: запускай с --detectOpenHandles (Jest) или следи за предупреждениями node --test об открытых дескрипторах / «не завершился», что указывают на таймер или сокет, ещё живой на выходе; запускай тесты в случайном порядке (--sequence.shuffle / --randomize), чтобы зависимость от порядка всплыла падением, а не везучим проходом; и относись к флаку как к багу — помести его в карантин (помечен пропущенным/известно-флакающим, вне критического пути) и чини корневую причину, а не заклеивай бюджетом ретраев, отмывающим реальную долю отказов в зелёную галочку.

Викторина

Тест проходит ~80% раз. Команда добавляет retries: 3, и CI зеленеет. Чего это реально добилось?

Вспомните перед уходом
  1. 01
    Почему откат транзакции на тест делает тесты быстрыми и изолированными и что он НЕ может протестировать?
  2. 02
    Как `retries: 3` превращает флакающий тест в продакшен-инцидент и что делать вместо этого?
Итог

Тест детерминирован, когда возвращает один и тот же вердикт в каждом прогоне, в любом порядке, на любой машине, — а флак есть потеря этого свойства, с ровно двумя корнями: общее изменяемое состояние (синглтон уровня модуля, общая строка БД, глобальные часы) и неуправляемый недетерминизм (Date.now, Math.random, UUID, таймзона, локаль, сон на стенных часах). Общее состояние плодит зависимость от порядка — тест B проходит лишь потому, что A отработал первым, — отчего зелёный целый набор краснеет, когда запускаешь один файл. Раннеры изолируют на границе файла воркерами-на-файл (изоляция процессов, герметична, но десятки мс на запуск каждого), тогда как тесты внутри одного файла делят контекст, где и живут протечки. Сеньорский ход — убрать неуправляемые входы у источника: зафиксировать часы фейковыми таймерами или внедрённым now(), посеять рандом или внедрить генератор id, заморозить CI через TZ=UTC и ждать настоящую работу вместо сна — а затем дёшево изолировать состояние сбросом в beforeEach или фикстурой отката транзакции (~1–3 мс), а не платить за изоляцию процессов везде. Для параллельных тестов БД выбирай стратегию по верности: откат на тест (~1–3 мс, но не тестит коммиты), truncate схема-на-воркер (~десятки мс, настоящие коммиты) или эфемерные контейнеры (~секунды, точный движок). И откажись от смертельнейшего антипаттерна: retries: 3 зеленит гонку 1 к 5 и хоронит реальную ~4%-ю долю отказов плюс неотожданный промис или протёкший дескриптор за ней, пока она не рванёт на продакшен-деплое — вместо этого рандомизируй порядок, используй --detectOpenHandles, карантинь и чини корневую причину. Теперь, когда увидишь, как коллега добавляет retries: 3 к падающему тесту, ты знаешь ровно, что этим скрывается — и знаешь два вопроса, которые надо задать: какой неуправляемый вход читает этот тест, и какое состояние он делит с соседями?

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.