i18n Testing — Testing Internationalized Applications
In this tutorial, you will learn about i18n testing. We cover key concepts, practical examples, and best practices to help you master this topic.
i18n testing verifies that multilingual applications display correct translations, handle locale-dependent formatting, and work across all supported locales.
What You'll Learn
By the end of this tutorial, you'll understand how to write automated tests for i18n functionality, how to test translations for completeness and correctness, how to perform visual regression testing across locales, and how to test RTL layouts and locale-specific formatting.
Why It Matters
A single untested locale can break your application in ways that aren't caught by standard tests. A missing translation key shows technical identifiers to users. A poorly tested RTL layout might hide navigation controls. A date format assumption that only works in English breaks appointment scheduling for German users. Comprehensive i18n testing prevents these issues from reaching production.
Real-World Use
A fintech app with 6 locales runs automated i18n tests on every commit: translation completeness checks (no missing keys), snapshot tests for each locale's output, RTL layout tests using Puppeteer, and formatting tests that verify dates, currencies, and numbers match locale expectations. A regression that broke Arabic date formatting was caught in CI, not in production.
i18n Testing Pyramid
graph LR
A[i18n Testing
Pyramid] --> B[Unit Tests
Translation resolution, formatters]
A --> C[Integration Tests
Components render with locales]
A --> D[E2E Tests
Full locale switching flow]
A --> E[Visual Tests
RTL layout, screenshot diffs]
A --> F[Linting
Missing keys, unused translations]
B --> G[Jest, Vitest
Fast, many tests]
C --> H[Testing Library
Per-locale assertions]
D --> I[Playwright, Cypress
Full user journeys]
E --> J[Percy, Chromatic
Screenshot comparisons]
F --> K[i18next-scanner,
eslint-plugin-i18n]
style A fill:#4a90d9,color:#fff
style B fill:#27ae60,color:#fff
style F fill:#f39c12,color:#fff
Unit Testing Translations
// tests/i18n/translations.test.js — Test translation completeness
import i18next from 'i18next';
import { initI18n } from '../../i18n/setup';
describe('Translation completeness', () => {
beforeAll(async () => {
await initI18n();
});
const locales = ['en', 'es', 'fr', 'de', 'ar', 'ja'];
// Test that all locales have the same keys
test('all locales have matching keys', () => {
const enKeys = Object.keys(i18next.getResourceBundle('en', 'translation')).sort();
const enNestedKeys = flattenKeys(i18next.getResourceBundle('en', 'translation'));
locales.forEach(locale => {
if (locale === 'en') return;
const localeBundle = i18next.getResourceBundle(locale, 'translation');
const localeKeys = flattenKeys(localeBundle);
const missing = enNestedKeys.filter(k => !localeKeys.includes(k));
const extra = localeKeys.filter(k => !enNestedKeys.includes(k));
expect(missing).toEqual([]);
expect(extra).toEqual([]);
});
});
// Test that translations don't contain technical placeholders
test('no missing interpolation variables', () => {
locales.forEach(locale => {
const bundle = i18next.getResourceBundle(locale, 'translation');
const keys = flattenKeys(bundle);
keys.forEach(key => {
const value = getValue(bundle, key);
// Check for unresolved placeholders
const missingVars = value.match(/\{\{(\w+)\}\}/g);
if (missingVars) {
// Variables should be present in the expected variable list
// or have defaults
}
});
});
});
// Test date formatting for each locale
test('date formatting works for all locales', () => {
const testDate = new Date('2026-12-31T15:00:00');
locales.forEach(locale => {
const formatted = new Intl.DateTimeFormat(locale, {
dateStyle: 'full'
}).format(testDate);
expect(formatted).toBeTruthy();
expect(formatted).not.toContain('NaN');
expect(formatted).not.toContain('undefined');
});
});
// Test number formatting for each locale
test('number formatting works for all locales', () => {
const testNumber = 1234567.89;
locales.forEach(locale => {
const formatted = new Intl.NumberFormat(locale).format(testNumber);
expect(formatted).toBeTruthy();
expect(formatted).not.toContain('NaN');
});
});
// Test currency formatting
test('currency formatting works for all locales', () => {
locales.forEach(locale => {
const formatted = new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'USD'
}).format(1234.56);
expect(formatted).toBeTruthy();
expect(formatted).not.toContain('NaN');
});
});
// Helper: flatten nested keys
function flattenKeys(obj, prefix = '') {
return Object.keys(obj).reduce((acc, key) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof obj[key] === 'object' && obj[key] !== null) {
acc.push(...flattenKeys(obj[key], fullKey));
} else {
acc.push(fullKey);
}
return acc;
}, []);
}
function getValue(obj, path) {
return path.split('.').reduce((current, key) => current?.[key], obj);
}
});
Locale-Specific Snapshot Testing
// tests/i18n/component-snapshots.test.jsx — Snapshot test per locale
import { render } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import i18next from 'i18next';
import { initI18n } from '../../i18n/setup';
import ProductCard from '../../components/ProductCard';
describe('ProductCard per locale', () => {
beforeAll(async () => {
await initI18n();
});
const locales = ['en', 'es', 'fr', 'de', 'ar', 'ja'];
const product = {
name: 'Test Product',
price: 29.99,
stockCount: 5,
releaseDate: new Date('2026-06-28'),
inStock: true,
description: 'A test product description.'
};
locales.forEach(locale => {
test(`renders correctly in ${locale}`, () => {
// Change i18next locale
i18next.changeLanguage(locale);
const { container } = render(
<I18nextProvider i18n={i18next}>
<ProductCard product={product} />
</I18nextProvider>
);
expect(container).toMatchSnapshot(locale);
});
});
// Test that prices are formatted correctly per locale
test('displays correct currency format for each locale', () => {
const priceFormats = {
'en': /\$/,
'de': /€/,
'fr': /€/,
'ja': /¥/,
'ar': /ر\.س/,
};
Object.entries(priceFormats).forEach(([locale, symbol]) => {
i18next.changeLanguage(locale);
const { getByText } = render(
<I18nextProvider i18n={i18next}>
<ProductCard product={product} />
</I18nextProvider>
);
// Check that the correct currency symbol appears
const priceElement = getByText(symbol);
expect(priceElement).toBeInTheDocument();
});
});
});
E2e Testing Locale Switching
// tests/e2e/locale-switching.spec.js — Playwright locale tests
const { test, expect } = require('@playwright/test');
const locales = ['en', 'es', 'fr', 'de', 'ar', 'ja'];
test.describe('Locale switching', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
// Test that locale switcher exists and works
test('locale switcher changes page language', async ({ page }) => {
const switcher = page.locator('[aria-label="Select language"]');
await expect(switcher).toBeVisible();
// Switch to Spanish
await switcher.selectOption('es');
await page.waitForURL(/\/es\//);
// Verify page content is in Spanish
await expect(page.locator('h1')).toContainText(/Bienvenido|Productos/);
});
// Test that switching locale doesn't break navigation
test('navigation works after locale switch', async ({ page }) => {
// Switch to Arabic
await page.locator('[aria-label="Select language"]').selectOption('ar');
await page.waitForURL(/\/ar\//);
// Navigate to products page
await page.locator('a:has-text("منتجات")').click();
await expect(page).toHaveURL(/\/ar\/products/);
// Verify RTL direction
const dir = await page.locator('html').getAttribute('dir');
expect(dir).toBe('rtl');
});
// Test URL-based locale detection
test('URL locale path sets correct language', async ({ page }) => {
await page.goto('/fr/products');
await expect(page.locator('html')).toHaveAttribute('lang', 'fr');
// Check French content
await expect(page.locator('h1')).toContainText(/Produits/);
});
// Test that all locales render without errors
locales.forEach(locale => {
test(`${locale} locale renders homepage without errors`, async ({ page }) => {
const errors = [];
page.on('pageerror', error => errors.push(error.message));
await page.goto(`/${locale}/`);
await page.waitForLoadState('networkidle');
expect(errors).toEqual([]);
expect(page.locator('h1')).toBeVisible();
});
});
});
Visual Regression Testing for RTL
// tests/visual/rtl-snapshots.spec.js — Visual diff testing for RTL
const { test, expect } = require('@playwright/test');
test.describe('RTL visual regression', () => {
test.beforeEach(async ({ page }) => {
// Set viewport to standard desktop size
await page.setViewportSize({ width: 1280, height: 800 });
});
// Compare LTR vs RTL screenshots
test('LTR and RTL layouts are mirrored', async ({ page }) => {
// Take LTR screenshot
await page.goto('/en/products');
await page.waitForLoadState('networkidle');
await page.screenshot({ path: 'screenshots/en-products.png' });
// Take RTL screenshot
await page.goto('/ar/products');
await page.waitForLoadState('networkidle');
await page.screenshot({ path: 'screenshots/ar-products.png' });
// In a real test, use Percy or Chromatic for visual diffing
// This example just captures the screenshots
});
// Test that menu order is reversed in RTL
test('navigation items are reversed in RTL', async ({ page }) => {
await page.goto('/en/');
const enNavItems = await page.locator('nav a').allTextContents();
await page.goto('/ar/');
const arNavItems = await page.locator('nav a').allTextContents();
// Navigation should be visually mirrored
expect(arNavItems).toEqual([...enNavItems].reverse());
});
// Test RTL layout at mobile breakpoint
test('RTL layout works on mobile', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/ar/');
await page.waitForLoadState('networkidle');
// Verify no horizontal overflow
const overflow = await page.evaluate(() => {
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
});
expect(overflow).toBe(false);
});
});
Linting Translations
// scripts/lint-translations.js — Automated translation linting
const fs = require('fs-extra');
const path = require('path');
const glob = require('glob');
class TranslationLinter {
constructor(localesDir) {
this.localesDir = localesDir;
this.issues = [];
}
run() {
const localeFiles = glob.sync(`${this.localesDir}/*.json`);
const locales = localeFiles.map(f => path.basename(f, '.json'));
// Load all translations
const translations = {};
locales.forEach(locale => {
translations[locale] = fs.readJsonSync(
path.join(this.localesDir, `${locale}.json`)
);
});
const baseKeys = this.flattenKeys(translations[locales[0]]);
// Check each locale against the base
locales.forEach(locale => {
if (locale === locales[0]) return;
const localeKeys = this.flattenKeys(translations[locale]);
// Missing keys
const missing = baseKeys.filter(k => !localeKeys.includes(k));
if (missing.length > 0) {
this.issues.push({
locale,
type: 'missing_keys',
count: missing.length,
keys: missing
});
}
// Extra keys (unused)
const extra = localeKeys.filter(k => !baseKeys.includes(k));
if (extra.length > 0) {
this.issues.push({
locale,
type: 'extra_keys',
count: extra.length,
keys: extra
});
}
});
// Check for placeholder mismatch
this.checkPlaceholders(translations, locales);
return this.report();
}
checkPlaceholders(translations, locales) {
const base = locales[0];
const baseKeys = this.flattenKeys(translations[base]);
baseKeys.forEach(key => {
const basePlaceholders = this.extractPlaceholders(
this.getValue(translations[base], key)
);
locales.slice(1).forEach(locale => {
const localeValue = this.getValue(translations[locale], key);
if (!localeValue) return;
const localePlaceholders = this.extractPlaceholders(localeValue);
const missing = basePlaceholders.filter(p => !localePlaceholders.includes(p));
const extra = localePlaceholders.filter(p => !basePlaceholders.includes(p));
if (missing.length > 0 || extra.length > 0) {
this.issues.push({
locale,
key,
type: 'placeholder_mismatch',
missing,
extra
});
}
});
});
}
flattenKeys(obj, prefix = '') {
return Object.keys(obj).reduce((acc, key) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof obj[key] === 'object' && obj[key] !== null) {
acc.push(...this.flattenKeys(obj[key], fullKey));
} else {
acc.push(fullKey);
}
return acc;
}, []);
}
getValue(obj, path) {
return path.split('.').reduce((current, key) => current?.[key], obj);
}
extractPlaceholders(str) {
if (typeof str !== 'string') return [];
const matches = str.match(/\{\{(\w+)\}\}/g);
return matches ? matches.map(m => m.replace(/\{\{|\}\}/g, '')) : [];
}
report() {
const summary = {
total: this.issues.length,
missing_keys: this.issues.filter(i => i.type === 'missing_keys').length,
extra_keys: this.issues.filter(i => i.type === 'extra_keys').length,
placeholder_mismatch: this.issues.filter(i => i.type === 'placeholder_mismatch').length,
issues: this.issues
};
if (summary.total === 0) {
console.log('✓ All translations clean');
} else {
console.log(`Found ${summary.total} translation issues:`);
this.issues.forEach(issue => {
console.log(` [${issue.locale}] ${issue.type}: ${issue.keys?.join(', ') || issue.key}`);
});
}
return summary;
}
}
// Run: node scripts/lint-translations.js locales/*.json
Common Mistakes
- Only testing in English during development. If you only develop in English, RTL bugs, date formatting issues, and pluralization errors will only appear in production. Test every feature in at least one non-English locale during development.
- Using snapshot tests without locale prefix. Snapshot tests overwrite per locale. Use separate snapshot files per locale or inline snapshots keyed by locale name.
- Not testing missing translation fallback. Configure your test environment to use the fallback locale and verify that the application doesn't crash when a translation key is missing.
- Hardcoding test data in one locale. Test data (dates, numbers, currencies) should be locale-agnostic in test setup. Generate test data programmatically so it works across all locales.
- Not including RTL in CI pipeline. RTL layout issues are invisible in LTR tests. Add at least one RTL locale (Arabic) to your CI test matrix to catch layout regressions.
Practice Questions
- How do you test that all locales have the same translation keys?
- Why should you snapshot test components per locale?
- How do you test RTL layout programmatically?
- How can you verify placeholder consistency across translations?
- What is the importance of testing locale switching in E2E tests?
Challenge: Build a comprehensive i18n test suite for a multilingual app with 5 locales. Include: unit tests for translation completeness and placeholder consistency, snapshot tests for 3 components per locale, E2E tests for locale switching and URL-based detection, visual regression tests for RTL layout, and a CI pipeline that runs all tests.
FAQ
Mini Project
Build a complete i18n test suite: translation linting script (missing keys, placeholder mismatch), locale snapshot tests for 3 components, E2E tests for locale switching and navigation, RTL visual regression tests, and a CI configuration that runs all tests on every push. Include both passing and intentionally broken test cases to demonstrate detection.
What's Next
You've mastered i18n testing. Now apply everything you've learned in the i18n Mini Project to build a complete multilingual web application.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro