How to Test Internationalization (i18n) in Web Apps

How to Test Internationalization (i18n) in Web Apps

Internationalization testing is one of those areas that teams defer until a customer reports that dates are backwards or buttons overflow in German. By then, the cost of fixing it is much higher than getting ahead of it. This guide covers the fundamentals of i18n testing across the three most common frameworks—i18next, react-intl, and Angular i18n—and gives you a structure you can plug into your existing CI pipeline.

What i18n Testing Actually Covers

Internationalization testing is not just checking that translated strings appear. It encompasses:

  • Translation key coverage — every key used in code has a corresponding translation in every locale
  • Pluralization correctness — "1 item" vs "2 items" vs edge cases like Arabic, which has six plural forms
  • Date and number formatting1,234.56 in English vs 1.234,56 in German
  • String length and layout — German text is typically 30% longer than English; UI must not break
  • RTL layout — Arabic and Hebrew require mirrored layouts
  • Locale switching at runtime — state must update without a full page reload

Setting Up i18next for Testability

i18next is the most widely used i18n library in the JavaScript ecosystem. Making it testable starts with initialization.

// i18n.js
import i18next from 'i18next';
import Backend from 'i18next-http-backend';

export async function initI18n(lng = 'en') {
  await i18next.init({
    lng,
    fallbackLng: 'en',
    ns: ['common', 'errors'],
    defaultNS: 'common',
    backend: {
      loadPath: '/locales/{{lng}}/{{ns}}.json',
    },
  });
  return i18next;
}

In tests, avoid the HTTP backend and load translations directly:

// i18n.test-setup.js
import i18next from 'i18next';
import enCommon from '../locales/en/common.json';
import deCommon from '../locales/de/common.json';

beforeAll(async () => {
  await i18next.init({
    lng: 'en',
    fallbackLng: 'en',
    resources: {
      en: { common: enCommon },
      de: { common: deCommon },
    },
  });
});

This eliminates network dependency and makes tests deterministic.

Testing Key Existence

The most fundamental i18n test: every key used in code must exist in every locale's translation file.

import enTranslations from '../locales/en/common.json';
import deTranslations from '../locales/de/common.json';
import frTranslations from '../locales/fr/common.json';

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 key coverage', () => {
  const enKeys = getAllKeys(enTranslations);
  const deKeys = getAllKeys(deTranslations);
  const frKeys = getAllKeys(frTranslations);

  test('German has all English keys', () => {
    const missing = enKeys.filter(k => !deKeys.includes(k));
    expect(missing).toEqual([]);
  });

  test('French has all English keys', () => {
    const missing = enKeys.filter(k => !frKeys.includes(k));
    expect(missing).toEqual([]);
  });
});

Run this in CI on every pull request. Missing keys in a target locale fail the build before they ever reach production.

Testing with react-intl

react-intl uses a <IntlProvider> wrapper that must be present in every component test. Create a helper once and reuse it everywhere:

// test-utils.jsx
import { render } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import enMessages from '../locales/en.json';

export function renderWithIntl(ui, { locale = 'en', messages = enMessages } = {}) {
  return render(
    <IntlProvider locale={locale} messages={messages}>
      {ui}
    </IntlProvider>
  );
}

Now tests are clean:

import { renderWithIntl } from './test-utils';
import { screen } from '@testing-library/react';
import ProductCard from './ProductCard';

test('shows formatted price in English', () => {
  renderWithIntl(<ProductCard price={1234.5} />, { locale: 'en' });
  expect(screen.getByText('$1,234.50')).toBeInTheDocument();
});

test('shows formatted price in German', () => {
  renderWithIntl(<ProductCard price={1234.5} />, {
    locale: 'de',
    messages: deMessages,
  });
  expect(screen.getByText('1.234,50 €')).toBeInTheDocument();
});

Testing Pluralization

import { renderWithIntl } from './test-utils';
import { screen } from '@testing-library/react';
import ItemCount from './ItemCount';

test.each([
  [0, '0 items'],
  [1, '1 item'],
  [2, '2 items'],
  [100, '100 items'],
])('displays correct plural form for count=%i', (count, expected) => {
  renderWithIntl(<ItemCount count={count} />);
  expect(screen.getByText(expected)).toBeInTheDocument();
});

Testing Angular i18n

Angular uses compile-time i18n by default, which complicates testing because translations are baked into the build. For unit tests, use @angular/localize at runtime instead.

// app.module.ts — for testing with runtime i18n
import '@angular/localize/init';

In TestBed, set the locale explicitly:

import { TestBed } from '@angular/core/testing';
import { LOCALE_ID } from '@angular/core';
import { registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';

describe('PriceComponent with de locale', () => {
  beforeEach(() => {
    registerLocaleData(localeDe);
    TestBed.configureTestingModule({
      declarations: [PriceComponent],
      providers: [{ provide: LOCALE_ID, useValue: 'de' }],
    });
  });

  it('formats price in German locale', () => {
    const fixture = TestBed.createComponent(PriceComponent);
    fixture.componentInstance.amount = 1234.5;
    fixture.detectChanges();
    expect(fixture.nativeElement.textContent).toContain('1.234,50');
  });
});

Testing Angular Pipes

The DatePipe, CurrencyPipe, and DecimalPipe all respect LOCALE_ID. Test them directly:

import { DatePipe } from '@angular/common';

describe('DatePipe with different locales', () => {
  it('formats date in US format', () => {
    const pipe = new DatePipe('en-US');
    const result = pipe.transform(new Date('2026-01-15'), 'shortDate');
    expect(result).toBe('1/15/26');
  });

  it('formats date in European format', () => {
    const pipe = new DatePipe('de-DE');
    const result = pipe.transform(new Date('2026-01-15'), 'shortDate');
    expect(result).toBe('15.01.26');
  });
});

CI Integration

Add i18n tests to your CI pipeline as a dedicated step:

# .github/workflows/ci.yml
jobs:
  i18n-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - name: Run i18n key coverage tests
        run: npm test -- --testPathPattern=i18n
      - name: Run unit tests with locale providers
        run: npm test -- --testPathPattern=intl

For visual regression of layout changes across locales, tools like Playwright support locale configuration at the browser level:

// playwright.config.js
export default defineConfig({
  projects: [
    { name: 'en', use: { locale: 'en-US' } },
    { name: 'de', use: { locale: 'de-DE' } },
    { name: 'ar', use: { locale: 'ar-SA', timezoneId: 'Asia/Riyadh' } },
  ],
});

Common i18n Testing Failures

Missing fallback locale: When a key is missing in a target locale, many libraries silently fall back to the key name or English. Your test suite should assert the actual translation appears, not just that something renders.

Hardcoded strings in tests: Tests that assert exact English text will fail when locale changes. Use translation keys or locale-aware matchers.

Timezone assumptions: new Date() in CI often runs in UTC. Tests that check formatted dates against local timezone expectations will fail. Always use fixed dates in tests.

Currency symbol placement: $1,234.50 and 1,234.50 $ are both valid for different locales. Verify the exact format your locale requires.

Summary

A robust i18n test strategy has three layers: key coverage tests that verify no translation is missing, unit tests for locale-sensitive components and pipes, and CI configuration that runs the same tests across multiple locales. Getting this right early makes adding new languages a pull request rather than a project.

Read more