Со временем каждый уважающий себя проект обрастает модульными и интеграционными тестами. В идеальном мире автотесты должны проходить быстро, чтобы их хотелось запускать как локально, так и в 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,
},
};
Заключение
Современные фреймворки для тестирования предоставляют удобную функциональность по работе с отложенными во времени операциями, благодаря чему появляется возможность значительно ускорить прохождение тестов, а, следовательно, повысить удовлетворенность и производительность разработчиков. Вдохните в ваши тесты новую жизнь за счет рассмотренных в статье практик!