Unit тесты

Unit тесты проверяют отдельные функции и обёртки в изоляции. При работе с timeago.js это означает тестирование утилит форматирования, обработки крайних случаев и пользовательских локалей.


Тестирование базовой обёртки format

// src/utils/timeago.ts
import { format } from 'timeago.js';

export function formatDate(date: Date | string | number | null, locale = 'ru'): string {
  if (!date) return '';
  const d = new Date(date as any);
  if (isNaN(d.getTime())) return '';
  return format(d, locale);
}
// src/utils/timeago.test.ts
import { formatDate } from './timeago';

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

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

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

describe('formatDate', () => {
  it('возвращает пустую строку для null', () => {
    expect(formatDate(null)).toBe('');
  });

  it('возвращает пустую строку для некорректной даты', () => {
    expect(formatDate('not-a-date')).toBe('');
  });

  it('принимает Date объект', () => {
    expect(formatDate(new Date(FIXED - 60_000))).toMatch(/минут/);
  });

  it('принимает timestamp', () => {
    expect(formatDate(FIXED - 3600_000)).toMatch(/час/);
  });

  it('принимает ISO строку', () => {
    expect(formatDate('2025-06-01T11:00:00Z')).toMatch(/час/);
  });

  it('использует locale ru по умолчанию', () => {
    const result = formatDate(new Date(FIXED - 60_000));
    expect(result).toMatch(/назад|только что/);
  });
});

Тестирование Result обёртки

// src/utils/tryFormat.ts
import { format } from 'timeago.js';

type Ok  = { ok: true;  value: string };
type Err = { ok: false; error: string };

export function tryFormat(date: unknown, locale = 'ru'): Ok | Err {
  if (date == null || date === '') {
    return { ok: false, error: 'Empty date' };
  }
  const d = new Date(date as any);
  if (isNaN(d.getTime())) {
    return { ok: false, error: `Invalid date: ${date}` };
  }
  return { ok: true, value: format(d, locale) };
}
// src/utils/tryFormat.test.ts
import { tryFormat } from './tryFormat';

describe('tryFormat', () => {
  it('возвращает ok: false для null', () => {
    const result = tryFormat(null);
    expect(result.ok).toBe(false);
  });

  it('возвращает ok: false для undefined', () => {
    expect(tryFormat(undefined).ok).toBe(false);
  });

  it('возвращает ok: false для пустой строки', () => {
    expect(tryFormat('').ok).toBe(false);
  });

  it('возвращает ok: false для невалидной даты', () => {
    const result = tryFormat('abc');
    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.error).toContain('abc');
    }
  });

  it('возвращает ok: true для валидной даты', () => {
    jest.useFakeTimers();
    jest.setSystemTime(new Date('2025-06-01').getTime());
    const result = tryFormat('2025-05-31');
    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(typeof result.value).toBe('string');
    }
    jest.useRealTimers();
  });
});

Тестирование enrichWithTimeAgo

// src/utils/enrich.ts
import { format } from 'timeago.js';

export function enrichWithTimeAgo<T extends { createdAt: string }>(
  items: T[],
  locale = 'ru'
): Array<T & { timeAgo: string }> {
  return items.map(item => ({
    ...item,
    timeAgo: format(item.createdAt, locale),
  }));
}
// src/utils/enrich.test.ts
import { enrichWithTimeAgo } from './enrich';

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

    const items = [
      { id: 1, createdAt: '2025-06-01T11:00:00Z' },
      { id: 2, createdAt: '2025-06-01T10:00:00Z' },
    ];

    const result = enrichWithTimeAgo(items);

    expect(result).toHaveLength(2);
    expect(typeof result[0].timeAgo).toBe('string');
    expect(typeof result[1].timeAgo).toBe('string');
    expect(result[0].id).toBe(1); // Исходные поля сохранены

    jest.useRealTimers();
  });

  it('возвращает пустой массив для пустого ввода', () => {
    expect(enrichWithTimeAgo([])).toEqual([]);
  });
});

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

// src/locales/custom.ts
import { register } from 'timeago.js';

