# Internationalization (i18n) Testing ## Table of Contents 1. [Locale Configuration](#locale-configuration) 2. [Testing Multiple Locales](#testing-multiple-locales) 3. [RTL Layout Testing](#rtl-layout-testing) 4. [Date, Time & Number Formats](#date-time--number-formats) 5. [Translation Verification](#translation-verification) 6. [Visual Regression for i18n](#visual-regression-for-i18n) ## Locale Configuration ### Setting Browser Locale ```typescript // playwright.config.ts import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ projects: [ { name: "english", use: { ...devices["Desktop Chrome"], locale: "en-US", timezoneId: "America/New_York", }, }, { name: "german", use: { ...devices["Desktop Chrome"], locale: "de-DE", timezoneId: "Europe/Berlin", }, }, { name: "japanese", use: { ...devices["Desktop Chrome"], locale: "ja-JP", timezoneId: "Asia/Tokyo", }, }, { name: "arabic", use: { ...devices["Desktop Chrome"], locale: "ar-SA", timezoneId: "Asia/Riyadh", }, }, ], }); ``` ### Per-Test Locale Override ```typescript test("test in French locale", async ({ browser }) => { const context = await browser.newContext({ locale: "fr-FR", timezoneId: "Europe/Paris", }); const page = await context.newPage(); await page.goto("/"); // Verify French content await expect(page.getByRole("button", { name: "Connexion" })).toBeVisible(); await context.close(); }); ``` ### Accept-Language Header ```typescript test("server-side locale detection", async ({ browser }) => { const context = await browser.newContext({ locale: "es-ES", extraHTTPHeaders: { "Accept-Language": "es-ES,es;q=0.9,en;q=0.8", }, }); const page = await context.newPage(); await page.goto("/"); // Server should respond with Spanish content await expect(page.locator("html")).toHaveAttribute("lang", "es"); }); ``` ## Testing Multiple Locales ### Parameterized Locale Tests ```typescript const locales = [ { locale: "en-US", greeting: "Hello", button: "Sign In" }, { locale: "de-DE", greeting: "Hallo", button: "Anmelden" }, { locale: "fr-FR", greeting: "Bonjour", button: "Se connecter" }, { locale: "ja-JP", greeting: "こんにちは", button: "ログイン" }, ]; for (const { locale, greeting, button } of locales) { test(`login page in ${locale}`, async ({ browser }) => { const context = await browser.newContext({ locale }); const page = await context.newPage(); await page.goto("/login"); await expect(page.getByText(greeting)).toBeVisible(); await expect(page.getByRole("button", { name: button })).toBeVisible(); await context.close(); }); } ``` ### Locale Fixture ```typescript // fixtures/i18n.ts import { test as base } from "@playwright/test"; type LocaleFixtures = { localePage: (locale: string) => Promise; }; export const test = base.extend({ localePage: async ({ browser }, use) => { const pages: Page[] = []; const createLocalePage = async (locale: string) => { const context = await browser.newContext({ locale }); const page = await context.newPage(); pages.push(page); return page; }; await use(createLocalePage); // Cleanup for (const page of pages) { await page.context().close(); } }, }); // Usage test("compare locales", async ({ localePage }) => { const enPage = await localePage("en-US"); const dePage = await localePage("de-DE"); await enPage.goto("/pricing"); await dePage.goto("/pricing"); const enPrice = await enPage.getByTestId("price").textContent(); const dePrice = await dePage.getByTestId("price").textContent(); expect(enPrice).toContain("$"); expect(dePrice).toContain("€"); }); ``` ### Testing Locale Switching ```typescript test("user can switch locale", async ({ page }) => { await page.goto("/"); // Initial locale (from browser) await expect(page.locator("html")).toHaveAttribute("lang", "en"); // Switch to German await page.getByRole("button", { name: "Language" }).click(); await page.getByRole("menuitem", { name: "Deutsch" }).click(); // Verify switch await expect(page.locator("html")).toHaveAttribute("lang", "de"); await expect(page.getByRole("heading", { level: 1 })).toContainText( /Willkommen/, ); // Verify persistence (reload) await page.reload(); await expect(page.locator("html")).toHaveAttribute("lang", "de"); }); ``` ## RTL Layout Testing ### Setting Up RTL Tests ```typescript // playwright.config.ts export default defineConfig({ projects: [ { name: "rtl-arabic", use: { locale: "ar-SA", // RTL is usually set by the app based on locale }, }, { name: "rtl-hebrew", use: { locale: "he-IL", }, }, ], }); ``` ### Verifying RTL Direction ```typescript test("RTL layout is applied", async ({ page }) => { await page.goto("/"); // Check document direction await expect(page.locator("html")).toHaveAttribute("dir", "rtl"); // Or check computed style const direction = await page.evaluate(() => { return window.getComputedStyle(document.body).direction; }); expect(direction).toBe("rtl"); }); ``` ### RTL-Specific Element Positioning ```typescript test("sidebar is on the right in RTL", async ({ page }) => { await page.goto("/dashboard"); const sidebar = page.getByTestId("sidebar"); const main = page.getByTestId("main-content"); const sidebarBox = await sidebar.boundingBox(); const mainBox = await main.boundingBox(); // In RTL, sidebar should be to the right of main content expect(sidebarBox!.x).toBeGreaterThan(mainBox!.x); }); ``` ### RTL Visual Regression ```typescript test("RTL layout matches snapshot", async ({ page }) => { await page.goto("/"); // Screenshot for RTL comparison await expect(page).toHaveScreenshot("homepage-rtl.png", { // Separate snapshots per locale/direction fullPage: true, }); }); // LTR comparison test("LTR layout matches snapshot", async ({ browser }) => { const context = await browser.newContext({ locale: "en-US" }); const page = await context.newPage(); await page.goto("/"); await expect(page).toHaveScreenshot("homepage-ltr.png", { fullPage: true }); }); ``` ### Testing Bidirectional Text ```typescript test("bidirectional text renders correctly", async ({ page }) => { await page.goto("/profile"); // Mixed LTR/RTL content const nameField = page.getByTestId("full-name"); // Arabic name with English email await expect(nameField).toContainText("محمد (mohammed@example.com)"); // Verify text doesn't overlap or break const box = await nameField.boundingBox(); expect(box!.width).toBeGreaterThan(100); // Content not collapsed }); ``` ## Date, Time & Number Formats ### Testing Date Formats ```typescript test("dates are formatted per locale", async ({ browser }) => { const testDate = new Date("2024-03-15"); const formats = [ { locale: "en-US", expected: "March 15, 2024" }, { locale: "en-GB", expected: "15 March 2024" }, { locale: "de-DE", expected: "15. März 2024" }, { locale: "ja-JP", expected: "2024年3月15日" }, ]; for (const { locale, expected } of formats) { const context = await browser.newContext({ locale }); const page = await context.newPage(); await page.goto(`/event?date=${testDate.toISOString()}`); const dateDisplay = page.getByTestId("event-date"); await expect(dateDisplay).toContainText(expected); await context.close(); } }); ``` ### Testing Number Formats ```typescript test("numbers are formatted per locale", async ({ browser }) => { const testNumber = 1234567.89; const formats = [ { locale: "en-US", expected: "1,234,567.89" }, { locale: "de-DE", expected: "1.234.567,89" }, { locale: "fr-FR", expected: "1 234 567,89" }, ]; for (const { locale, expected } of formats) { const context = await browser.newContext({ locale }); const page = await context.newPage(); await page.goto(`/stats?value=${testNumber}`); await expect(page.getByTestId("formatted-number")).toHaveText(expected); await context.close(); } }); ``` ### Testing Currency Formats ```typescript test("currency displays correctly", async ({ browser }) => { const price = 99.99; const currencies = [ { locale: "en-US", currency: "USD", expected: "$99.99" }, { locale: "de-DE", currency: "EUR", expected: "99,99 €" }, { locale: "ja-JP", currency: "JPY", expected: "¥100" }, // JPY has no decimals { locale: "en-GB", currency: "GBP", expected: "£99.99" }, ]; for (const { locale, currency, expected } of currencies) { const context = await browser.newContext({ locale }); const page = await context.newPage(); await page.goto(`/product?price=${price}¤cy=${currency}`); await expect(page.getByTestId("price")).toContainText(expected); await context.close(); } }); ``` ## Translation Verification ### Checking for Missing Translations ```typescript test("no missing translations", async ({ page }) => { await page.goto("/"); // Common patterns for missing translations const missingPatterns = [ /\{\{.*\}\}/, // Handlebars-style /\$\{.*\}/, // Template literal style /t\(["'][\w.]+["']\)/, // i18n key exposed /MISSING_TRANSLATION/, // Common placeholder /\[UNTRANSLATED\]/, // Another placeholder ]; const bodyText = await page.locator("body").textContent(); for (const pattern of missingPatterns) { expect(bodyText).not.toMatch(pattern); } }); ``` ### Detecting Text Overflow ```typescript test("translations fit UI containers", async ({ browser }) => { const locales = ["en-US", "de-DE", "fr-FR", "es-ES"]; const issues: string[] = []; for (const locale of locales) { const context = await browser.newContext({ locale }); const page = await context.newPage(); await page.goto("/"); const overflowing = await page.evaluate(() => { const elements = document.querySelectorAll("button, .label, h1, h2, h3"); return Array.from(elements) .filter( (el) => (el as HTMLElement).scrollWidth > (el as HTMLElement).clientWidth, ) .map((el) => `${el.tagName}: "${el.textContent?.substring(0, 20)}..."`); }); if (overflowing.length > 0) issues.push(`${locale}: ${overflowing.join(", ")}`); await context.close(); } expect(issues).toEqual([]); }); ``` ## Visual Regression for i18n ### Locale-Specific Snapshots ```typescript // playwright.config.ts export default defineConfig({ snapshotPathTemplate: "{testDir}/__snapshots__/{projectName}/{testFilePath}/{arg}{ext}", projects: [ { name: "en-US", use: { locale: "en-US" } }, { name: "de-DE", use: { locale: "de-DE" } }, { name: "ja-JP", use: { locale: "ja-JP" } }, { name: "ar-SA", use: { locale: "ar-SA" } }, ], }); ``` ```typescript // test file test("homepage visual", async ({ page }) => { await page.goto("/"); // Snapshot auto-saved to {projectName}/homepage.png await expect(page).toHaveScreenshot("homepage.png"); }); ``` ### Critical Element Screenshots ```typescript test("navigation in all locales", async ({ page }) => { await page.goto("/"); // Just the nav - catches overflow, truncation const nav = page.getByRole("navigation"); await expect(nav).toHaveScreenshot("navigation.png"); }); test("buttons dont truncate", async ({ page }) => { await page.goto("/checkout"); const ctaButton = page.getByRole("button", { name: /checkout|kaufen|acheter/i, }); await expect(ctaButton).toHaveScreenshot("checkout-button.png"); }); ``` ### Font Loading for i18n ```typescript test("wait for fonts before screenshot", async ({ page }) => { await page.goto("/"); // Wait for fonts (important for CJK, Arabic) await page.evaluate(() => document.fonts.ready); await page.waitForFunction(() => document.fonts.check("16px 'Noto Sans Arabic'"), ); await expect(page).toHaveScreenshot("with-fonts.png"); }); ``` ## Anti-Patterns to Avoid | Anti-Pattern | Problem | Solution | | ------------------------- | ------------------------------- | ------------------------------- | | Hardcoded text assertions | Breaks in other locales | Use test IDs or parameterize | | Single locale testing | Misses i18n bugs | Test multiple locales | | Ignoring RTL | Layout broken for RTL users | Dedicated RTL project | | No font wait | Screenshots with fallback fonts | Wait for `document.fonts.ready` | ## Related References - **Clock Mocking**: See [clock-mocking.md](../advanced/clock-mocking.md) for timezone testing - **Mobile Testing**: See [mobile-testing.md](../advanced/mobile-testing.md) for device-specific locales