The i18n Testing Pyramid
Internationalization testing requires a structured approach that catches issues early while maintaining development velocity. The i18n testing pyramid adapts the traditional testing pyramid to localization-specific concerns, with unit tests forming the base (70%), integration tests in the middle (20%), and end-to-end tests at the top (10%). Unit tests validate that translation keys exist, pluralization logic works correctly, and date/number formatting functions produce expected outputs for different locales. Integration tests verify that components render correctly with various translation lengths, RTL layouts don't break UI components, and locale switching doesn't cause visual regressions. End-to-end tests ensure complete user journeys work across languages, including signup flows in German, checkout in Japanese, and support tickets in Arabic. This pyramid prioritizes fast, reliable tests at the bottom while using slower, more comprehensive tests sparingly at the top. Pseudo-localization sits outside the pyramid as a technique that enhances all levels, catching hardcoded strings and UI layout issues before real translations arrive. Effective i18n testing combines automated checks with manual review, balancing coverage with practicality.
Pseudo-Localization Explained
Pseudo-localization transforms source language strings into modified versions that simulate translation characteristics without actual translation, enabling developers to identify i18n issues before translations are available:
TypeScript1function pseudoLocalize(text: string): string { 2 // Character substitution (Latin → accented) 3 const charMap: Record<string, string> = { 4 'a': 'á', 'e': 'é', 'i': 'í', 'o': 'ó', 'u': 'ú', 5 'A': 'Á', 'E': 'É', 'I': 'Í', 'O': 'Ó', 'U': 'Ú', 6 'n': 'ñ', 'c': 'ç', 's': 'š' 7 }; 8 9 let result = text; 10 11 // Apply character substitution 12 Object.entries(charMap).forEach(([char, replacement]) => { 13 result = result.replace(new RegExp(char, 'g'), replacement); 14 }); 15 16 // Add expansion (typically 30% longer) 17 const expansion = ' '.repeat(Math.floor(text.length * 0.3)); 18 19 // Add brackets to identify boundaries 20 return `[!! ${result}${expansion} !!]`; 21} 22 23// Examples 24pseudoLocalize('Welcome'); // "[!! Wélçómé !!]" 25pseudoLocalize('Log in'); // "[!! Lóg íñ !!]" 26pseudoLocalize('Save changes'); // "[!! Sávé çháñgés !!]"
Benefits of Pseudo-Localization
Detects hardcoded strings: Any text without [!! ... !!] brackets is hardcoded:
TSX1// ❌ Hardcoded text will appear as "Submit" instead of "[!! Súbmít !!]" 2<button>Submit</button> 3 4// ✅ Localized text appears as "[!! Súbmít !!]" 5<button>{t('button.submit')}</button>
Reveals layout issues: 30% expansion simulates German/Finnish translations:
CSS1/* This button will overflow with pseudo-localized text */ 2.button { 3 width: 80px; /* Too narrow for "[!! Súbmít !!]" */ 4} 5 6/* Better approach */ 7.button { 8 padding: 8px 16px; 9 white-space: nowrap; 10}
Tests string concatenation: Exposes improper string building:
JavaScript1// ❌ BAD: This breaks in many languages 2const message = t('hello') + ', ' + userName + '!'; 3// Pseudo: "[!! Hélló !!], John!" (comma placement wrong for some languages) 4 5// ✅ GOOD: Use placeholders 6const message = t('greeting', { name: userName }); 7// Source: "Hello, {name}!" 8// Pseudo: "[!! Hélló, {name}! !!]" → "[!! Hélló, John! !!]"
Unit Testing i18n Functions
Testing Translation Key Existence
TypeScript1import { describe, it, expect } from 'vitest'; 2import en from './locales/en.json'; 3import es from './locales/es.json'; 4 5describe('Translation completeness', () => { 6 it('should have all keys in all languages', () => { 7 const enKeys = Object.keys(en); 8 const esKeys = Object.keys(es); 9 10 expect(enKeys.sort()).toEqual(esKeys.sort()); 11 }); 12 13 it('should not have empty translations', () => { 14 const emptyKeys = Object.entries(es) 15 .filter(([_, value]) => value === '') 16 .map(([key]) => key); 17 18 expect(emptyKeys).toEqual([]); 19 }); 20});
Testing Pluralization Logic
TypeScript1import { describe, it, expect } from 'vitest'; 2import { formatMessage } from './i18n'; 3 4describe('Plural rules', () => { 5 const testCases = [ 6 { count: 0, expected: '0 items' }, 7 { count: 1, expected: '1 item' }, 8 { count: 2, expected: '2 items' }, 9 { count: 5, expected: '5 items' } 10 ]; 11 12 testCases.forEach(({ count, expected }) => { 13 it(`should format ${count} items correctly`, () => { 14 const result = formatMessage('itemCount', { count }); 15 expect(result).toBe(expected); 16 }); 17 }); 18}); 19 20describe('Russian plural rules', () => { 21 const testCases = [ 22 { count: 1, expected: '1 файл' }, 23 { count: 2, expected: '2 файла' }, 24 { count: 5, expected: '5 файлов' }, 25 { count: 21, expected: '21 файл' }, 26 { count: 22, expected: '22 файла' } 27 ]; 28 29 testCases.forEach(({ count, expected }) => { 30 it(`should format ${count} for Russian`, () => { 31 const result = formatMessage('fileCount', { count }, 'ru'); 32 expect(result).toBe(expected); 33 }); 34 }); 35});
Testing Date/Number Formatting
TypeScript1import { describe, it, expect } from 'vitest'; 2 3describe('Date formatting', () => { 4 const date = new Date('2026-02-12T10:30:00Z'); 5 6 it('should format date for US locale', () => { 7 const formatted = new Intl.DateTimeFormat('en-US').format(date); 8 expect(formatted).toBe('2/12/2026'); 9 }); 10 11 it('should format date for DE locale', () => { 12 const formatted = new Intl.DateTimeFormat('de-DE').format(date); 13 expect(formatted).toBe('12.2.2026'); 14 }); 15}); 16 17describe('Number formatting', () => { 18 it('should format currency correctly', () => { 19 const amount = 1234.56; 20 21 expect(new Intl.NumberFormat('en-US', { 22 style: 'currency', 23 currency: 'USD' 24 }).format(amount)).toBe('$1,234.56'); 25 26 expect(new Intl.NumberFormat('de-DE', { 27 style: 'currency', 28 currency: 'EUR' 29 }).format(amount)).toBe('1.234,56 €'); 30 }); 31});
Visual Regression Testing
Screenshot Testing with Playwright
TypeScript1import { test, expect } from '@playwright/test'; 2 3const locales = ['en', 'es', 'de', 'ar', 'ja']; 4 5test.describe('Visual regression across locales', () => { 6 locales.forEach(locale => { 7 test(`homepage in ${locale}`, async ({ page }) => { 8 await page.goto(`/?lang=${locale}`); 9 await expect(page).toHaveScreenshot(`homepage-${locale}.png`); 10 }); 11 12 test(`dashboard in ${locale}`, async ({ page }) => { 13 await page.goto(`/dashboard?lang=${locale}`); 14 await expect(page).toHaveScreenshot(`dashboard-${locale}.png`); 15 }); 16 }); 17});
Component-Level Visual Testing
TypeScript1import { test } from '@playwright/test'; 2import { mount } from '@playwright/experimental-ct-react'; 3import { IntlProvider } from 'react-intl'; 4import Button from './Button'; 5 6test.describe('Button component', () => { 7 test('should render in all locales', async ({ mount }) => { 8 const messages = { 9 'button.submit': 'Submit' 10 }; 11 12 const component = await mount( 13 <IntlProvider locale="en" messages={messages}> 14 <Button id="button.submit" /> 15 </IntlProvider> 16 ); 17 18 await expect(component).toHaveScreenshot('button-en.png'); 19 }); 20 21 test('should handle long German text', async ({ mount }) => { 22 const messages = { 23 'button.submit': 'Einreichen und Bestätigen' 24 }; 25 26 const component = await mount( 27 <IntlProvider locale="de" messages={messages}> 28 <Button id="button.submit" /> 29 </IntlProvider> 30 ); 31 32 await expect(component).toHaveScreenshot('button-de.png'); 33 }); 34});
String Expansion Testing
Languages expand at different rates. German and Finnish often 30-40% longer than English:
TypeScript1import { test, expect } from '@playwright/test'; 2 3test.describe('String expansion handling', () => { 4 test('button should not overflow', async ({ page }) => { 5 await page.goto('/?lang=de'); 6 7 const button = page.locator('.submit-button'); 8 const box = await button.boundingBox(); 9 10 // Check button doesn't overflow container 11 const parent = await button.locator('..').boundingBox(); 12 expect(box!.width).toBeLessThanOrEqual(parent!.width); 13 14 // Check text doesn't truncate 15 await expect(button).toHaveText(/Einreichen/); 16 }); 17 18 test('navigation labels should wrap gracefully', async ({ page }) => { 19 await page.goto('/?lang=fi'); 20 21 const navItems = page.locator('.nav-item'); 22 for (const item of await navItems.all()) { 23 const height = await item.evaluate(el => el.offsetHeight); 24 // Multi-line is OK, but should be consistent 25 expect(height).toBeGreaterThan(0); 26 } 27 }); 28});
RTL Testing
Automated RTL Layout Verification
TypeScript1import { test, expect } from '@playwright/test'; 2 3test.describe('RTL layout', () => { 4 test('should mirror navigation in Arabic', async ({ page }) => { 5 await page.goto('/?lang=ar'); 6 7 // Check document direction 8 const dir = await page.evaluate(() => document.documentElement.dir); 9 expect(dir).toBe('rtl'); 10 11 // Check text alignment 12 const heading = page.locator('h1'); 13 const textAlign = await heading.evaluate( 14 el => window.getComputedStyle(el).textAlign 15 ); 16 expect(textAlign).toBe('right'); 17 18 // Screenshot for visual verification 19 await expect(page).toHaveScreenshot('homepage-rtl.png'); 20 }); 21 22 test('should flip back button in RTL', async ({ page }) => { 23 await page.goto('/details?lang=ar'); 24 25 const backButton = page.locator('.back-button svg'); 26 const transform = await backButton.evaluate( 27 el => window.getComputedStyle(el).transform 28 ); 29 30 // Should contain scaleX(-1) for horizontal flip 31 expect(transform).toContain('matrix(-1'); 32 }); 33});
Accessibility Testing for i18n
TypeScript1import { test, expect } from '@playwright/test'; 2import AxeBuilder from '@axe-core/playwright'; 3 4test.describe('Accessibility across locales', () => { 5 const locales = ['en', 'es', 'de', 'ar']; 6 7 locales.forEach(locale => { 8 test(`should have no a11y violations in ${locale}`, async ({ page }) => { 9 await page.goto(`/?lang=${locale}`); 10 11 const results = await new AxeBuilder({ page }).analyze(); 12 13 expect(results.violations).toEqual([]); 14 }); 15 16 test(`should have correct lang attribute in ${locale}`, async ({ page }) => { 17 await page.goto(`/?lang=${locale}`); 18 19 const lang = await page.evaluate(() => document.documentElement.lang); 20 expect(lang).toBe(locale); 21 }); 22 }); 23});
CI/CD Integration
GitHub Actions Workflow
YAML1name: i18n Tests 2 3on: [push, pull_request] 4 5jobs: 6 i18n-tests: 7 runs-on: ubuntu-latest 8 9 steps: 10 - uses: actions/checkout@v3 11 - uses: actions/setup-node@v3 12 with: 13 node-version: '18' 14 15 - name: Install dependencies 16 run: npm ci 17 18 - name: Check translation completeness 19 run: npm run i18n:check 20 21 - name: Run unit tests 22 run: npm test 23 24 - name: Install Playwright 25 run: npx playwright install --with-deps 26 27 - name: Run visual regression tests 28 run: npm run test:visual 29 30 - name: Upload screenshots 31 if: always() 32 uses: actions/upload-artifact@v3 33 with: 34 name: screenshots 35 path: test-results/
Translation Completeness Check
TypeScript1// scripts/check-translations.ts 2import fs from 'fs'; 3import path from 'path'; 4 5const LOCALES_DIR = './src/locales'; 6const languages = ['en', 'es', 'de', 'fr', 'ar', 'ja']; 7 8function loadTranslations(lang: string) { 9 const filePath = path.join(LOCALES_DIR, `${lang}.json`); 10 return JSON.parse(fs.readFileSync(filePath, 'utf-8')); 11} 12 13function flattenKeys(obj: any, prefix = ''): string[] { 14 const keys: string[] = []; 15 16 Object.entries(obj).forEach(([key, value]) => { 17 const fullKey = prefix ? `${prefix}.${key}` : key; 18 19 if (typeof value === 'string') { 20 keys.push(fullKey); 21 } else { 22 keys.push(...flattenKeys(value, fullKey)); 23 } 24 }); 25 26 return keys; 27} 28 29const en = loadTranslations('en'); 30const enKeys = flattenKeys(en).sort(); 31 32let hasErrors = false; 33 34languages.filter(lang => lang !== 'en').forEach(lang => { 35 const translations = loadTranslations(lang); 36 const langKeys = flattenKeys(translations).sort(); 37 38 const missing = enKeys.filter(key => !langKeys.includes(key)); 39 const extra = langKeys.filter(key => !enKeys.includes(key)); 40 41 if (missing.length > 0) { 42 console.error(`❌ ${lang}: Missing keys:`, missing); 43 hasErrors = true; 44 } 45 46 if (extra.length > 0) { 47 console.error(`⚠️ ${lang}: Extra keys:`, extra); 48 } 49}); 50 51if (hasErrors) { 52 process.exit(1); 53} else { 54 console.log('✅ All translations complete'); 55}
IntlPull's Visual Context Feature
IntlPull provides screenshot-based context for translators, which also serves as a testing tool:
- Automated Screenshots: Capture UI screenshots for every translation key
- Visual Diff: Compare translations side-by-side with screenshots
- Length Warnings: Flag translations that exceed UI constraints
- RTL Preview: Toggle RTL view to verify layout
- Context Validation: Ensure translations make sense in their visual context
When developers upload screenshots to IntlPull, the platform automatically validates that translations fit within the captured UI boundaries, warning translators when text might overflow or truncate.
FAQ
Q: Should I test every language or just a subset? A: Test thoroughly for 2-3 representative languages (English, German for expansion, Arabic for RTL). Run smoke tests for all languages to catch missing translations.
Q: How often should I run visual regression tests? A: Run on every PR for critical pages. Run full suite nightly or weekly to catch gradual regressions.
Q: What's the best way to handle flaky screenshot tests? A: Use consistent viewport sizes, wait for fonts to load, disable animations, and use threshold-based comparison (allow 1-2% pixel difference).
Q: How do I test translations I don't understand? A: Use pseudo-localization for structural issues. For actual content quality, rely on native-speaking reviewers or professional QA services.
Q: Should I test machine translations? A: Yes, but focus on technical correctness (no broken UI, proper encoding) rather than linguistic quality. Have human reviewers validate MT output.
Q: How do I test locale switching without full page reloads? A: Write integration tests that trigger locale changes via UI controls, then verify DOM updates correctly without reloading. Check that stateful components maintain data across switches.
Q: What tools does IntlPull provide for testing? A: IntlPull offers pseudo-locale generation, screenshot comparison, translation completeness checks, and RTL preview—all integrated into the translation workflow.
