Мультиязычность влияет не только на отображение текста, но и на поведение интерфейса, маршрутизацию, форматирование данных, доступность компонентов и пользовательские сценарии. Ошибки локализации часто проявляются только в полностью собранном приложении, когда одновременно работают:
Unit-тесты покрывают отдельные функции FormatJS, однако именно E2E-тестирование позволяет проверить реальные пользовательские сценарии.
Основные цели E2E тестирования мультиязычности:
lang,
hreflang);Наиболее популярные инструменты:
| Инструмент | Особенности |
|---|---|
| Cypress | Удобный API, быстрый запуск |
| Playwright | Мультибраузерность, мощная работа с локалями |
| WebdriverIO | Интеграция с Selenium |
| TestCafe | Простая конфигурация |
| Selenium | Классический подход |
Для приложений с FormatJS чаще всего используется комбинация:
Пример структуры:
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-сценариев — изменение языка интерфейса.
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()
})
Тексты интерфейса меняются между локалями, поэтому нельзя строить селекторы исключительно на текстовых значениях.
Неправильно:
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')
})
React Intl выводит ошибки локализации через консоль браузера.
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()
})
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()
})
Арабский и иврит используют направление справа налево.
test('rtl enabled', async ({ page }) => {
await page.goto('/ar')
const dir = await page.getAttribute('html', 'dir')
expect(dir).toBe('rtl')
})
RTL часто ломает:
Playwright snapshot:
test('rtl layout', async ({ page }) => {
await page.goto('/ar')
expect(
await page.screenshot()
).toMatchSnapshot('rtl-homepage.png')
})
Часто переводы загружаются динамически.
async function loadMessages(locale) {
return import(`./locales/${locale}.json`)
}
Тест должен убедиться, что:
test('lazy translations loaded', async ({ page }) => {
await page.goto('/ru')
await expect(
page.getByText('Загрузка...')
).not.toBeVisible()
await expect(
page.getByText('Главная')
).toBeVisible()
})
При SSR возможны проблемы:
Тест:
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.
Примеры:
/en/products
/ru/products
/de/products
Тест:
test('locale route', async ({ page }) => {
await page.goto('/ru/products')
await expect(
page.getByText('Товары')
).toBeVisible()
})
Если перевод отсутствует, приложение может использовать 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 на странице.
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.
Playwright:
test.use({
timezoneId: 'Europe/Moscow'
})
Проверка:
test('timezone formatting', async ({ page }) => {
await page.goto('/')
await expect(
page.getByText('18:00')
).toBeVisible()
})
Важно тестировать:
lang;hreflang;test('html lang attribute', async ({ page }) => {
await page.goto('/de')
const lang = await page.getAttribute('html', 'lang')
expect(lang).toBe('de')
})
test('hreflang links', async ({ page }) => {
await page.goto('/')
const links = await page.locator(
'link[rel="alternate"]'
).count()
expect(links).toBeGreaterThan(0)
})
Снимки помогают обнаружить:
test('homepage snapshot', async ({ page }) => {
await page.goto('/ru')
expect(
await page.locator('body').textContent()
).toMatchSnapshot()
})
FormatJS:
<FormattedMessage
id="greeting"
values={{ name: 'Анна' }}
/>
Сообщение:
{
"greeting": "Привет, {name}"
}
Тест:
test('message interpolation', async ({ page }) => {
await page.goto('/')
await expect(
page.getByText('Привет, Анна')
).toBeVisible()
})
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()
})
Формы могут содержать:
Тест:
test('localized validation', async ({ page }) => {
await page.goto('/ru/login')
await page.click('button[type=submit]')
await expect(
page.getByText('Введите email')
).toBeVisible()
})
Локализация влияет и на accessibility.
Необходимо проверять:
Пример:
test('localized aria labels', async ({ page }) => {
await page.goto('/ru')
const button = page.locator(
'[aria-label="Закрыть"]'
)
await expect(button).toBeVisible()
})
E2E тесты должны запускаться:
Пример 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 }}
Иногда вместо перевода отображается сам ключ.
Пример ошибки:
profile.settings.title
Тест:
test('no raw ids', async ({ page }) => {
await page.goto('/')
const text = await page.textContent('body')
expect(text).not.toContain('.title')
})
Для стабильности тестов важно:
Пример:
test.use({
locale: 'en-US',
timezoneId: 'UTC'
})
Иногда E2E тестируется с тестовыми переводами.
{
"home.title": "TEST_HOME_TITLE"
}
Преимущества:
Причины:
Причины:
Причина:
getByText('Settings')
после смены языка.
Разные браузеры могут по-разному форматировать:
Удобная структура:
tests/
├── locales/
│ ├── en.spec.ts
│ ├── ru.spec.ts
│ └── de.spec.ts
├── rtl/
├── seo/
├── formatting/
└── accessibility/
Полноценная стратегия включает:
Playwright особенно удобен благодаря:
Типичная конфигурация:
import { defineConfig } from '@playwright/test'
export default defineConfig({
use: {
locale: 'en-US',
timezoneId: 'UTC'
}
})
Можно контролировать сетевые запросы.
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()
})
Если переводы загружаются динамически, важно проверять ошибки сети.
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)
})
Локализация сильно влияет на layout:
Visual regression testing помогает обнаруживать: