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, core/locators.md
Quick Reference
// 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
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
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
// 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
// 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
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
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
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
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
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
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
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
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
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
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
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
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 (<code v-pre>{{</code>, 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 <meta charset="utf-8"> and that CJK/Arabic fonts are available |
Related
- core/locators.md -- locator strategies that work across locales
- core/configuration.md -- project-level locale and timezone configuration
- core/visual-regression.md -- screenshot comparison fundamentals
- core/clock-and-time-mocking.md -- mocking time for date-dependent testing
- ci/docker-and-containers.md -- consistent font rendering in CI with Docker