Back

Работа со временем в тестах: надёжные паттерны для асинхронности и задержек

Работа со временем в тестах: надёжные паттерны для асинхронности и задержек

Время — это скрытый убийца надёжности тестов. Тест, который проходит локально, падает в CI. Набор тестов, который выполнялся за секунды, теперь занимает минуты. Компонент, который работает идеально, показывает фантомные ошибки, потому что таймеры и промисы не взаимодействуют должным образом.

Эта статья охватывает современные паттерны тестирования асинхронного времени и лучшие практики работы с фейковыми таймерами для Jest, Vitest, React Testing Library, Playwright и Cypress — с фокусом на то, что действительно работает в 2025 году.

Ключевые выводы

  • Используйте асинхронные API таймеров (advanceTimersByTimeAsync) вместо синхронных вариантов, чтобы предотвратить взаимоблокировки промисов и таймеров
  • Настраивайте автоматическое продвижение таймеров при комбинировании фейковых таймеров с асинхронными утилитами Testing Library
  • Всегда очищайте фейковые таймеры в afterEach, чтобы предотвратить загрязнение тестов и каскадные сбои
  • В E2E-тестах ожидайте конкретных условий, а не используйте произвольные задержки вроде waitForTimeout или cy.wait(ms)

Почему время ломает тесты

Тесты падают из-за времени по трём причинам: реальные задержки замедляют выполнение, вариации времени вызывают нестабильность, а фейковые таймеры конфликтуют с промисами.

Цикл событий JavaScript обрабатывает макрозадачи (setTimeout, setInterval) и микрозадачи (колбэки Promise) в определённом порядке. Фейковые таймеры перехватывают макрозадачи, но не обрабатывают микрозадачи автоматически. Это несоответствие вызывает большинство сбоев тестов, связанных с таймерами.

Асинхронные таймеры Jest: современный подход

Текущая реализация фейковых таймеров Jest использует @sinonjs/fake-timers под капотом. Ключ к надёжному асинхронному тестированию — понимание того, какие API использовать вместе.

beforeEach(() => {
  jest.useFakeTimers();
});

afterEach(() => {
  jest.useRealTimers();
});

test('debounced search triggers after delay', async () => {
  render(<SearchInput />);
  await userEvent.type(screen.getByRole('textbox'), 'query');
  
  await jest.advanceTimersByTimeAsync(300);
  
  expect(mockSearch).toHaveBeenCalledWith('query');
});

Критическая деталь: используйте advanceTimersByTimeAsync вместо advanceTimersByTime. Асинхронный вариант очищает микрозадачи между выполнениями таймеров, предотвращая взаимоблокировку промисов и таймеров, которая вызывает зависание тестов.

Для анимаций Jest предоставляет jest.advanceTimersToNextFrame() для пошагового выполнения колбэков requestAnimationFrame без ожидания реального времени кадров.

Фейковые таймеры Vitest: интеграция с Sinon

Vitest использует ту же основу @sinonjs/fake-timers, что делает фейковые таймеры Vitest похожими на Jest с небольшими различиями в API.

import { vi } from 'vitest';

beforeEach(() => {
  vi.useFakeTimers();
});

afterEach(() => {
  vi.useRealTimers();
});

test('toast disappears after timeout', async () => {
  render(<Toast message="Saved" />);
  
  await vi.advanceTimersByTimeAsync(3000);
  
  expect(screen.queryByText('Saved')).not.toBeInTheDocument();
});

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

Координация с React Testing Library

Взаимодействие между фейковыми таймерами и waitFor из Testing Library часто вызывает путаницу. Когда фейковые таймеры активны, waitFor опрашивает, но время не продвигается — создавая бесконечные циклы.

Решение: настроить фейковые таймеры для автоматического продвижения во время асинхронных ожиданий.

jest.useFakeTimers({ advanceTimers: true });

Это указывает Jest продвигать таймеры, когда промисы находятся в ожидании, позволяя waitFor и findBy запросам работать естественно с фейковыми таймерами.

Для user-event убедитесь, что используете актуальный API. Библиотека управляет своим собственным временем внутренне и координируется с фейковыми таймерами при правильной настройке.

Управление временем в E2E: Playwright и Cypress

API page.clock в Playwright обеспечивает точное управление временем в контекстах браузера:

await page.clock.install({ time: new Date('2025-01-15') });
await page.clock.fastForward(5000);

Cypress предлагает cy.clock() и cy.tick() для аналогичного управления. Оба подхода предпочтительнее waitForTimeout или cy.wait(ms), которые вводят реальные задержки и нестабильность.

Распространённые ловушки, вызывающие нестабильность

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

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

Порядок микрозадач/макрозадач: код, который смешивает setTimeout с Promise.resolve(), ведёт себя по-разному под фейковыми таймерами. Используйте асинхронные API таймеров последовательно.

Соображения по Temporal API: более новые кодовые базы, использующие Temporal вместо Date, требуют отдельных стратегий мокирования. Абстрагируйте источник времени, чтобы сделать тесты независимыми от фреймворка.

Заключение

Предпочитайте асинхронные API таймеров (advanceTimersByTimeAsync) синхронным вариантам. Настраивайте автоматическое продвижение при использовании асинхронных утилит Testing Library. Избегайте произвольных задержек и waitForTimeout в E2E-тестах — вместо этого ожидайте конкретных условий. Всегда очищайте фейковые таймеры, чтобы предотвратить загрязнение тестов.

Цель — тесты, которые выполняются быстро, проходят стабильно и не кодируют детали реализации времени. Автоматически продвигающиеся фейковые таймеры обеспечивают большую часть пути. Для всего остального явная асинхронная координация побеждает произвольные задержки каждый раз.

Часто задаваемые вопросы

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

Настройте Jest с включённой опцией advanceTimers, вызвав jest.useFakeTimers({ advanceTimers: true }). Это указывает Jest автоматически продвигать время, когда промисы находятся в ожидании, позволяя waitFor и findBy запросам корректно опрашивать без создания бесконечных циклов.

Используйте фейковые таймеры через page.clock в Playwright или cy.clock и cy.tick в Cypress. Реальные задержки с waitForTimeout или cy.wait вносят нестабильность, потому что скорость выполнения варьируется в разных окружениях. Вместо этого ожидайте конкретных условий UI или используйте контролируемое продвижение времени.

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

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay