Skip to content

i18n Testing — Testing Internationalized Applications

DodaTech Updated 2026-06-28 10 min read

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

  1. 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.
  2. Using snapshot tests without locale prefix. Snapshot tests overwrite per locale. Use separate snapshot files per locale or inline snapshots keyed by locale name.
  3. 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.
  4. 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.
  5. 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

  1. How do you test that all locales have the same translation keys?
  2. Why should you snapshot test components per locale?
  3. How do you test RTL layout programmatically?
  4. How can you verify placeholder consistency across translations?
  5. 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

How do I test translations without loading all locale files?

Use a mock i18next instance in unit tests with only the keys needed for that test. For integration tests, load all locales once in beforeAll and reuse the instance across tests.

Should I test every locale in every test?

No. Unit tests can use any single locale. Integration and E2E tests should test all locales in a matrix. Use test.each or a locale loop to run the same test against multiple locales.

How do I test RTL layout without a real browser?

Use jsdom with the dir attribute set to 'rtl'. For visual checks, use Playwright or Puppeteer with actual RTL content. CSS logical properties should work the same in both directions with the correct dir attribute.

How do I prevent translation regressions in CI?

Add a lint step that checks translation completeness (all locales have all keys) and placeholder consistency (same variables in all translations). Fail the CI build if issues are found.

What's the best way to test locale detection?

Mock navigator.language and navigator.languages in unit tests. For E2E tests, use Playwright's emulation: await page.context().addInitScript(() => { Object.defineProperty(navigator, 'language', { get: () => 'fr' }); });

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