E2E тестирование мультиязычности

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

  • загрузка переводов;
  • переключение локалей;
  • серверный рендеринг;
  • форматирование дат и чисел;
  • асинхронные запросы;
  • fallback-механизмы;
  • RTL-режимы;
  • интеграция с браузерными API.

Unit-тесты покрывают отдельные функции FormatJS, однако именно E2E-тестирование позволяет проверить реальные пользовательские сценарии.

Основные цели E2E тестирования мультиязычности:

  • проверка корректного отображения переводов;
  • обнаружение отсутствующих сообщений;
  • контроль переключения локалей;
  • проверка форматирования валют, дат и чисел;
  • тестирование RTL-интерфейсов;
  • проверка SSR и гидратации;
  • обнаружение проблем с lazy-loading переводов;
  • контроль SEO-маркеров (lang, hreflang);
  • предотвращение регрессий после обновления переводов.

Инструменты E2E тестирования

Наиболее популярные инструменты:

Инструмент Особенности
Cypress Удобный API, быстрый запуск
Playwright Мультибраузерность, мощная работа с локалями
WebdriverIO Интеграция с Selenium
TestCafe Простая конфигурация
Selenium Классический подход

Для приложений с FormatJS чаще всего используется комбинация:

  • Playwright + React Intl;
  • Cypress + react-intl.

Типовая архитектура мультиязычного приложения

Пример структуры:

src/
 ├── i18n/
 │    ├── locales/
 │    │    ├── en.json
 │    │    ├── ru.json
 │    │    └── de.json
 │    ├── provider.tsx
 │    └── messages.ts
 ├── pages/
 ├── components/
 └── tests/

Provider:

import { IntlProvider } from 'react-intl'
import messages from './messages'

export function I18nProvider({ locale, children }) {
  return (
    <IntlProvider
      locale={locale}
      messages={messages[locale]}
    >
      {children}
    </IntlProvider>
  )
}

Проверка переключения локали

Один из главных E2E-сценариев — изменение языка интерфейса.

Пример на Playwright

import { test, expect } from '@playwright/test'

test('language switch', async ({ page }) => {
  await page.goto('/')

  await expect(
    page.getByText('Welcome')
  ).toBeVisible()

  await page.click('[data-testid="lang-ru"]')

  await expect(
    page.getByText('Добро пожаловать')
  ).toBeVisible()
})

Использование data-testid

Тексты интерфейса меняются между локалями, поэтому нельзя строить селекторы исключительно на текстовых значениях.

Неправильно:

await page.click('text=Settings')

Правильно:

<button data-testid="settings-btn">
  <FormattedMessage id="settings" />
</button>

Тест:

await page.click('[data-testid="settings-btn"]')

Это делает тесты независимыми от языка интерфейса.


Проверка отсутствующих переводов

FormatJS может отображать fallback вместо перевода:

[missing "profile.title" translation]

E2E тест должен обнаруживать подобные случаи.

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

test('no missing translations', async ({ page }) => {
  await page.goto('/')

  const content = await page.content()

  expect(content).not.toContain('[missing')
})

Перехват console.error

React Intl выводит ошибки локализации через консоль браузера.

Playwright

test('no intl errors', async ({ page }) => {
  const errors = []

  page.on('console', msg => {
    if (msg.type() === 'error') {
      errors.push(msg.text())
    }
  })

  await page.goto('/')

  expect(errors).toEqual([])
})

Проверка всех локалей

Один тест должен выполняться для каждой поддерживаемой локали.

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

const locales = ['en', 'ru', 'de']

for (const locale of locales) {
  test(`homepage ${locale}`, async ({ page }) => {
    await page.goto(`/${locale}`)

    await expect(
      page.locator('h1')
    ).toBeVisible()
  })
}

Проверка форматирования дат

FormatJS активно использует Intl.DateTimeFormat.

Компонент:

<FormattedDate
  value={date}
  year="numeric"
  month="long"
  day="numeric"
/>

E2E-тест:

test('date formatting ru locale', async ({ page }) => {
  await page.goto('/ru')

  await expect(
    page.getByText('15 января 2025 г.')
  ).toBeVisible()
})

Проверка валют

FormatJS:

<FormattedNumber
  value={1500}
  style="currency"
  currency="USD"
/>

Тест:

test('currency formatting', async ({ page }) => {
  await page.goto('/de')

  await expect(
    page.getByText('1.500,00 $')
  ).toBeVisible()
})

Проверка pluralization

Plural rules отличаются между языками.

Пример:

<FormattedMessage
  id="notifications"
  values={{ count: 5 }}
/>

Сообщение:

{
  "notifications": "{count, plural, one {# message} other {# messages}}"
}

Тест:

test('plural forms', async ({ page }) => {
  await page.goto('/en')

  await expect(
    page.getByText('5 messages')
  ).toBeVisible()
})

Проверка RTL-интерфейсов

Арабский и иврит используют направление справа налево.

Проверка атрибута dir

