Testing Internationalization in React and Next.js Apps
React and Next.js have become the default stack for building multilingual web apps, with libraries like react-i18next, next-intl, and react-intl handling the heavy lifting. But a common mistake is writing no tests for the i18n layer itself — assuming that if translations load, everything works.
This guide covers testing internationalization at every layer: unit tests for translation hooks and utilities, integration tests for components with locale context, and end-to-end tests that verify the full locale experience.
Testing the i18n Layer
Unit Testing Translation Hooks
The most common React i18n pattern uses a useTranslation hook. Testing it requires mocking the i18n provider:
// Using react-i18next
import { renderHook } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import i18n from '../i18n/config'; // your i18n instance
import { useProductLabels } from '../hooks/useProductLabels';
describe('useProductLabels', () => {
it('returns German labels when locale is de', async () => {
await i18n.changeLanguage('de');
const wrapper = ({ children }) => (
<I18nextProvider i18n={i18n}>{children}</I18nextProvider>
);
const { result } = renderHook(() => useProductLabels(), { wrapper });
expect(result.current.addToCart).toBe('In den Warenkorb');
expect(result.current.outOfStock).toBe('Nicht vorrätig');
});
it('falls back to English for missing German keys', async () => {
await i18n.changeLanguage('de');
// Intentionally missing from German translation
const { result } = renderHook(() => useProductLabels(), { wrapper });
expect(result.current.betaFeatureLabel).toBe('Beta'); // English fallback
});
});Testing Translation Completeness
Write a test that verifies all keys in the English translation file exist in all other locale files:
// tests/i18n/completeness.test.js
import fs from 'fs';
import path from 'path';
function getAllKeys(obj, prefix = '') {
return Object.entries(obj).flatMap(([key, value]) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
return typeof value === 'object' && value !== null
? getAllKeys(value, fullKey)
: [fullKey];
});
}
describe('Translation completeness', () => {
const localesDir = path.join(__dirname, '../../public/locales');
const englishKeys = getAllKeys(
JSON.parse(fs.readFileSync(path.join(localesDir, 'en/common.json'), 'utf8'))
);
const supportedLocales = ['de', 'fr', 'ja', 'ar'];
for (const locale of supportedLocales) {
it(`${locale} has all English keys`, () => {
const localeTranslations = JSON.parse(
fs.readFileSync(path.join(localesDir, `${locale}/common.json`), 'utf8')
);
const localeKeys = getAllKeys(localeTranslations);
const missingKeys = englishKeys.filter(k => !localeKeys.includes(k));
expect(missingKeys).toEqual([]);
});
}
});This test is cheap and catches the most common l10n defect: a key added in English but not translated yet.
Testing Components with Locale Context
Component Integration Tests
Testing that a component renders correctly in a given locale:
// components/PriceDisplay.test.jsx
import { render, screen } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import { createI18nInstance } from '../test-utils/i18n';
import PriceDisplay from './PriceDisplay';
function renderWithLocale(ui, locale) {
const i18n = createI18nInstance(locale);
return render(
<I18nextProvider i18n={i18n}>{ui}</I18nextProvider>
);
}
describe('PriceDisplay', () => {
it('formats price in USD for en-US', () => {
renderWithLocale(<PriceDisplay amount={1234.56} currency="USD" />, 'en-US');
expect(screen.getByText('$1,234.56')).toBeInTheDocument();
});
it('formats price in EUR for de-DE', () => {
renderWithLocale(<PriceDisplay amount={1234.56} currency="EUR" />, 'de-DE');
expect(screen.getByText('1.234,56 €')).toBeInTheDocument();
});
it('formats price in JPY for ja-JP without decimals', () => {
renderWithLocale(<PriceDisplay amount={1234.56} currency="JPY" />, 'ja-JP');
expect(screen.getByText('¥1,235')).toBeInTheDocument(); // rounded, no decimal
});
});Testing Plural Forms
describe('ItemCounter plural forms', () => {
const cases = [
{ count: 0, locale: 'en', expected: '0 items' },
{ count: 1, locale: 'en', expected: '1 item' },
{ count: 5, locale: 'en', expected: '5 items' },
{ count: 1, locale: 'ru', expected: '1 товар' },
{ count: 3, locale: 'ru', expected: '3 товара' }, // "few" form
{ count: 11, locale: 'ru', expected: '11 товаров' }, // "many" form (11 is exception)
];
for (const { count, locale, expected } of cases) {
it(`shows "${expected}" for count=${count}, locale=${locale}`, () => {
renderWithLocale(<ItemCounter count={count} />, locale);
expect(screen.getByText(expected)).toBeInTheDocument();
});
}
});Next.js-Specific i18n Testing
Next.js has built-in i18n routing. The next-intl library (the current standard) adds middleware for locale detection and provides typed translation APIs.
Testing Next.js Middleware Locale Detection
// tests/middleware.test.js
import { NextRequest } from 'next/server';
import { middleware } from '../middleware';
describe('locale detection middleware', () => {
it('redirects to /de when Accept-Language is German', async () => {
const request = new NextRequest('http://localhost/', {
headers: { 'accept-language': 'de-DE,de;q=0.9,en;q=0.8' }
});
const response = await middleware(request);
expect(response.status).toBe(307);
expect(response.headers.get('location')).toContain('/de');
});
it('defaults to /en for unsupported locale', async () => {
const request = new NextRequest('http://localhost/', {
headers: { 'accept-language': 'zz-ZZ' } // fictional locale
});
const response = await middleware(request);
expect(response.headers.get('location')).toContain('/en');
});
it('does not redirect when locale is already in URL', async () => {
const request = new NextRequest('http://localhost/de/dashboard');
const response = await middleware(request);
expect(response.status).not.toBe(307);
});
});Testing next-intl Translations
// Using next-intl's testing utilities
import { render, screen } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import deMessages from '../../messages/de.json';
import CheckoutButton from '../components/CheckoutButton';
it('renders German checkout text', () => {
render(
<NextIntlClientProvider locale="de" messages={deMessages}>
<CheckoutButton />
</NextIntlClientProvider>
);
expect(screen.getByRole('button')).toHaveTextContent('Zur Kasse');
});End-to-End Tests with Playwright
Unit and integration tests verify the pieces. E2E tests verify the real user experience.
Testing Full Locale Routing
// e2e/i18n.spec.js
import { test, expect } from '@playwright/test';
test.describe('locale routing', () => {
test('navigates to German locale when browser language is German', async ({ browser }) => {
const context = await browser.newContext({ locale: 'de-DE' });
const page = await context.newPage();
await page.goto('/');
// Should redirect to /de/ automatically
await expect(page).toHaveURL(/\/de\//);
// Verify German content is shown
await expect(page.locator('h1')).not.toHaveText('Welcome');
await expect(page.locator('h1')).toHaveText(/Willkommen/);
});
test('language switcher changes locale and persists on navigation', async ({ page }) => {
await page.goto('/en');
// Switch to French using the language selector
await page.click('[data-testid="language-switcher"]');
await page.click('[data-testid="locale-fr"]');
// Should be on French URL
await expect(page).toHaveURL(/\/fr\//);
// Navigate to another page — locale should persist
await page.click('[data-testid="nav-products"]');
await expect(page).toHaveURL(/\/fr\/products/);
});
});Testing Static Generation with Locales
Next.js generates separate pages for each locale with getStaticPaths. Test that all locale variants are correctly rendered:
test('all locale variants are accessible', async ({ request }) => {
const locales = ['en', 'de', 'fr', 'ja'];
for (const locale of locales) {
const response = await request.get(`/${locale}/about`);
expect(response.ok()).toBeTruthy();
const body = await response.text();
expect(body).not.toContain('404');
expect(body).not.toContain('[MISSING:'); // no missing translations
}
});Testing react-intl (FormattedMessage)
If you're using react-intl:
import { render, screen } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import OrderSummary from './OrderSummary';
import deMessages from '../../i18n/messages/de.json';
function renderWithIntl(ui, locale, messages) {
return render(
<IntlProvider locale={locale} messages={messages}>
{ui}
</IntlProvider>
);
}
it('shows order total in German format', () => {
renderWithIntl(
<OrderSummary total={1234.50} />,
'de',
deMessages
);
// German format: 1.234,50 €
expect(screen.getByTestId('order-total')).toHaveTextContent('1.234,50');
});Setting Up i18n Test Utils
Create a shared test utility to avoid repeating locale setup in every test file:
// test-utils/i18n.js
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
export function createI18nInstance(locale) {
const instance = i18n.createInstance();
// Load translations from your actual translation files
const resources = {
en: { common: require('../public/locales/en/common.json') },
de: { common: require('../public/locales/de/common.json') },
ja: { common: require('../public/locales/ja/common.json') },
ar: { common: require('../public/locales/ar/common.json') },
};
instance
.use(initReactI18next)
.init({
lng: locale,
fallbackLng: 'en',
resources,
ns: ['common'],
defaultNS: 'common',
interpolation: { escapeValue: false },
});
return instance;
}This utility lets you write renderWithLocale(ui, 'de-DE') from any test file.
What to Test vs. What Not to Test
Test:
- Your i18n configuration is correct (fallback chains, namespace setup)
- Components render locale-specific content when given a locale
- Date/number formatting uses locale-aware APIs
- Missing translation keys are caught (completeness test)
- Locale routing middleware redirects correctly
- E2E: actual pages render in correct language with locale set
Don't test:
- That react-i18next itself works (it's a library with its own tests)
- That every single string is translated correctly (that's a translation review, not a test)
- That
Intl.NumberFormatformats numbers correctly (it's a browser built-in)
Focus your test effort on your application's code — the configuration, the hooks you wrote, the components that use translations, the routing logic.
Summary
Testing i18n in React/Next.js apps has three layers that serve different purposes:
- Unit tests: Verify your translation hooks, format utilities, and completeness of translation files
- Integration tests: Verify that components render correctly with different locale contexts
- E2E tests: Verify that the full locale experience works — routing, detection, content, formatting
The translation completeness test is the most valuable to add first — it catches missing keys automatically before they reach users. From there, add format assertions for date/number display in your key components.