Юнит-тесты для форматтеров

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

Библиотека Globalize активно использует данные CLDR и предоставляет API для создания форматтеров. Поскольку форматтеры являются функциями, их удобно покрывать юнит-тестами.

Тестирование форматтеров решает несколько задач:

  • проверка корректности локализации;
  • защита от регрессий;
  • контроль изменений CLDR-данных;
  • проверка пользовательских форматов;
  • контроль edge-case сценариев;
  • обеспечение стабильности SSR и клиентского рендеринга.

Установка тестового окружения

Для тестирования удобно использовать:

  • Jest;
  • Vitest;
  • Mocha;
  • Chai.

Пример установки Jest:

npm install --save-dev jest

Установка Globalize и CLDR:

npm install globalize cldr-data

Базовая структура тестов

Типичная структура:

project/
├── src/
├── tests/
│   ├── number.test.js
│   ├── currency.test.js
│   ├── date.test.js
│   └── message.test.js
└── jest.config.js

Подготовка Globalize для тестов

Перед запуском тестов необходимо загрузить CLDR-данные.

Общий setup-файл

// tests/setupGlobalize.js

const Globalize = require("globalize");

Globalize.load(
  require("cldr-data/supplemental/likelySubtags.json"),
  require("cldr-data/main/en/numbers.json"),
  require("cldr-data/main/en/ca-gregorian.json"),
  require("cldr-data/main/en/currencies.json"),
  require("cldr-data/main/ru/numbers.json"),
  require("cldr-data/main/ru/ca-gregorian.json"),
  require("cldr-data/main/ru/currencies.json"),
  require("cldr-data/supplemental/numberingSystems.json")
);

Globalize.loadTimeZone(require("iana-tz-data"));

module.exports = Globalize;

Тестирование форматирования чисел

Проверка локализованных разделителей

const Globalize = require("./setupGlobalize");

describe("Number formatting", () => {
  test("formats number in English locale", () => {
    const g = new Globalize("en");

    const formatter = g.numberFormatter();

    expect(formatter(1234567.89))
      .toBe("1,234,567.89");
  });

  test("formats number in Russian locale", () => {
    const g = new Globalize("ru");

    const formatter = g.numberFormatter();

    expect(formatter(1234567.89))
      .toBe("1 234 567,89");
  });
});

Проверка округления

Globalize использует правила ICU/CLDR, поэтому округление следует тестировать отдельно.

test("rounds number correctly", () => {
  const g = new Globalize("en");

  const formatter = g.numberFormatter({
    minimumFractionDigits: 2,
    maximumFractionDigits: 2
  });

  expect(formatter(1.236)).toBe("1.24");
});

Проверка отрицательных чисел

test("formats negative numbers", () => {
  const g = new Globalize("en");

  const formatter = g.numberFormatter();

  expect(formatter(-5000)).toBe("-5,000");
});

Тестирование процентного форматирования

test("formats percent values", () => {
  const g = new Globalize("en");

  const formatter = g.numberFormatter({
    style: "percent"
  });

  expect(formatter(0.25)).toBe("25%");
});

Тестирование валют

Форматирование валют особенно чувствительно к локали.

Проверка символа валюты

test("formats USD currency", () => {
  const g = new Globalize("en");

  const formatter = g.currencyFormatter("USD");

  expect(formatter(100))
    .toBe("$100.00");
});

Проверка валютной локали

test("formats RUB currency in Russian locale", () => {
  const g = new Globalize("ru");

  const formatter = g.currencyFormatter("RUB");

  expect(formatter(1500))
    .toBe("1 500,00 ₽");
});

Проверка нестандартных валютных параметров

test("formats currency without decimals", () => {
  const g = new Globalize("en");

  const formatter = g.currencyFormatter("USD", {
    minimumFractionDigits: 0,
    maximumFractionDigits: 0
  });

  expect(formatter(500))
    .toBe("$500");
});

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

Форматирование дат зависит от:

  • календаря;
  • локали;
  • временной зоны;
  • шаблона форматирования.

Проверка короткого формата даты

test("formats short date", () => {
  const g = new Globalize("en");

  const formatter = g.dateFormatter({
    datetime: "short"
  });

  const result = formatter(new Date(2025, 4, 20));

  expect(result).toContain("5/");
});

Проверка русской локали

test("formats Russian date", () => {
  const g = new Globalize("ru");

  const formatter = g.dateFormatter({
    date: "medium"
  });

  const result = formatter(new Date(2025, 0, 15));

  expect(result).toContain("2025");
});

Тестирование временных зон

Ошибки временных зон являются одной из самых частых проблем.

test("formats UTC date", () => {
  const g = new Globalize("en");

  const formatter = g.dateFormatter({
    datetime: "medium",
    timeZone: "UTC"
  });

  const date = new Date(Date.UTC(2025, 0, 1, 12, 0));

  const result = formatter(date);

  expect(result).toContain("12:00");
});

Проверка пользовательских шаблонов

