
Со временем каждый уважающий себя проект обрастает модульными и интеграционными тестами. В идеальном мире автотесты должны проходить быстро, чтобы их хотелось запускать как локально, так и в CI. Но почему в большинстве проектов запуск тестов отнимает критически много времени?
– Зачастую причиной является неправильная работа с асинхронными операциями. В статье разберемся, как Jest помогает писать молниеносные тесты, и рассмотрим ключевые сценарии.
Дисклеймер: В данной статье примеры рассматриваются с использованием фреймворка для тестирования Jest, но подобная функциональность присутствует и в других инструментах.
Корень проблемы
Перед тем как перейти к конкретным ошибкам в тестировании, определимся с целевыми сценариями:
Используются отложенные во времени операции.
Ограничивается частота или количество поступающих данных.
Использование асинхронности позволяет увеличить отзывчивость приложений, особенно в случаях, когда необходимо обращаться к удаленным ресурсам, выполнять длительные операции, ограничивать частоту и количество поступающих данных. Во всех этих сценариях мы используем либо явно, либо под капотом такие конструкции, как: setInterval и setTimeout, или опираемся на разницу во времени с помощью Date.now или performance.now.
Очень частый паттерн тестирования в данных сценариях – вызов jest.DoneCallback после expect. Но проблема данного подхода в том, что по умолчанию Jest ограничивает время выполнения теста 5 секундами. Это существенно увеличивает время прохождения тестов. Представьте, если таких тестов будут десятки или даже сотни!
it('should return status 200', (done: jest.DoneCallback) => { fetch('<your-api-url>') .then((res) => { expect(res.status).toBe(200); // уведомляем jest об успешном завершении теста done(); }) .catch(() => { // уведомляем jest об ошибке во время выполнения теста done.fail(); }); });
Пути решения
Jest предоставляет fake timers – функциональность по досрочному выполнению асинхронных операций, в том числе к определенному времени. Fake timers подразделяются на два типа:
legacy – признаны устаревшими, использовались по умолчанию до Jest 27
modern – появились в Jest 26, используются по умолчанию. Основаны на библиотеке @sinonjs/fake-timers, благодаря чему поддерживают
queueMicrotaskи имитируют поведениеDate(в отличие от legacy).
Fake timers включают такие синхронные методы, как:
advanceTimersByTime(x)– запускает все отложенные операции, которые должны были выполниться по истечении x миллисекунд.runOnlyPendingTimers()– запускает все запланированные отложенные во времени операции.runAllTimers()– запускает все отложенные во времени операции, даже если они были запланированы уже во время выполненияrunAllTimers().Так, если в тестируемом коде будетsetIntervalили рекурсивныйsetTimeout, мы никогда не дождемся завершенияrunAllTimers().
Сценарий I. Отложенные операции
Для примера возьмем функцию, которая показывает рекламный баннер через 15 секунд.
export function showPromotionBanner(text: string): void { setTimeout(() => alert(text), 15000); }
При использовании real timers тест будет выполняться свыше 15 секунд, так как Jest тратит процессорное время на запуск теста (обычно около 20 мс, но зависит от сложности кода и ресурсов компьютера). Это стоит учитывать при написании тестов и указывать большее допустимое время.
// Используем real timers const DELAY_MS: number = 15000; const TEST_TEXT: string = 'Hello!'; describe('show-promotion-banner', () => { let alertSpy: jest.SpyInstance; beforeEach(() => { alertSpy = jest.spyOn(window, 'alert').mockImplementation(); }); it( 'should call alert after 15 seconds', (done: jest.DoneCallback) => { showPromotionBanner('Hello!'); expect(alertSpy).not.toHaveBeenCalled(); setTimeout(() => { expect(alertSpy).toHaveBeenCalledWith(TEST_TEXT); // тест завершен done(); }, DELAY_MS); }, DELAY_MS + 20 ); });
При использовании fake timers:
абстрагируемся от ресурсов компьютера,
отсутствует лишняя вложенность в коде,
тест выполняется критически быстро.
// Используем fake timers const DELAY_MS: number = 15000; const TEST_TEXT: string = 'Hello!'; describe('show-delayed-banner', () => { let alertSpy: jest.SpyInstance; beforeEach(() => { jest.useFakeTimers(); alertSpy = jest.spyOn(window, 'alert').mockImplementation(); }); it('should call alert after 15 seconds', () => { showPromotionBanner('Hello!'); expect(alertSpy).not.toHaveBeenCalled(); // "перематываем" время на 15 секунд вперед jest.advanceTimersByTime(DELAY_MS); expect(alertSpy).toHaveBeenCalledWith(TEST_TEXT); }); });
Ниже приведены отчеты Jest о выполнении тестов с использованием разных таймеров. Разница во времени составила около 15 секунд!


Сценарий II. Debounce
При разработке программных продуктов приходится работать с часто изменяемыми источниками данных. Примером может послужить пользовательский ввод, ResizeObserver, Scroll Events и подобное. В таких случаях часть поступающих значений отбрасывают, чтобы реже выполнять логику, особенно если она тяжеловесная или задействована по всему приложению. Существует много способов подобной фильтрации данных, но поскольку в этой статье речь идет о манипуляциях со временем, давайте рассмотрим распространенный подход с использованием debounce. У этого метода есть множество реализаций в различных библиотеках: lodash, rxjs и другие. Debounce откладывает вызов функции до тех пор, пока не истечет указанное время с момента последнего вызова функции. Ниже приведена его базовая реализация.
type BaseFunction = (...args: unknown[] | []) => unknown; export function debounce<T extends BaseFunction>( callback: T, timeout: number ): (...args: Parameters<T>) => void { let timerId: number; return (...args: Parameters<T>): void => { clearTimeout(timerId); timerId = setTimeout(() => callback(...args), timeout); }; }
Тестироваться будет функция, которая синхронизирует положение скроллбара с LocalStorage. Пока пользователь прокручивает страницу – ничего не происходит. Как только проходит 500 мс после остановки прокручивания, данные записываются в хранилище.
import { debounce } from './debounce'; const STORAGE_KEY: string = 'SCROLL'; const DELAY_MS: number = 500; // тестируемая функция export function syncScrollPositionWithStorage(): VoidFunction { const listener = debounce(saveScrollPosition, DELAY_MS); window.addEventListener('scroll', listener); return () => window.removeEventListener('scroll', listener); } function saveScrollPosition(): void { localStorage.setItem( STORAGE_KEY, JSON.stringify({ x: window.scrollX, y: window.scrollY }) ); }
Ниже приведены тесты на данную функциональность с использованием real-timers и fake-timers.
В тестах с real timers постоянно приходится думать о времени исполнения теста. Например, в данном тесте при изменении задержки в setTimeout (15-17 строчки) – необходимо будет изменить доступное тесту время (32 строка).
// Используем real timers const SYNC_DELAY_MS: number = 500; const STORAGE_KEY: string = 'SCROLL'; describe('sync-scroll-position', () => { let setItemSpy: jest.SpyInstance; beforeEach(() => { setItemSpy = jest.spyOn(window.localStorage.__proto__, 'setItem'); }); it('should call setItem with last position with debounce time', (done) => { syncScrollPositionWithStorage(); setTimeout(() => scrollTo(11, 111), 50); setTimeout(() => scrollTo(22, 222), 100); setTimeout(() => scrollTo(33, 333), 250); setTimeout(() => { const [KEY, VALUE]: [string, string] = setItemSpy.mock.calls[0]; expect(setItemSpy).toHaveBeenCalledTimes(1); expect(KEY).toBe(STORAGE_KEY); expect(JSON.parse(VALUE)).toEqual({ x: 33, y: 333, }); // тест завершен done(); }, SYNC_DELAY_MS + 250 + 20); }); });
В тестах с fake timers от времени можно абстрагироваться. В следующем примере можно было бы использовать jest.runAllTimers(), чтобы дождаться завершения всех отложенных операций, но был выбран jest.advanceTimersByTime() для более точного тестирования времени до синхронизации.
const SYNC_DELAY_MS: number = 500; const STORAGE_KEY: string = 'SCROLL'; describe('sync-scroll-position', () => { let setItemSpy: jest.SpyInstance; beforeEach(() => { jest.useFakeTimers(); setItemSpy = jest.spyOn(window.localStorage.__proto__, 'setItem'); }); it('should call setItem with last position with debounce time', () => { syncScrollPositionWithStorage(); setTimeout(() => scrollTo(11, 111), 50); setTimeout(() => scrollTo(22, 222), 100); setTimeout(() => scrollTo(33, 333), 250); jest.advanceTimersByTime(SYNC_DELAY_MS + 250); const [KEY, VALUE]: [string, string] = setItemSpy.mock.calls[0]; expect(setItemSpy).toHaveBeenCalledTimes(1); expect(KEY).toBe(STORAGE_KEY); expect(JSON.parse(VALUE)).toEqual({ x: 33, y: 333, }); }); });
Ниже приведены отчеты Jest о выполнении тестов с использованием разных таймеров. Разница во времени составила ~770 мс.


Вам может показаться, что разница в 770 мс не очень заметна, но в реальности на каждый блок кода с debounce зачастую требуется больше одного теста.
В одном из проектов (~400k строк) было найдено 219 мест применения debounce. В 179 случаях debounce использовался со средним временем задержки – 0.3 секунды. Таким образом, если каждый блок с debounce будет покрыт хотя бы 3 тестами, время тестирования составит 179 * 3 * 0.3 = 161.1 секунды! Прибавим к этому числу другие операции с подобным временем задержки, и рассчитанное значение увеличится многократно. Вот почему использование fake timers необходимо вашему проекту.
Как внедрить fake timers в проект
Если в проекте существует большое количество тестов, использующих real timers, переезд на новые таймеры целиком займет довольно много времени. Рациональное решение в данной ситуации – постепенная миграция.
Jest позволяет включать fake timers локально. Для этого вызовем метод jest.useFakeTimers() перед выполнением необходимого набора тестов, и jest.useRealTimers() после. За счет такого подхода можно внедрять fake timers на уровне блока кода. Если же вы переводите весь тестовый файл на fake timers, то вам достаточно будет добавить опцию jest.useFakeTimers() в начале файла.
beforeAll(() => jest.useFakeTimers()); // здесь ваши тесты afterAll(() => jest.useRealTimers());
Когда большинство тестов будет использовать fake timers – включим их глобально в файле jest.config.js.
module.exports = { // ... fakeTimers: { enableGlobally: true, }, };
Заключение
Современные фреймворки для тестирования предоставляют удобную функциональность по работе с отложенными во времени операциями, благодаря чему появляется возможность значительно ускорить прохождение тестов, а, следовательно, повысить удовлетворенность и производительность разработчиков. Вдохните в ваши тесты новую жизнь за счет рассмотренных в статье практик!
