Со временем каждый уважающий себя проект обрастает модульными и интеграционными тестами. В идеальном мире автотесты должны проходить быстро, чтобы их хотелось запускать как локально, так и в CI. Но почему в большинстве проектов запуск тестов отнимает критически много времени?

– Зачастую причиной является неправильная работа с асинхронными операциями. В статье разберемся, как Jest помогает писать молниеносные тесты, и рассмотрим ключевые сценарии.

Дисклеймер: В данной статье примеры рассматриваются с использованием фреймворка для тестирования Jest, но подобная функциональность присутствует и в других инструментах.

Корень проблемы

Перед тем как перейти к конкретным ошибкам в тестировании, определимся с целевыми сценариями:

  1. Используются отложенные во времени операции.

  2. Ограничивается частота или количество поступающих данных.

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

Время выполнения теста с real timers - 15010 мс
Время выполнения теста с real timers - 15010 мс
Время выполнения теста с fake timers - 3 мс
Время выполнения теста с fake timers - 3 мс

Сценарий II. Debounce

При разработке программных продуктов приходится работать с часто изменяемыми источниками данных. Примером может послужить пользовательский ввод, ResizeObserver, Scroll Events и подобное. В таких случаях часть поступающих значений отбрасывают, чтобы реже выполнять логику, особенно если она тяжеловесная или задействована по всему приложению. Существует много способов подобной фильтрации данных, но поскольку в этой статье речь идет о манипуляциях со временем, давайте рассмотрим распространенный подход с использованием debounce. У этого метода есть множество реализаций в различных библиотеках: lodashrxjs и другие. 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 мс.

Время выполнения теста с real timers - 775 мс
Время выполнения теста с real timers - 775 мс
Время выполнения теста с fake timers - 4 мс
Время выполнения теста с fake timers - 4 мс

Вам может показаться, что разница в 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 – добавим строчку "timers": "modern" в файле jest.config.js.

Заключение

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

Комментарии (0)