test("formats custom date pattern", () => {
  const g = new Globalize("en");

  const formatter = g.dateFormatter({
    raw: "yyyy-MM-dd"
  });

  expect(
    formatter(new Date(2025, 6, 10))
  ).toBe("2025-07-10");
});

Тестирование plural rules

Globalize поддерживает множественные формы.

test("selects plural category", () => {
  const g = new Globalize("en");

  const plural = g.pluralGenerator();

  expect(plural(1)).toBe("one");
  expect(plural(5)).toBe("other");
});

Тестирование plural rules для русского языка

Русский язык содержит сложные правила множественного числа.

test("handles Russian plurals", () => {
  const g = new Globalize("ru");

  const plural = g.pluralGenerator();

  expect(plural(1)).toBe("one");
  expect(plural(2)).toBe("few");
  expect(plural(5)).toBe("many");
});

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

MessageFormatter часто используется совместно с ICU MessageFormat.

Загрузка сообщений

Globalize.loadMessages({
  en: {
    hello: "Hello, {name}"
  }
});

Проверка форматирования сообщения

test("formats localized message", () => {
  const g = new Globalize("en");

  const formatter = g.messageFormatter("hello");

  expect(
    formatter({ name: "John" })
  ).toBe("Hello, John");
});

Тестирование plural messages

Globalize.loadMessages({
  en: {
    items:
      "{count, plural, one {# item} other {# items}}"
  }
});
test("formats pluralized message", () => {
  const g = new Globalize("en");

  const formatter = g.messageFormatter("items");

  expect(formatter({ count: 1 }))
    .toBe("1 item");

  expect(formatter({ count: 5 }))
    .toBe("5 items");
});

Тестирование fallback-локалей

Некоторые приложения используют fallback-механизм.

test("falls back to English locale", () => {
  const g = new Globalize("fr");

  const formatter = g.numberFormatter();

  expect(formatter(1000))
    .toContain("1");
});

Проверка отсутствующих сообщений

test("throws for missing message", () => {
  const g = new Globalize("en");

  expect(() => {
    g.messageFormatter("missing");
  }).toThrow();
});

Проверка некорректных локалей

test("throws on invalid locale", () => {
  expect(() => {
    new Globalize("invalid-locale");
  }).toThrow();
});

Тестирование edge cases

Null и undefined

test("handles null safely", () => {
  const g = new Globalize("en");

  const formatter = g.numberFormatter();

  expect(() => formatter(null))
    .not.toThrow();
});

Проверка NaN

test("formats NaN", () => {
  const g = new Globalize("en");

  const formatter = g.numberFormatter();

  expect(formatter(NaN))
    .toContain("NaN");
});

Проверка Infinity

test("formats Infinity", () => {
  const g = new Globalize("en");

  const formatter = g.numberFormatter();

  expect(formatter(Infinity))
    .toContain("∞");
});

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

Snapshot-тесты полезны для контроля форматирования.

test("matches number snapshot", () => {
  const g = new Globalize("ru");

  const formatter = g.numberFormatter();

  expect(
    formatter(123456.78)
  ).toMatchSnapshot();
});

Первый запуск создаёт snapshot:

exports[`matches number snapshot 1`] =
  `"123 456,78"`;

Проблемы snapshot-тестов

Snapshot-тесты для локализации требуют осторожности:

  • изменения CLDR могут ломать snapshots;
  • разные версии ICU дают разные результаты;
  • пробелы и Unicode-символы отличаются между платформами;
  • форматирование может меняться после обновления Node.js.

По этой причине snapshot-тесты рекомендуется использовать только для стабильных сценариев.


Нормализация Unicode в тестах

Некоторые локали используют специальные Unicode-пробелы.

Например:

  • non-breaking space;
  • narrow no-break space;
  • thin space.

Для избежания ложных падений тестов применяют нормализацию.

function normalize(str) {
  return str.replace(/\s/g, " ");
}

Использование:

expect(
  normalize(formatter(1000))
).toBe("1 000");

Изоляция CLDR-данных

При больших тестовых наборах загрузка полного CLDR замедляет тестирование.

Оптимизация:

Globalize.load(
  require("cldr-data/main/en/numbers.json")
);

Тесты должны загружать только необходимые данные.


Моки в тестировании форматтеров

Иногда форматтеры оборачиваются собственными сервисами.

Пример:

export function formatPrice(value) {
  const g = new Globalize("en");

  return g.currencyFormatter("USD")(value);
}

Тест:

test("formats price correctly", () => {
  expect(formatPrice(50))
    .toBe("$50.00");
});

Мокирование Globalize

При необходимости можно подменять методы.

jest.mock("globalize", () => {
  return jest.fn().mockImplementation(() => ({
    numberFormatter: () => value => `mock:${value}`
  }));
});

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

Иногда важно контролировать скорость создания форматтеров.

Антипаттерн

function renderPrice(price) {
  const g = new Globalize("en");

  return g.currencyFormatter("USD")(price);
}

Форматтер создаётся при каждом вызове.


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