test('rtl enabled', async ({ page }) => {
  await page.goto('/ar')

  const dir = await page.getAttribute('html', 'dir')

  expect(dir).toBe('rtl')
})

Визуальное тестирование RTL

RTL часто ломает:

  • flex-layout;
  • отступы;
  • иконки;
  • анимации;
  • абсолютное позиционирование.

Playwright snapshot:

test('rtl layout', async ({ page }) => {
  await page.goto('/ar')

  expect(
    await page.screenshot()
  ).toMatchSnapshot('rtl-homepage.png')
})

Проверка lazy-loading переводов

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

Пример

async function loadMessages(locale) {
  return import(`./locales/${locale}.json`)
}

Тест должен убедиться, что:

  • загрузка завершилась;
  • fallback исчез;
  • UI обновился.
test('lazy translations loaded', async ({ page }) => {
  await page.goto('/ru')

  await expect(
    page.getByText('Загрузка...')
  ).not.toBeVisible()

  await expect(
    page.getByText('Главная')
  ).toBeVisible()
})

Проверка гидратации SSR

При SSR возможны проблемы:

  • mismatch локали;
  • разные timezone;
  • несовпадение переводов;
  • двойной рендер.

Тест:

test('no hydration mismatch', async ({ page }) => {
  const errors = []

  page.on('console', msg => {
    if (msg.text().includes('hydration')) {
      errors.push(msg.text())
    }
  })

  await page.goto('/')

  expect(errors).toEqual([])
})

Проверка URL-локалей

Многие приложения используют локаль в URL.

Примеры:

/en/products
/ru/products
/de/products

Тест:

test('locale route', async ({ page }) => {
  await page.goto('/ru/products')

  await expect(
    page.getByText('Товары')
  ).toBeVisible()
})

Проверка fallback locale

Если перевод отсутствует, приложение может использовать fallback.

Конфигурация:

<IntlProvider
  locale={locale}
  defaultLocale="en"
  messages={messages}
/>

Тест:

test('fallback translation', async ({ page }) => {
  await page.goto('/fr')

  await expect(
    page.getByText('Settings')
  ).toBeVisible()
})

Автоматическая проверка всех сообщений

Иногда полезно проверять наличие всех message id на странице.

Получение id

const ids = await page.evaluate(() => {
  return window.__MESSAGES__
})

Проверка:

expect(ids.length).toBeGreaterThan(0)

Проверка локали браузера

FormatJS может использовать navigator.language.

Playwright позволяет эмулировать локаль.

test.use({
  locale: 'ru-RU'
})

Тест:

test('browser locale', async ({ page }) => {
  await page.goto('/')

  await expect(
    page.getByText('Добро пожаловать')
  ).toBeVisible()
})

Эмуляция timezone

Форматирование времени зависит от timezone.

Playwright:

test.use({
  timezoneId: 'Europe/Moscow'
})

Проверка:

test('timezone formatting', async ({ page }) => {
  await page.goto('/')

  await expect(
    page.getByText('18:00')
  ).toBeVisible()
})

Проверка SEO для мультиязычности

Важно тестировать:

  • lang;
  • hreflang;
  • canonical URL;
  • локализованные meta tags.

Проверка lang

test('html lang attribute', async ({ page }) => {
  await page.goto('/de')

  const lang = await page.getAttribute('html', 'lang')

  expect(lang).toBe('de')
})

Проверка hreflang

test('hreflang links', async ({ page }) => {
  await page.goto('/')

  const links = await page.locator(
    'link[rel="alternate"]'
  ).count()

  expect(links).toBeGreaterThan(0)
})

Снапшот-тестирование переводов

Снимки помогают обнаружить:

  • неожиданные изменения текста;
  • обрезание строк;
  • ошибки interpolation.
test('homepage snapshot', async ({ page }) => {
  await page.goto('/ru')

  expect(
    await page.locator('body').textContent()
  ).toMatchSnapshot()
})

Проверка interpolation

FormatJS:

<FormattedMessage
  id="greeting"
  values={{ name: 'Анна' }}
/>

Сообщение:

{
  "greeting": "Привет, {name}"
}

Тест:

test('message interpolation', async ({ page }) => {
  await page.goto('/')

  await expect(
    page.getByText('Привет, Анна')
  ).toBeVisible()
})

Проверка rich text сообщений

FormatJS поддерживает JSX внутри сообщений.

Пример:

<FormattedMessage
  id="terms"
  values={{
    link: chunks => <a href="/terms">{chunks}</a>
  }}
/>

Тест:

test('rich text translation', async ({ page }) => {
  await page.goto('/')

  const link = page.locator('a[href="/terms"]')

  await expect(link).toBeVisible()
})

Проверка динамического переключения языка

Некоторые приложения меняют язык без перезагрузки страницы.

Тест:

test('dynamic locale switching', async ({ page }) => {
  await page.goto('/')

  await page.click('[data-testid="lang-de"]')

  await expect(
    page.getByText('Willkommen')
  ).toBeVisible()

  await page.click('[data-testid="lang-en"]')

  await expect(
    page.getByText('Welcome')
  ).toBeVisible()
})

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

