Моки для i18next в тестах

Тестирование кода, использующего i18next, почти всегда упирается в необходимость изолировать слой переводов от бизнес-логики. В реальных приложениях i18n добавляет асинхронную инициализацию, загрузку ресурсов, работу с интерполяцией и множественными языками, что усложняет предсказуемость тестов. Моки позволяют зафиксировать поведение переводов и убрать внешние зависимости, сохраняя проверку логики компонентов и функций.

Основная проблема тестирования с реальным i18next — нестабильность окружения:

  • асинхронная загрузка ресурсов может менять тайминги тестов;
  • переключение языков влияет на результат рендера;
  • отсутствие переводов приводит к падению тестов или неожиданным fallback-строкам;
  • плагины (backend, detector, cache) добавляют лишнюю сложность.

Изоляция переводческого слоя позволяет тестировать только поведение приложения, а не корректность i18n-конфигурации.

Простейший мок через фиктивную реализацию t()

Самый распространённый подход — подмена функции перевода t.

Пример для Jest:

jest.mock('i18next', () => ({
  t: (key) => key
}));

Такой вариант полезен, когда важно лишь проверить, что ключи используются корректно.

Более расширенный вариант:

const translations = {
  hello: 'Привет',
  goodbye: 'Пока'
};

jest.mock('i18next', () => ({
  t: (key) => translations[key] || key
}));

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

Мок через react-i18next

В приложениях на React чаще используется react-i18next. В тестах компоненты не должны зависеть от реального контекста провайдера.

Базовый мок useTranslation:

jest.mock('react-i18next', () => ({
  useTranslation: () => ({
    t: (key) => key,
    i18n: {
      changeLanguage: jest.fn()
    }
  }),
  initReactI18next: {
    type: '3rdParty',
    init: jest.fn()
  }
}));

Такой подход полностью убирает необходимость подключения i18n provider в тестах.

Мок с контекстом переводов

Когда важно проверять конкретные строки, а не ключи, используется словарь:

const resources = {
  ru: {
    translation: {
      submit: 'Отправить',
      cancel: 'Отмена'
    }
  },
  en: {
    translation: {
      submit: 'Submit',
      cancel: 'Cancel'
    }
  }
};

let currentLang = 'ru';

jest.mock('i18next', () => ({
  changeLanguage: (lng) => {
    currentLang = lng;
    return Promise.resolve();
  },
  language: () => currentLang,
  t: (key) => resources[currentLang]?.translation?.[key] || key
}));

Такой мок позволяет тестировать переключение языков без реального i18n-движка.

Изоляция через фабрику переводов

Более гибкий подход — создание фабрики мока:

export const createI18nMock = (lang = 'ru') => {
  const state = { lang };

  return {
    language: state.lang,
    changeLanguage: (lng) => {
      state.lang = lng;
      return Promise.resolve();
    },
    t: (key) => key
  };
};

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

vi.mock('i18next', () => ({
  default: createI18nMock()
}));

Такой подход удобен при масштабных тестовых наборах, где требуется контролируемое состояние.

Моки для интерполяции

i18next активно использует интерполяцию:

t('welcome', { name: 'Alex' })

Простой мок часто это игнорирует, но в тестах UI может быть важно проверять итоговую строку.

Расширенный мок:

const t = (key, options = {}) => {
  if (key === 'welcome') {
    return `Привет, ${options.name}`;
  }
  return key;
};

Для более универсального решения можно добавить шаблонизацию:

const t = (key, options = {}) => {
  let result = key;

  Object.entries(options).forEach(([k, v]) => {
    result = result.replace(`{{${k}}}`, v);
  });

  return result;
};

Моки для множественного числа (pluralization)

i18next поддерживает сложную систему plural rules. В тестах часто достаточно упрощённой модели:

const t = (key, options = {}) => {
  if (key === 'items') {
    return options.count === 1 ? '1 элемент' : `${options.count} элементов`;
  }
  return key;
};

Такой мок позволяет проверять поведение UI без реализации CLDR-правил.

Моки namespace-структур

В реальных проектах переводы разделены по namespace:

t('auth:login.button')

Упрощённый мок может игнорировать namespace:

const t = (key) => key.split(':').pop();

Более контролируемый вариант:

const dictionaries = {
  auth: {
    login: {
      button: 'Войти'
    }
  }
};

const t = (key) => {
  const [ns, path] = key.split(':');
  return dictionaries[ns]?.login?.[path] || key;
};

Мок асинхронной инициализации

Некоторые тесты падают из-за init():

i18next.init()

Упрощённый мок:

jest.mock('i18next', () => ({
  init: () => Promise.resolve(),
  t: (key) => key
}));

Это убирает необходимость ожидания готовности i18n в тестах.

Интеграция с тестами компонентов

В тестах компонентов важно избегать реального провайдера:

render(<App />)

Если используется реальный i18n, тест становится нестабильным. Поэтому мок должен подменять:

  • t
  • i18n.language
  • changeLanguage
  • useTranslation

Типовой минимальный набор:

useTranslation: () => ({
  t: (key) => key,
  i18n: {
    language: 'ru',
    changeLanguage: jest.fn()
  }
})

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

1. Потеря реактивности языка

Если language не реактивен, тесты не проверяют реальное переключение UI.

2. Слишком примитивный t()

Возврат только ключа скрывает ошибки в интерполяции и форматировании строк.

3. Игнорирование namespace

Код может работать в моках, но ломаться в реальной конфигурации.

4. Несовместимость с async init

Тесты проходят, но приложение падает в рантайме.

Стратегии выбора уровня мока

  • Полный мок t(key) → быстрые unit-тесты логики
  • Мок с переводами → UI-тесты компонентов
  • Частичный i18n (init без backend) → интеграционные тесты
  • Реальный i18n → e2e сценарии

Баланс между уровнем абстракции и реалистичностью напрямую влияет на стабильность тестового набора и стоимость поддержки.