IntlPull
Technical
11 min read

i18n Testing Strategies: How to Test Localized Applications

Complete guide to i18n testing strategies. Learn pseudo-localization, visual regression testing, automated QA, and CI/CD integration for localized apps.

IntlPull Team
IntlPull Team
Feb 12, 2026
On this page
Summary

Complete guide to i18n testing strategies. Learn pseudo-localization, visual regression testing, automated QA, and CI/CD integration for localized apps.

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:

TypeScript
1function 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:

TSX
1// ❌ 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:

CSS
1/* 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:

JavaScript
1// ❌ 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

TypeScript
1import { 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

TypeScript
1import { 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

TypeScript
1import { 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

TypeScript
1import { 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

TypeScript
1import { 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:

TypeScript
1import { 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

TypeScript
1import { 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

TypeScript
1import { 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

YAML
1name: 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

TypeScript
1// 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.

Tags
testing
i18n
qa
localization-testing
pseudo-localization
automation
IntlPull Team
IntlPull Team
Engineering

Building tools to help teams ship products globally. Follow us for more insights on localization and i18n.