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

Архитектура интернационализации в тестовой среде

Компоненты, использующие международализацию через FormatJS, обычно зависят от контекста IntlProvider, набора сообщений и текущей локали. В тестовой среде это создаёт дополнительный слой сложности: поведение UI становится зависимым не только от входных пропсов, но и от локализационного окружения.

Ключевая особенность заключается в том, что FormatJS использует ICU-сообщения, форматирование дат, чисел и валют через стандартный Intl. Это означает, что тесты должны учитывать детерминированность вывода, иначе результаты становятся нестабильными.

Основные зависимости тестируемого компонента:

  • локаль (locale)
  • словарь сообщений (messages)
  • форматтеры date, number, time
  • контекст IntlProvider

Базовая стратегия изоляции i18n

На практике тестирование компонентов с i18n сводится к созданию стабильного окружения, в котором локализация полностью контролируется.

Типичный подход — создание обёртки над рендером:

import { IntlProvider } from "react-intl";
import { render } from "@testing-library/react";

const messages = {
  ru: {
    greeting: "Привет, {name}",
    unread: "У вас {count} сообщений",
  },
};

function renderWithIntl(ui, { locale = "ru", messages: msg = messages.ru } = {}) {
  return render(
    <IntlProvider locale={locale} messages={msg}>
      {ui}
    </IntlProvider>
  );
}

Такой слой позволяет:

  • исключить повторение boilerplate
  • контролировать локаль
  • подменять словари сообщений
  • стабилизировать форматирование

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

Наиболее прямой способ работы с FormatJS в компонентах — через useIntl() и formatMessage.

Компонент:

import { useIntl } from "react-intl";

export function Greeting({ name }) {
  const intl = useIntl();

  return (
    <div>
      {intl.formatMessage(
        { id: "greeting" },
        { name }
      )}
    </div>
  );
}

Тест:

test("renders formatted greeting", () => {
  const { getByText } = renderWithIntl(<Greeting name="Ivan" />);

  getByText("Привет, Ivan");
});

Важный аспект: тестируется не структура ICU, а конечный результат форматирования. Это снижает хрупкость тестов при изменении текстов.

Проверка параметризованных сообщений

FormatJS активно использует параметры, и именно они чаще всего становятся источником ошибок.

test("renders message with plural form", () => {
  const messages = {
    unread: "У вас {count, plural, one {# сообщение} few {# сообщения} many {# сообщений}}",
  };

  const { getByText } = renderWithIntl(
    <Unread count={5} />,
    { messages }
  );

  getByText("У вас 5 сообщений");
});

Здесь важно не проверять ICU-структуру, а результат ветвления plural rules.

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

FormatJS опирается на Intl.DateTimeFormat и Intl.NumberFormat. Это создаёт потенциальную нестабильность тестов из-за различий окружений CI и локальных машин.

Компонент:

import { useIntl } from "react-intl";

export function OrderDate({ date }) {
  const intl = useIntl();

  return (
    <span>
      {intl.formatDate(date, {
        year: "numeric",
        month: "long",
        day: "2-digit",
      })}
    </span>
  );
}

Тест становится чувствительным к локали, поэтому фиксируется окружение:

beforeAll(() => {
  jest.useFakeTimers().setSystemTime(new Date("2024-01-01"));
});

Либо фиксируется конкретная локаль:

const wrapper = ({ children }) => (
  <IntlProvider locale="en-US">{children}</IntlProvider>
);

Проверка:

getByText("January 01, 2024");

Изоляция нестабильности Intl

В некоторых средах Intl может отличаться (особенно в Node.js без полифилов). Для стабилизации используются:

  • @formatjs/intl-pluralrules
  • @formatjs/intl-datetimeformat
  • @formatjs/intl-numberformat

Инициализация тестового окружения:

import "@formatjs/intl-pluralrules/polyfill";
import "@formatjs/intl-datetimeformat/polyfill";
import "@formatjs/intl-numberformat/polyfill";
import "@formatjs/intl-datetimeformat/locale-data/ru";