const FORMS: [string, string][] = [
  ['только что',       'сейчас'],
  ['%s секунду назад', 'через %s секунду'],
  ['%s секунды назад', 'через %s секунды'],
  ['%s секунд назад',  'через %s секунд'],
  ['минуту назад',     'через минуту'],
  ['%s минуты назад',  'через %s минуты'],
  ['%s минут назад',   'через %s минут'],
  ['час назад',        'через час'],
  ['%s часа назад',    'через %s часа'],
  ['%s часов назад',   'через %s часов'],
  ['день назад',       'через день'],
  ['%s дня назад',     'через %s дня'],
  ['%s дней назад',    'через %s дней'],
  ['год назад',        'через год'],
  ['%s лет назад',     'через %s лет'],
];

export const customLocale = (n: number, i: number): [string, string] => FORMS[i];
register('custom', customLocale);
// src/locales/custom.test.ts
import { customLocale } from './custom';

describe('customLocale', () => {
  it('возвращает кортеж для каждого индекса от 0 до 14', () => {
    for (let i = 0; i <= 14; i++) {
      const result = customLocale(1, i);
      expect(Array.isArray(result)).toBe(true);
      expect(result).toHaveLength(2);
      expect(typeof result[0]).toBe('string');
      expect(typeof result[1]).toBe('string');
    }
  });

  it('индекс 0 — "только что"', () => {
    expect(customLocale(1, 0)[0]).toBe('только что');
  });

  it('индекс 4 — "минуту назад"', () => {
    expect(customLocale(1, 4)[0]).toBe('минуту назад');
  });
});

Тестирование isValidDate хелпера

// src/utils/date.ts
export function isValidDate(value: unknown): value is Date | string | number {
  if (value == null) return false;
  const d = new Date(value as any);
  return !isNaN(d.getTime());
}
// src/utils/date.test.ts
import { isValidDate } from './date';

describe('isValidDate', () => {
  const valid = [
    new Date(),
    Date.now(),
    '2025-01-01',
    '2025-06-01T12:00:00Z',
    0,
    -1000,
  ];

  const invalid = [
    null,
    undefined,
    '',
    'not-a-date',
    'Jan 32',
    NaN,
    {},
    [],
  ];

  it.each(valid)('принимает %s как валидную дату', (value) => {
    expect(isValidDate(value)).toBe(true);
  });

  it.each(invalid)('отклоняет %s как невалидную дату', (value) => {
    expect(isValidDate(value)).toBe(false);
  });
});

Тестирование нормализации дат

// src/utils/normalizeDate.ts
export function normalizeDate(str: string): string {
  if (!str.includes('T') && !str.includes('Z') && !str.includes('+')) {
    return str + 'T00:00:00Z';
  }
  if (str.includes('T') && !str.endsWith('Z') && !str.includes('+')) {
    return str + 'Z';
  }
  return str;
}
// src/utils/normalizeDate.test.ts
import { normalizeDate } from './normalizeDate';

describe('normalizeDate', () => {
  it('добавляет T00:00:00Z к строке только с датой', () => {
    expect(normalizeDate('2025-06-01')).toBe('2025-06-01T00:00:00Z');
  });

  it('добавляет Z к строке с датой и временем без часового пояса', () => {
    expect(normalizeDate('2025-06-01T12:00:00')).toBe('2025-06-01T12:00:00Z');
  });

  it('не изменяет строку с Z', () => {
    expect(normalizeDate('2025-06-01T12:00:00Z')).toBe('2025-06-01T12:00:00Z');
  });

  it('не изменяет строку с явным offset', () => {
    expect(normalizeDate('2025-06-01T12:00:00+03:00')).toBe('2025-06-01T12:00:00+03:00');
  });
});

Покрытие unit тестами: что тестировать

Функция Что покрыть
Обёртка format null, undefined, некорректная дата, корректная дата
Пользовательская локаль Все индексы 0-14, корректный формат кортежа
Валидатор дат Граничные значения, типы, NaN
Result обёртка ok: true и ok: false ветки
enrich функции Сохранение исходных полей, добавление нового поля