Testing Internationalization in React and Next.js Apps

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.NumberFormat formats 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:

  1. Unit tests: Verify your translation hooks, format utilities, and completeness of translation files
  2. Integration tests: Verify that components render correctly with different locale contexts
  3. 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.

Read more

Start now free