Подходы к тестированию

Тестирование кода с timeago.js требует контроля над временем, изоляции от реального DOM и проверки поведения таймеров. Существует несколько подходов в зависимости от уровня тестирования.


Что именно нужно тестировать

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


Уровни тестирования

Уровень Что тестируется Инструмент
Unit Обёртки, утилиты, хуки Jest, Vitest
Интеграционный Компоненты с реальным DOM Testing Library
E2E Полный рендер в браузере Playwright, Cypress
Производительность Количество таймеров, скорость update performance.now(), DevTools

Настройка Jest для timeago.js

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  transform: {
    '^.+\\.tsx?$': 'ts-jest',
  },
  moduleNameMapper: {
    '^timeago.js$': '<rootDir>/__mocks__/timeago.js',
  },
};

Структура тест-файла

import { format } from '../src/utils/time';

describe('time utils', () => {
  describe('format', () => {
    it('форматирует дату в прошлом', () => { /* ... */ });
    it('форматирует дату в будущем', () => { /* ... */ });
    it('обрабатывает null', () => { /* ... */ });
  });

  describe('render lifecycle', () => {
    it('вызывает cancel при размонтировании', () => { /* ... */ });
    it('не утекает таймер при быстром размонтировании', () => { /* ... */ });
  });
});

Тестирование с заморозкой времени

Для стабильных снэпшотов время замораживается перед тестами и восстанавливается после:

describe('timeago format', () => {
  const NOW = new Date('2025-06-01T12:00:00Z').getTime();

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

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

  it('показывает "только что" для текущего времени', () => {
    const result = format(new Date(), 'ru');
    expect(result).toBe('только что');
  });

  it('показывает "5 минут назад" для времени 5 минут назад', () => {
    const fiveMinutesAgo = new Date(NOW - 5 * 60 * 1000);
    const result = format(fiveMinutesAgo, 'ru');
    expect(result).toContain('минут');
  });
});

Тестирование поведения обёрток

import { maybeFormat } from '../src/utils/timeago';

describe('maybeFormat', () => {
  it('возвращает null для null', () => {
    expect(maybeFormat(null)).toBeNull();
  });

  it('возвращает null для undefined', () => {
    expect(maybeFormat(undefined)).toBeNull();
  });

  it('возвращает null для некорректной строки даты', () => {
    expect(maybeFormat('not-a-date')).toBeNull();
  });

  it('возвращает строку для валидной даты', () => {
    expect(maybeFormat('2020-01-01')).toEqual(expect.any(String));
  });
});

Тестирование React компонентов

import { render, screen } from '@testing-library/react';
import { TimeAgo } from '../src/components/TimeAgo';

const FIXED_NOW = new Date('2025-06-01T12:00:00Z').getTime();

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

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

describe('TimeAgo component', () => {
  it('рендерит относительное время', () => {
    const date = new Date(FIXED_NOW - 2 * 60 * 1000); // 2 минуты назад
    render(<TimeAgo date={date} />);
    expect(screen.getByText(/минут/)).toBeInTheDocument();
  });

  it('обновляет текст при смене пропса date', () => {
    const date1 = new Date(FIXED_NOW - 60_000);
    const { rerender } = render(<TimeAgo date={date1} />);

    const date2 = new Date(FIXED_NOW - 120_000);
    rerender(<TimeAgo date={date2} />);
    // Компонент должен отображать новое значение
  });
});

Тестирование очистки таймеров

import { render, unmountComponentAtNode } from 'react-dom';
import { act } from 'react-dom/test-utils';

it('отменяет render при размонтировании', () => {
  const cancel = jest.fn();
  jest.mock('timeago.js', () => ({
    render:   jest.fn(),
    cancel,
    format:   jest.fn(() => 'mock'),
    register: jest.fn(),
  }));

  const container = document.createElement('div');
  document.body.appendChild(container);

  act(() => { render(<LiveTimeAgo date={new Date()} />, container); });
  act(() => { unmountComponentAtNode(container); });

  expect(cancel).toHaveBeenCalled();
});

Тестирование хуков

import { renderHook, act } from '@testing-library/react-hooks';
import { useTimeAgo } from '../src/hooks/useTimeAgo';

const FIXED = new Date('2025-06-01T12:00:00Z').getTime();

it('обновляет значение с интервалом', () => {
  jest.useFakeTimers();
  jest.setSystemTime(FIXED);

  const { result } = renderHook(() => useTimeAgo(new Date(FIXED - 30_000)));

  const firstValue = result.current;

  // Перемотать время вперёд на 60 секунд
  act(() => { jest.advanceTimersByTime(60_000); });

  // Значение должно обновиться (или оставаться тем же — проверяем тип)
  expect(typeof result.current).toBe('string');

  jest.useRealTimers();
});

Параметрические тесты

describe.each([
  [new Date('2025-01-01'),     'ru', expect.any(String)],
  [1_700_000_000_000,          'ru', expect.any(String)],
  ['2024-12-25T00:00:00Z',    'de', expect.any(String)],
])('format(%s, %s)', (date, locale, expected) => {
  it(`возвращает строку для ${locale}`, () => {
    expect(format(date, locale)).toEqual(expected);
  });
});

Snapshot тестирование

it('соответствует снэпшоту для конкретного момента', () => {
  jest.useFakeTimers();
  jest.setSystemTime(new Date('2025-06-01T12:00:00Z').getTime());

  const result = format(new Date('2025-05-31T12:00:00Z'), 'ru');
  expect(result).toMatchSnapshot();

  jest.useRealTimers();
});

Снэпшот фиксирует значение "1 день назад". При изменении поведения библиотеки тест упадёт.


Тестирование с разными локалями

const LOCALES: Array<[string, RegExp]> = [
  ['ru',    /назад|только что/],
  ['en_US', /ago|just now/],
  ['de',    /vor|gerade/],
];

describe.each(LOCALES)('locale %s', (locale, pattern) => {
  it(`возвращает строку, соответствующую паттерну`, () => {
    jest.setSystemTime(new Date('2025-06-01').getTime());
    const result = format(new Date('2025-05-30'), locale);
    expect(result).toMatch(pattern);
  });
});