test("reuses formatter", () => {
  const g = new Globalize("en");

  const formatter = g.currencyFormatter("USD");

  const start = performance.now();

  for (let i = 0; i < 10000; i++) {
    formatter(i);
  }

  const end = performance.now();

  expect(end - start).toBeLessThan(100);
});

Проверка кэширования

Часто используется memoization.

const cache = new Map();

function getFormatter(locale) {
  if (!cache.has(locale)) {
    cache.set(
      locale,
      new Globalize(locale).numberFormatter()
    );
  }

  return cache.get(locale);
}

Тест:

test("returns cached formatter", () => {
  const a = getFormatter("en");
  const b = getFormatter("en");

  expect(a).toBe(b);
});

Параметризованные тесты

Jest поддерживает table-driven testing.

describe.each([
  ["en", "1,000.5"],
  ["ru", "1 000,5"]
])("locale %s", (locale, expected) => {
  test("formats correctly", () => {
    const g = new Globalize(locale);

    const formatter = g.numberFormatter();

    expect(formatter(1000.5))
      .toBe(expected);
  });
});

Тестирование нескольких валют

describe.each([
  ["USD", "$10.00"],
  ["EUR", "€10.00"]
])("currency %s", (currency, expected) => {
  test("formats currency", () => {
    const g = new Globalize("en");

    const formatter =
      g.currencyFormatter(currency);

    expect(formatter(10))
      .toBe(expected);
  });
});

Тестирование пользовательских обёрток

Во многих приложениях Globalize скрывается за abstraction layer.

export class I18nService {
  constructor(locale) {
    this.g = new Globalize(locale);
  }

  formatMoney(value) {
    return this.g
      .currencyFormatter("USD")(value);
  }
}

Тест:

test("formats money via service", () => {
  const service = new I18nService("en");

  expect(service.formatMoney(99))
    .toBe("$99.00");
});

Регрессионные тесты

Регрессионные тесты фиксируют ранее найденные ошибки.

test("does not duplicate currency symbol", () => {
  const g = new Globalize("en");

  const formatter =
    g.currencyFormatter("USD");

  expect(formatter(10))
    .not.toBe("$$10.00");
});

Тестирование после обновления CLDR

После обновления:

  • cldr-data;
  • Globalize;
  • ICU;
  • Node.js

необходимо запускать полный набор тестов локализации.

Особенно важно проверять:

  • валюты;
  • plural rules;
  • даты;
  • пробельные символы;
  • часовые пояса.

Практика организации тестов

Хорошая практика:

tests/
├── formatters/
│   ├── number/
│   ├── currency/
│   ├── date/
│   └── message/
├── locales/
├── regression/
└── snapshots/

Частые ошибки при тестировании Globalize

Сравнение строк без нормализации

Ошибка:

expect(result).toBe("1 000");

В строке может использоваться Unicode NBSP.


Отсутствие загрузки CLDR

E_MISSING_CLDR

Причина — забытые JSON-файлы.


Проверка нестабильных дат

expect(formatter(new Date()))

Текущая дата зависит от:

  • таймзоны;
  • среды выполнения;
  • локального времени.

Использование реального времени

Антипаттерн:

new Date()

Лучше:

new Date(Date.UTC(2025, 0, 1))

Стратегия надёжных тестов

Надёжные тесты форматтеров обладают следующими свойствами:

  • детерминированность;
  • независимость от платформы;
  • независимость от времени;
  • минимальная зависимость от CLDR-изменений;
  • изолированная загрузка данных;
  • строгий контроль локалей.

Интеграционные и юнит-тесты

Юнит-тест:

formatter(1000)

Интеграционный тест:

render(<Price value={1000} />)

Юнит-тесты проверяют сам форматтер, интеграционные — взаимодействие UI и локализации.


Проверка пользовательских локалей

Иногда приложение поддерживает кастомные locale aliases.

const localeMap = {
  ua: "uk",
  kz: "kk"
};

Тест:

test("maps locale aliases", () => {
  expect(localeMap.ua).toBe("uk");
});

Автоматизация локализационных тестов

Тесты Globalize обычно включаются в:

  • CI/CD;
  • pre-push hooks;
  • nightly builds;
  • regression suites.

Пример:

npm test

или:

jest tests/formatters

Практика поддержки больших локализационных наборов

При поддержке десятков локалей полезно:

  • использовать parameterized tests;
  • выносить expected values в fixtures;
  • разделять locale-specific tests;
  • автоматически валидировать сообщения;
  • фиксировать версии CLDR.

Fixture-подход

export default [
  {
    locale: "en",
    input: 1000.5,
    output: "1,000.5"
  },
  {
    locale: "ru",
    input: 1000.5,
    output: "1 000,5"
  }
];

Тест:

fixtures.forEach(item => {
  test(item.locale, () => {
    const g = new Globalize(item.locale);

    const formatter = g.numberFormatter();

    expect(
      formatter(item.input)
    ).toBe(item.output);
  });
});