# Internationalization and Localization Testing > **When to use**: Verifying your application works correctly across locales, languages, text directions, date/number formats, and timezones. Catches layout breaks, missing translations, and format errors before they reach international users. > **Prerequisites**: [core/configuration.md](configuration.md), [core/locators.md](locators.md) ## Quick Reference ```typescript // Set locale and timezone per context const context = await browser.newContext({ locale: 'de-DE', timezoneId: 'Europe/Berlin', }); // Or in playwright.config.ts for project-level locale testing projects: [ { name: 'english', use: { locale: 'en-US', timezoneId: 'America/New_York' } }, { name: 'german', use: { locale: 'de-DE', timezoneId: 'Europe/Berlin' } }, { name: 'arabic', use: { locale: 'ar-SA', timezoneId: 'Asia/Riyadh' } }, ], ``` ## Patterns ### Setting Browser Locale **Use when**: Testing locale-dependent rendering -- date formats, number formatting, currency, sorting, and browser-level localization. **Avoid when**: Your app does not use the browser locale and instead relies on a user preference stored server-side. **TypeScript** ```typescript import { test, expect } from "@playwright/test" test.describe("locale-specific formatting", () => { test("US locale formats dates as MM/DD/YYYY", async ({ browser }) => { const context = await browser.newContext({ locale: "en-US" }) const page = await context.newPage() await page.goto("/dashboard") // Verify date format matches US convention await expect(page.getByTestId("last-updated")).toHaveText(/\d{1,2}\/\d{1,2}\/\d{4}/) await context.close() }) test("German locale formats dates as DD.MM.YYYY", async ({ browser }) => { const context = await browser.newContext({ locale: "de-DE" }) const page = await context.newPage() await page.goto("/dashboard") await expect(page.getByTestId("last-updated")).toHaveText(/\d{1,2}\.\d{1,2}\.\d{4}/) await context.close() }) test("Japanese locale formats numbers with commas", async ({ browser }) => { const context = await browser.newContext({ locale: "ja-JP" }) const page = await context.newPage() await page.goto("/pricing") // Japanese yen: no decimal places, uses comma grouping await expect(page.getByTestId("price")).toHaveText(/[\d,]+円/) await context.close() }) }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("German locale formats dates as DD.MM.YYYY", async ({ browser }) => { const context = await browser.newContext({ locale: "de-DE" }) const page = await context.newPage() await page.goto("/dashboard") await expect(page.getByTestId("last-updated")).toHaveText(/\d{1,2}\.\d{1,2}\.\d{4}/) await context.close() }) ``` ### Multi-Locale Project Configuration **Use when**: Running the full test suite across multiple locales in CI. **Avoid when**: You only need to test a single locale or the app does not vary by locale. **TypeScript** ```typescript // playwright.config.ts import { defineConfig } from "@playwright/test" export default defineConfig({ projects: [ { name: "en-US", use: { locale: "en-US", timezoneId: "America/New_York", }, }, { name: "de-DE", use: { locale: "de-DE", timezoneId: "Europe/Berlin", }, }, { name: "ar-SA", use: { locale: "ar-SA", timezoneId: "Asia/Riyadh", }, }, { name: "ja-JP", use: { locale: "ja-JP", timezoneId: "Asia/Tokyo", }, }, ], }) ``` **JavaScript** ```javascript // playwright.config.js const { defineConfig } = require("@playwright/test") module.exports = defineConfig({ projects: [ { name: "en-US", use: { locale: "en-US", timezoneId: "America/New_York" } }, { name: "de-DE", use: { locale: "de-DE", timezoneId: "Europe/Berlin" } }, { name: "ar-SA", use: { locale: "ar-SA", timezoneId: "Asia/Riyadh" } }, { name: "ja-JP", use: { locale: "ja-JP", timezoneId: "Asia/Tokyo" } }, ], }) ``` ### RTL Layout Testing **Use when**: Your app supports right-to-left languages (Arabic, Hebrew, Persian, Urdu) and you need to verify layout direction, text alignment, and mirrored UI. **Avoid when**: Your app has no RTL support. **TypeScript** ```typescript import { test, expect } from "@playwright/test" test.describe("RTL layout", () => { test("Arabic locale renders RTL layout", async ({ browser }) => { const context = await browser.newContext({ locale: "ar-SA" }) const page = await context.newPage() await page.goto("/") // Verify the document direction const dir = await page.getAttribute("html", "dir") expect(dir).toBe("rtl") // Verify navigation is right-aligned const nav = page.getByRole("navigation", { name: "Main" }) const navBox = await nav.boundingBox() const viewportWidth = page.viewportSize()!.width // Nav should start from the right side expect(navBox!.x + navBox!.width).toBeGreaterThan(viewportWidth * 0.5) // Verify text alignment const heading = page.getByRole("heading", { level: 1 }) const textAlign = await heading.evaluate((el) => window.getComputedStyle(el).textAlign) expect(textAlign).toMatch(/right|start/) await context.close() }) test("RTL layout does not cause horizontal overflow", async ({ browser }) => { const context = await browser.newContext({ locale: "ar-SA" }) const page = await context.newPage() await page.goto("/dashboard") // Check for horizontal scrollbar (content overflow) const hasHorizontalOverflow = await page.evaluate(() => { return document.documentElement.scrollWidth > document.documentElement.clientWidth }) expect(hasHorizontalOverflow).toBe(false) await context.close() }) test("icons and directional elements are mirrored in RTL", async ({ browser }) => { const context = await browser.newContext({ locale: "ar-SA" }) const page = await context.newPage() await page.goto("/dashboard") // Back arrow should point right in RTL const backButton = page.getByRole("button", { name: /back|رجوع/i }) const transform = await backButton.evaluate((el) => window.getComputedStyle(el).transform) // CSS transform for horizontal flip: matrix(-1, 0, 0, 1, 0, 0) or scaleX(-1) // Or check the logical property direction expect(transform).not.toBe("none") await context.close() }) }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("Arabic locale renders RTL layout", async ({ browser }) => { const context = await browser.newContext({ locale: "ar-SA" }) const page = await context.newPage() await page.goto("/") const dir = await page.getAttribute("html", "dir") expect(dir).toBe("rtl") const hasHorizontalOverflow = await page.evaluate( () => document.documentElement.scrollWidth > document.documentElement.clientWidth ) expect(hasHorizontalOverflow).toBe(false) await context.close() }) ``` ### Date, Number, and Currency Format Verification **Use when**: Your app uses `Intl.DateTimeFormat`, `Intl.NumberFormat`, or similar locale-sensitive APIs. **Avoid when**: Formats are hardcoded and do not depend on locale. **TypeScript** ```typescript import { test, expect } from "@playwright/test" const FORMAT_EXPECTATIONS = { "en-US": { date: /\d{1,2}\/\d{1,2}\/\d{4}/, // 1/15/2025 number: /1,234,567\.89/, // 1,234,567.89 currency: /\$[\d,]+\.\d{2}/, // $1,234.56 }, "de-DE": { date: /\d{1,2}\.\d{1,2}\.\d{4}/, // 15.1.2025 number: /1\.234\.567,89/, // 1.234.567,89 currency: /[\d.,]+\s?€/, // 1.234,56 € }, "ja-JP": { date: /\d{4}\/\d{1,2}\/\d{1,2}/, // 2025/1/15 number: /1,234,567\.89/, // 1,234,567.89 currency: /[¥¥][\d,]+/, // ¥1,235 }, } as const for (const [locale, expected] of Object.entries(FORMAT_EXPECTATIONS)) { test.describe(`${locale} formatting`, () => { test(`dates match ${locale} format`, async ({ browser }) => { const context = await browser.newContext({ locale }) const page = await context.newPage() await page.goto("/account") const dateText = await page.getByTestId("member-since").textContent() expect(dateText).toMatch(expected.date) await context.close() }) test(`currency matches ${locale} format`, async ({ browser }) => { const context = await browser.newContext({ locale }) const page = await context.newPage() await page.goto("/pricing") const priceText = await page.getByTestId("monthly-price").textContent() expect(priceText).toMatch(expected.currency) await context.close() }) }) } ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") const locales = [ { locale: "en-US", datePattern: /\d{1,2}\/\d{1,2}\/\d{4}/, currencyPattern: /\$[\d,]+\.\d{2}/ }, { locale: "de-DE", datePattern: /\d{1,2}\.\d{1,2}\.\d{4}/, currencyPattern: /[\d.,]+\s?€/ }, ] for (const { locale, datePattern, currencyPattern } of locales) { test(`${locale} date format`, async ({ browser }) => { const context = await browser.newContext({ locale }) const page = await context.newPage() await page.goto("/account") const dateText = await page.getByTestId("member-since").textContent() expect(dateText).toMatch(datePattern) await context.close() }) } ``` ### Language Switcher Testing **Use when**: Your app has an in-app language selector that changes the UI language without depending on browser locale. **Avoid when**: Language is determined purely by browser locale with no user override. **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("language switcher changes UI language", async ({ page }) => { await page.goto("/") // Default language await expect(page.getByRole("heading", { level: 1 })).toContainText("Welcome") await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible() // Switch to French await page.getByRole("combobox", { name: /language|langue/i }).selectOption("fr") // UI should update to French await expect(page.getByRole("heading", { level: 1 })).toContainText("Bienvenue") await expect(page.getByRole("button", { name: "Se connecter" })).toBeVisible() // Verify language persists after navigation await page.getByRole("link", { name: /about|à propos/i }).click() await expect(page.getByRole("heading", { level: 1 })).not.toContainText("About") }) test("language preference persists across sessions", async ({ page, context }) => { await page.goto("/") await page.getByRole("combobox", { name: /language/i }).selectOption("es") await expect(page.getByRole("button", { name: "Iniciar sesión" })).toBeVisible() // Reload page — language should persist (cookie/localStorage) await page.reload() await expect(page.getByRole("button", { name: "Iniciar sesión" })).toBeVisible() }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("language switcher changes UI language", async ({ page }) => { await page.goto("/") await expect(page.getByRole("heading", { level: 1 })).toContainText("Welcome") await page.getByRole("combobox", { name: /language/i }).selectOption("fr") await expect(page.getByRole("heading", { level: 1 })).toContainText("Bienvenue") await expect(page.getByRole("button", { name: "Se connecter" })).toBeVisible() }) ``` ### Translation Completeness Checks **Use when**: Verifying that all visible UI strings are translated and no fallback keys leak into the UI. **Avoid when**: You have a build-time translation validation step that already catches missing keys. **TypeScript** ```typescript import { test, expect } from "@playwright/test" const LANGUAGES = ["en", "fr", "de", "es", "ja"] const PAGES_TO_CHECK = ["/", "/login", "/dashboard", "/settings", "/pricing"] for (const lang of LANGUAGES) { test.describe(`${lang} translation completeness`, () => { for (const pagePath of PAGES_TO_CHECK) { test(`no missing translations on ${pagePath}`, async ({ page }) => { // Set language via URL param, cookie, or language switcher await page.goto(`${pagePath}?lang=${lang}`) // Check for common translation key leak patterns const pageText = await page.textContent("body") // Translation keys typically look like: key.subkey, UPPER_SNAKE_CASE, or {{key}} expect(pageText).not.toMatch(/\b[a-z]+\.[a-z]+\.[a-z]+\b/) // dot.notation.keys expect(pageText).not.toContain("{{") // Unresolved templates expect(pageText).not.toContain("}}") expect(pageText).not.toMatch(/\bTODO\b/i) // Placeholder text // Check for untranslated English text when in non-English locale if (lang !== "en") { // These common English strings should be translated const untranslated = ["Sign in", "Log out", "Settings", "Dashboard", "Submit"] for (const text of untranslated) { const count = await page.getByText(text, { exact: true }).count() // Allow 0 matches (element not on page) but flag exact English matches if (count > 0) { console.warn(`Possible untranslated text "${text}" found on ${pagePath} for ${lang}`) } } } }) } }) } ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") const LANGUAGES = ["en", "fr", "de"] const PAGES = ["/", "/login", "/dashboard"] for (const lang of LANGUAGES) { for (const pagePath of PAGES) { test(`${lang}: no missing translations on ${pagePath}`, async ({ page }) => { await page.goto(`${pagePath}?lang=${lang}`) const pageText = await page.textContent("body") expect(pageText).not.toMatch(/\b[a-z]+\.[a-z]+\.[a-z]+\b/) expect(pageText).not.toContain("{{") expect(pageText).not.toContain("}}") }) } } ``` ### Timezone Testing **Use when**: Your app displays time-sensitive data (event times, deadlines, scheduling) and you need to verify correct timezone rendering. **Avoid when**: All times are displayed in UTC with no local conversion. **TypeScript** ```typescript import { test, expect } from "@playwright/test" test.describe("timezone rendering", () => { test("event times adjust to user timezone", async ({ browser }) => { // Event stored as 2025-03-15T14:00:00Z (2 PM UTC) const contextNY = await browser.newContext({ locale: "en-US", timezoneId: "America/New_York", // UTC-5 in March (EST) }) const pageNY = await contextNY.newPage() await pageNY.goto("/events/123") // Should display 9:00 AM await expect(pageNY.getByTestId("event-time")).toContainText("9:00 AM") await contextNY.close() const contextTokyo = await browser.newContext({ locale: "en-US", timezoneId: "Asia/Tokyo", // UTC+9 }) const pageTokyo = await contextTokyo.newPage() await pageTokyo.goto("/events/123") // Should display 11:00 PM await expect(pageTokyo.getByTestId("event-time")).toContainText("11:00 PM") await contextTokyo.close() }) test("deadline displays correctly across DST boundary", async ({ browser }) => { const context = await browser.newContext({ locale: "en-US", timezoneId: "America/Los_Angeles", }) const page = await context.newPage() // Test a deadline that crosses DST (March second Sunday) await page.goto("/tasks?deadline=2025-03-10T07:00:00Z") // Before DST: UTC-8, so 11 PM on March 9 await expect(page.getByTestId("deadline")).toContainText("Mar 9") await context.close() }) }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("event times adjust to user timezone", async ({ browser }) => { const context = await browser.newContext({ locale: "en-US", timezoneId: "America/New_York", }) const page = await context.newPage() await page.goto("/events/123") await expect(page.getByTestId("event-time")).toContainText("9:00 AM") await context.close() }) ``` ### Multi-Language Screenshot Comparison **Use when**: Catching visual layout regressions caused by text expansion, RTL mirroring, or font rendering differences across locales. **Avoid when**: Visual regression testing is handled separately and locale is not a layout risk. **TypeScript** ```typescript import { test, expect } from "@playwright/test" const LOCALES_TO_SCREENSHOT = [ { locale: "en-US", name: "english" }, { locale: "de-DE", name: "german" }, // German text is ~30% longer than English { locale: "ja-JP", name: "japanese" }, // CJK characters, different font metrics { locale: "ar-SA", name: "arabic" }, // RTL layout ] for (const { locale, name } of LOCALES_TO_SCREENSHOT) { test(`visual snapshot for ${name} (${locale})`, async ({ browser }) => { const context = await browser.newContext({ locale, viewport: { width: 1280, height: 720 }, }) const page = await context.newPage() await page.goto("/") // Wait for fonts and images to load await page.waitForLoadState("networkidle") await expect(page).toHaveScreenshot(`homepage-${name}.png`, { maxDiffPixelRatio: 0.02, // Allow 2% pixel difference for font rendering fullPage: true, }) await context.close() }) } test("long German text does not overflow buttons", async ({ browser }) => { const context = await browser.newContext({ locale: "de-DE" }) const page = await context.newPage() await page.goto("/dashboard") // Check that no button has overflowing text const buttons = page.getByRole("button") const count = await buttons.count() for (let i = 0; i < count; i++) { const button = buttons.nth(i) const overflow = await button.evaluate((el) => { const style = window.getComputedStyle(el) return el.scrollWidth > el.clientWidth && style.overflow !== "hidden" }) if (overflow) { const text = await button.textContent() expect(overflow, `Button "${text}" overflows in German`).toBe(false) } } await context.close() }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") const LOCALES = [ { locale: "en-US", name: "english" }, { locale: "de-DE", name: "german" }, { locale: "ar-SA", name: "arabic" }, ] for (const { locale, name } of LOCALES) { test(`visual snapshot for ${name}`, async ({ browser }) => { const context = await browser.newContext({ locale, viewport: { width: 1280, height: 720 } }) const page = await context.newPage() await page.goto("/") await page.waitForLoadState("networkidle") await expect(page).toHaveScreenshot(`homepage-${name}.png`, { maxDiffPixelRatio: 0.02, fullPage: true, }) await context.close() }) } ``` ## Decision Guide | Scenario | Approach | Why | | ------------------------------------------ | ------------------------------------------------------- | --------------------------------------------------------------- | | Test locale-dependent formatting | Set `locale` in browser context | Playwright sets `navigator.language` and affects `Intl` APIs | | Test app language switcher | Interact with the switcher UI directly | Tests the actual user workflow, not just browser locale | | Test timezone rendering | Set `timezoneId` in browser context | Overrides `Date` and `Intl.DateTimeFormat` timezone | | Catch text overflow from long translations | Visual regression + bounding box checks | German/Finnish text is 30-40% longer than English | | Verify RTL layout | Set Arabic/Hebrew locale + assert `dir="rtl"` | Tests both browser signal and app response | | Catch missing translations | Scan page text for key patterns (`{{`, dot notation) | Catches build/deploy issues where translation files are missing | | Compare layouts across locales | `toHaveScreenshot` per locale with project-based config | Captures visual differences automatically | | Test DST edge cases | Set specific `timezoneId` + known date boundaries | DST boundaries cause the most timezone bugs | ## Anti-Patterns | Don't Do This | Problem | Do This Instead | | ---------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------- | | Hardcoding translated text in assertions | Breaks when translations change | Use `getByRole` with `name` regex, or test IDs for locale-independent selection | | Testing only `en-US` | Misses RTL, text overflow, and format bugs | Test at least one LTR, one RTL, and one CJK locale | | Setting `locale` on the page instead of context | `locale` is a context-level option, not page-level | Set locale when creating the context or in project config | | Ignoring text expansion for German/Finnish | Buttons and labels overflow in longer languages | Use visual regression or bounding box assertions | | Using `toHaveScreenshot` without `maxDiffPixelRatio` | Font rendering differs slightly across OS/CI | Set `maxDiffPixelRatio: 0.02` or higher for cross-platform tolerance | | Testing timezone only in UTC | Masks timezone conversion bugs | Test with at least 3 timezones: UTC-negative, UTC, UTC-positive | | Relying on browser locale for app language | Many apps use server-side or cookie-based language | Test via the app's own language switching mechanism | | Not testing DST boundaries | Time displayed can be off by 1 hour at transitions | Test dates near DST transitions for your target timezones | ## Troubleshooting | Symptom | Likely Cause | Fix | | ----------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------ | | `locale` option has no effect | App ignores `navigator.language` and uses server-side locale | Set locale through the app's own mechanism (cookie, URL param, API) | | Date format does not change with locale | App hardcodes date format instead of using `Intl.DateTimeFormat` | This is an app bug to fix, not a test issue | | RTL page still renders LTR | App checks locale but does not set `dir="rtl"` | Verify the app's RTL detection logic; may need to set `Accept-Language` header | | Visual screenshots fail across OS | Font rendering differs between macOS, Linux, Windows | Run visual tests in Docker for consistency, or increase `maxDiffPixelRatio` | | `timezoneId` does not affect page | App uses server time, not client `Date` | Timezone testing only works for client-side date rendering | | Translation key appears briefly then disappears | Translation files load asynchronously | Wait for `networkidle` or a specific translated element before asserting | | Character encoding issues (garbled text) | Incorrect charset in HTML or missing font | Verify `` and that CJK/Arabic fonts are available | ## Related - [core/locators.md](locators.md) -- locator strategies that work across locales - [core/configuration.md](configuration.md) -- project-level locale and timezone configuration - [core/visual-regression.md](visual-regression.md) -- screenshot comparison fundamentals - [core/clock-and-time-mocking.md](clock-and-time-mocking.md) -- mocking time for date-dependent testing - [ci/docker-and-containers.md](../ci/docker-and-containers.md) -- consistent font rendering in CI with Docker