Это устраняет расхождения между средами выполнения.

Тестирование через React Testing Library

Наиболее устойчивый подход — тестирование через поведение UI, а не через внутренние вызовы FormatJS.

Пример компонента:

export function Cart({ items }) {
  const intl = useIntl();

  return (
    <div>
      {intl.formatMessage(
        { id: "cart.total" },
        { count: items.length }
      )}
    </div>
  );
}

Тест:

test("shows cart count", () => {
  const { getByText } = renderWithIntl(
    <Cart items={[1, 2, 3]} />
  );

  getByText("3 items");
});

Здесь проверяется пользовательское поведение, а не внутренняя реализация i18n.

Snapshot-тестирование и его ограничения

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

Проблема:

expect(container).toMatchSnapshot();

Даже незначительное изменение текста или локали приводит к массовым изменениям снапшотов.

Более устойчивый подход:

  • исключать текст из snapshot-проверок
  • заменять на проверки ключевых элементов
  • фиксировать locale

Пример стабилизации:

expect(getByTestId("label")).toHaveTextContent("Привет, Ivan");

Тестирование useIntl в изоляции

Иногда требуется проверить логику вне DOM.

import { createIntl, createIntlCache } from "react-intl";

const cache = createIntlCache();

const intl = createIntl(
  {
    locale: "ru",
    messages: {
      hello: "Привет",
    },
  },
  cache
);

test("formats message outside component", () => {
  expect(intl.formatMessage({ id: "hello" })).toBe("Привет");
});

Такой подход используется для:

  • утилитарных функций
  • бизнес-логики
  • форматирования вне React

Моки сообщений и стратегия контроля локалей

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

const testMessages = {
  "app.title": "Test App",
  "user.name": "Name: {name}",
};

Это снижает:

  • стоимость поддержки тестов
  • зависимость от реальных переводов
  • риск изменений UI при локализации

Проверка fallback-логики

FormatJS поддерживает fallback на id сообщения, если перевод отсутствует.

test("falls back to message id", () => {
  const { getByText } = renderWithIntl(
    <MissingMessage />
  );

  getByText("missing.key");
});

Это позволяет выявлять:

  • отсутствующие переводы
  • ошибки в ключах
  • несоответствие контрактов i18n

Тестирование компонентов с динамической локалью

Иногда локаль меняется в runtime:

function LocaleSwitcher() {
  const [locale, setLocale] = useState("ru");

  return (
    <IntlProvider locale={locale} messages={messages[locale]}>
      <button onCl ick={() => setLocale("en")}>
        Switch
      </button>
    </IntlProvider>
  );
}

Тестирование фокусируется на:

  • смене сообщений
  • корректном обновлении UI
fireEvent.click(getByText("Switch"));

getByText("Hello");

Контроль детерминизма в тестах

Главная проблема i18n-тестирования — недетерминированность. Она возникает из-за:

  • системной локали
  • временных зон
  • различий ICU реализаций
  • дефолтных fallback значений

Решение заключается в жёсткой фиксации среды:

  • явная locale
  • фиксированное время
  • минимальные messages
  • полифилы Intl
  • отказ от snapshot как основного метода

Тестирование сложных ICU-конструкций

FormatJS поддерживает вложенные конструкции:

{
  id: "profile.status",
  defaultMessage:
    "{gender, select, male {Он онлайн} female {Она онлайн} other {Пользователь онлайн}}"
}

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

getByText("Она онлайн");

В таких случаях проверяется не структура ICU, а конечная ветка результата.

Интеграция i18n-тестов в CI

В CI-среде особенно важно учитывать:

  • отсутствие системных locale
  • урезанный ICU
  • различия Node версий

Практика стабилизации:

  • установка TZ=UTC
  • фиксация Node версии
  • использование formatjs polyfills
  • изоляция тестов от системного Intl

Такой подход снижает вероятность ложных падений тестов при миграции окружений.