Формы могут содержать:

  • локализованные placeholder;
  • форматирование чисел;
  • локализованные ошибки валидации.

Тест:

test('localized validation', async ({ page }) => {
  await page.goto('/ru/login')

  await page.click('button[type=submit]')

  await expect(
    page.getByText('Введите email')
  ).toBeVisible()
})

Проверка accessibility

Локализация влияет и на accessibility.

Необходимо проверять:

  • aria-label;
  • aria-description;
  • screen reader тексты;
  • lang атрибуты.

Пример:

test('localized aria labels', async ({ page }) => {
  await page.goto('/ru')

  const button = page.locator(
    '[aria-label="Закрыть"]'
  )

  await expect(button).toBeVisible()
})

CI/CD тестирование мультиязычности

E2E тесты должны запускаться:

  • для каждой локали;
  • в нескольких браузерах;
  • при изменении переводов;
  • перед production deploy.

Пример GitHub Actions:

name: e2e

on: [push]

jobs:
  tests:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        locale: [en, ru, de]

    steps:
      - uses: actions/checkout@v4

      - run: npm install

      - run: npm run test:e2e -- --locale=${{ matrix.locale }}

Проверка отсутствия необработанных message id

Иногда вместо перевода отображается сам ключ.

Пример ошибки:

profile.settings.title

Тест:

test('no raw ids', async ({ page }) => {
  await page.goto('/')

  const text = await page.textContent('body')

  expect(text).not.toContain('.title')
})

Изоляция переводов в тестах

Для стабильности тестов важно:

  • фиксировать timezone;
  • фиксировать locale браузера;
  • отключать случайные данные;
  • стабилизировать даты.

Пример:

test.use({
  locale: 'en-US',
  timezoneId: 'UTC'
})

Mock переводов

Иногда E2E тестируется с тестовыми переводами.

{
  "home.title": "TEST_HOME_TITLE"
}

Преимущества:

  • легко искать элементы;
  • проще находить missing translations;
  • стабильные snapshot-тесты.

Распространённые проблемы

Нестабильные snapshot-тесты

Причины:

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

Медленная загрузка переводов

Причины:

  • большие JSON-файлы;
  • отсутствие code splitting;
  • синхронная загрузка locale bundles.

Падения из-за текста

Причина:

getByText('Settings')

после смены языка.

ICU различия

Разные браузеры могут по-разному форматировать:

  • валюты;
  • plural rules;
  • даты;
  • пробелы.

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

Удобная структура:

tests/
 ├── locales/
 │    ├── en.spec.ts
 │    ├── ru.spec.ts
 │    └── de.spec.ts
 ├── rtl/
 ├── seo/
 ├── formatting/
 └── accessibility/

Стратегия полного покрытия мультиязычности

Полноценная стратегия включает:

  1. Unit-тесты ICU сообщений.
  2. Integration-тесты React Intl.
  3. E2E тестирование пользовательских сценариев.
  4. Visual regression testing.
  5. Accessibility testing.
  6. Проверку SEO.
  7. Проверку SSR.
  8. Проверку lazy-loading.
  9. Проверку fallback locale.
  10. CI/CD автоматизацию.

Комбинация Playwright и FormatJS

Playwright особенно удобен благодаря:

  • встроенной эмуляции locale;
  • timezone control;
  • snapshot testing;
  • network interception;
  • мультибраузерности;
  • высокой скорости выполнения.

Типичная конфигурация:

import { defineConfig } from '@playwright/test'

export default defineConfig({
  use: {
    locale: 'en-US',
    timezoneId: 'UTC'
  }
})

Проверка загрузки locale bundles

Можно контролировать сетевые запросы.

test('locale bundle loaded', async ({ page }) => {
  const requests = []

  page.on('request', req => {
    requests.push(req.url())
  })

  await page.goto('/ru')

  expect(
    requests.some(url => url.includes('ru.json'))
  ).toBeTruthy()
})

Тестирование offline-сценариев

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

test('translation loading failure', async ({ page }) => {
  await page.route('**/ru.json', route => {
    route.abort()
  })

  await page.goto('/ru')

  await expect(
    page.getByText('Translation error')
  ).toBeVisible()
})

Проверка кеширования переводов

При повторной навигации locale bundle не должен загружаться повторно.

test('translation cache', async ({ page }) => {
  let count = 0

  page.on('request', req => {
    if (req.url().includes('ru.json')) {
      count++
    }
  })

  await page.goto('/ru')
  await page.goto('/')

  expect(count).toBe(1)
})

Интеграция с visual regression testing

Локализация сильно влияет на layout:

  • немецкий длиннее английского;
  • арабский использует RTL;
  • японский меняет ширину текста.

Visual regression testing помогает обнаруживать:

  • переносы;
  • обрезание текста;
  • переполнение контейнеров;
  • ломание grid-layout.