Тестирование производительности

Тесты производительности проверяют, что использование timeago.js не создаёт узких мест: не накапливает таймеры, не замедляет начальный рендер, не вызывает избыточных обновлений DOM.


Что измеряется

Метрика Что означает
Время выполнения format Насколько быстро форматируется одна дата
Скорость пакетного обновления Сколько элементов/секунду можно обновить
Количество таймеров Не превышает ли число видимых элементов
Потребление памяти Нет ли утечек при монтировании/размонтировании
Reflow-операции Сколько пересчётов макета вызывает обновление

Benchmark: скорость format

import { format } from 'timeago.js';

function benchFormat(iterations = 10_000): number {
  const dates = Array.from({ length: iterations }, (_, i) =>
    new Date(Date.now() - i * 1000)
  );

  const start = performance.now();
  dates.forEach(d => format(d, 'ru'));
  return performance.now() - start;
}

it('format выполняет 10 000 вызовов за < 500 мс', () => {
  const elapsed = benchFormat(10_000);
  expect(elapsed).toBeLessThan(500);
});

Benchmark: средняя стоимость одного вызова

it('один вызов format занимает < 0.05 мс', () => {
  const iterations = 10_000;
  const dates = Array.from({ length: iterations }, (_, i) =>
    new Date(Date.now() - i * 1000)
  );

  const start = performance.now();
  dates.forEach(d => format(d, 'ru'));
  const elapsed = performance.now() - start;
  const perCall = elapsed / iterations;

  console.log(`Среднее: ${perCall.toFixed(4)} мс/вызов`);
  expect(perCall).toBeLessThan(0.05);
});

Тест утечки таймеров через WeakRef (Node.js >= 14.6)

it('render не накапливает таймеры при повторном вызове без cancel', () => {
  // Получить начальный счётчик таймеров
  const before = process._getActiveHandles().filter(h => h.constructor.name === 'Timeout').length;

  const els = Array.from({ length: 10 }, () => {
    const el = document.createElement('time');
    el.setAttribute('datetime', new Date(Date.now() - 60_000).toISOString());
    document.body.appendChild(el);
    return el;
  });

  render(els, 'ru');

  const after = process._getActiveHandles().filter(h => h.constructor.name === 'Timeout').length;

  // Количество таймеров не должно расти бесконечно
  expect(after - before).toBeLessThanOrEqual(els.length);

  cancel(els);
  els.forEach(el => document.body.removeChild(el));
});

Тест DOM операций

import { format } from 'timeago.js';

it('batch update не вызывает reflow при неизменившихся значениях', () => {
  jest.useFakeTimers();
  jest.setSystemTime(new Date('2025-06-01T12:00:00Z').getTime());

  const elements = Array.from({ length: 100 }, (_, i) => {
    const el = document.createElement('time');
    el.setAttribute('datetime', new Date(Date.now() - (i + 1) * 60_000).toISOString());
    el.textContent = format(el.getAttribute('datetime')!, 'ru');
    document.body.appendChild(el);
    return el;
  });

  let writeCount = 0;

  // Второй проход — значения не изменились
  elements.forEach(el => {
    const next = format(el.getAttribute('datetime')!, 'ru');
    if (el.textContent !== next) {
      el.textContent = next;
      writeCount++;
    }
  });

  expect(writeCount).toBe(0); // Ни одной лишней записи в DOM

  elements.forEach(el => document.body.removeChild(el));
  jest.useRealTimers();
});

Тест скорости enrichWithTimeAgo для большого массива

import { enrichWithTimeAgo } from '../src/utils/enrich';

it('enrichWithTimeAgo обрабатывает 1000 элементов за < 100 мс', () => {
  jest.useFakeTimers();
  jest.setSystemTime(Date.now());

  const items = Array.from({ length: 1000 }, (_, i) => ({
    id:        i,
    createdAt: new Date(Date.now() - i * 3600_000).toISOString(),
  }));

  const start = performance.now();
  const result = enrichWithTimeAgo(items);
  const elapsed = performance.now() - start;

  expect(result).toHaveLength(1000);
  expect(elapsed).toBeLessThan(100);

  jest.useRealTimers();
});

Тест скорости пакетной инициализации

it('render для 100 элементов инициализируется за < 50 мс', () => {
  const els = Array.from({ length: 100 }, (_, i) => {
    const el = document.createElement('time');
    el.setAttribute('datetime', new Date(Date.now() - i * 60_000).toISOString());
    document.body.appendChild(el);
    return el;
  });

  const start = performance.now();
  render(els, 'ru');
  const elapsed = performance.now() - start;

  expect(elapsed).toBeLessThan(50);

  cancel(els);
  els.forEach(el => document.body.removeChild(el));
});

Мониторинг количества таймеров

it('отменяет все таймеры после cancel', () => {
  let timerCount = 0;
  const originalSetTimeout = global.setTimeout;

  global.setTimeout = function (...args) {
    timerCount++;
    return originalSetTimeout(...args);
  } as typeof setTimeout;

  const el = document.createElement('time');
  el.setAttribute('datetime', new Date(Date.now() - 60_000).toISOString());
  document.body.appendChild(el);

  render(el, 'ru');

  const beforeCancel = timerCount;
  cancel(el);

  // После cancel таймеры перестают создаваться
  global.setTimeout = originalSetTimeout;

  document.body.removeChild(el);
  expect(beforeCancel).toBeGreaterThan(0); // Хотя бы один таймер был создан
});

Профилирование с User Timing API

it('format укладывается в performance mark', () => {
  const iterations = 1000;
  const dates = Array.from({ length: iterations }, (_, i) =>
    new Date(Date.now() - i * 60_000)
  );

  performance.mark('format-start');
  dates.forEach(d => format(d, 'ru'));
  performance.mark('format-end');

  performance.measure('format-total', 'format-start', 'format-end');
  const measures = performance.getEntriesByName('format-total');

  expect(measures[0].duration).toBeLessThan(200);

  performance.clearMarks();
  performance.clearMeasures();
});

Пороговые значения для CI

const PERF_THRESHOLDS = {
  formatSingle:     0.05,  // мс на один вызов
  formatBatch1000:  100,   // мс на 1000 вызовов
  renderInit100:    50,    // мс на render 100 элементов
};

// Используются в тестах как бюджеты производительности
// При превышении — тест падает, CI уведомляет команду