i18n and Localization Testing with Playwright
Your app ships in twelve languages, but your test suite only ever sees English. The day a German user reports a date showing as “12/06/2026” instead of “06.12.2026,” or a French checkout button overflowing its container, you find out the hard way that your green pipeline never actually exercised those locales. This guide shows you exactly how Playwright i18n localization testing works in practice: emulating locales and time zones, swapping translation bundles with network interception, asserting on locale-aware number and date formatting, catching layout breaks, and validating right-to-left rendering.
๐ญ Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.
Contents
Why localization breaks slip past normal E2E tests
Localization bugs are rarely logic bugs. The code works; the presentation is wrong for a given locale. A US-formatted price ($1,234.56) becomes 1.234,56 โฌ in German, dates flip day and month order, plural rules differ, and translated strings can be two to three times longer than English. Standard end-to-end tests assert on hardcoded English strings and run with the machine’s default locale, so they are structurally blind to every one of these failure modes.
Playwright fixes this at the browser-context level. Each BrowserContext can declare its own locale, timezoneId, and geolocation. The locale option changes navigator.language, the Accept-Language request header, and crucially the Intl formatting rules the browser applies to numbers, dates, and currency. That means you can run the identical test body against en-US, de-DE, and ar-EG and assert the right thing for each, all in one suite.
Emulating locale and timezone in the Playwright config
Start by defining a baseline locale and time zone in playwright.config.ts, then create per-locale projects. Projects let one suite fan out across every language you support without duplicating test code.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
use: {
baseURL: 'http://localhost:3000',
// Global defaults โ overridden per project below.
locale: 'en-US',
timezoneId: 'America/New_York',
},
projects: [
{
name: 'en-US',
use: { ...devices['Desktop Chrome'], locale: 'en-US', timezoneId: 'America/New_York' },
},
{
name: 'de-DE',
use: { ...devices['Desktop Chrome'], locale: 'de-DE', timezoneId: 'Europe/Berlin' },
},
{
name: 'fr-FR',
use: { ...devices['Desktop Chrome'], locale: 'fr-FR', timezoneId: 'Europe/Paris' },
},
{
name: 'ar-EG',
use: { ...devices['Desktop Chrome'], locale: 'ar-EG', timezoneId: 'Africa/Cairo' },
},
],
});
Run a single locale with npx playwright test --project=de-DE, or all of them at once with a plain npx playwright test. You can also override locale for one test file or block using test.use({ locale: 'ja-JP', timezoneId: 'Asia/Tokyo' }), which is handy for a focused spec that only matters in one market.
Asserting locale-aware number, currency, and date formatting
The most reliable way to test formatting is to compute the expected value with the same Intl API the browser uses, rather than hardcoding strings. This keeps your assertions correct even when ICU data shifts between browser versions. Below, the same test runs under every project and derives its expectation from the project’s locale.
import { test, expect } from '@playwright/test';
test('price renders in the active locale format', async ({ page }, testInfo) => {
// The project name doubles as the locale string here.
const locale = testInfo.project.name;
await page.goto('/products/42');
const expected = new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'EUR',
}).format(1234.56);
// de-DE โ "1.234,56 โฌ", en-US โ "โฌ1,234.56", fr-FR โ "1 234,56 โฌ"
await expect(page.getByTestId('product-price')).toHaveText(expected);
});
test('order date follows locale conventions', async ({ page }, testInfo) => {
const locale = testInfo.project.name;
await page.goto('/orders/recent');
const expected = new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(new Date('2026-06-27T10:00:00Z'));
await expect(page.getByTestId('order-date')).toHaveText(expected);
});
A note of caution: the browser must actually receive the locale you think it has. When asserting against Intl output computed in Node, make sure the Node process and the browser agree. Reading navigator.language from inside the page via page.evaluate(() => navigator.language) is a quick sanity check during debugging.
Controlling time zones and clocks together
Date formatting depends on two things: the locale (how it looks) and the time zone (what instant it represents). Set timezoneId on the context, then freeze the clock with page.clock so a “2 hours ago” or “Today at 9:00” label is deterministic instead of drifting with the wall clock. Install the clock before navigation so the app sees the fixed time from first paint.
import { test, expect } from '@playwright/test';
test.use({ locale: 'de-DE', timezoneId: 'Europe/Berlin' });
test('timestamp respects locale and frozen Berlin time', async ({ page }) => {
// Freeze time BEFORE the app boots so first render is deterministic.
await page.clock.install({ time: new Date('2026-06-27T08:30:00Z') });
await page.goto('/dashboard');
// 08:30 UTC is 10:30 in Berlin (CEST, UTC+2).
const expected = new Intl.DateTimeFormat('de-DE', {
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Berlin',
}).format(new Date('2026-06-27T08:30:00Z'));
await expect(page.getByTestId('last-synced')).toHaveText(new RegExp(expected));
});
Use page.clock.install() when timers, polling, or relative-time labels need to advance under your control; use page.clock.setFixedTime() when you only need Date.now() and new Date() to return a constant value without touching timers. For relative-time UIs, install the clock and call page.clock.fastForward('01:00') to jump an hour and assert the label updated.
Swapping translation bundles with page.route
Many apps lazy-load JSON translation files (for example /locales/de.json). You can intercept those requests with page.route to inject a known fixture, to force a pseudo-locale, or to test the missing-key fallback path without standing up a translation backend. This is the cleanest way to test what happens when a key is absent.
import { test, expect } from '@playwright/test';
test('falls back to the key when a translation is missing', async ({ page }) => {
// Serve an intentionally incomplete bundle to exercise the fallback path.
await page.route('**/locales/de.json', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
'checkout.title': 'Kasse',
// 'checkout.submit' is deliberately omitted.
}),
});
});
await page.goto('/checkout?lang=de');
await expect(page.getByRole('heading')).toHaveText('Kasse');
// App should show a safe fallback, never a raw "undefined".
const submit = page.getByTestId('submit-button');
await expect(submit).not.toHaveText('undefined');
await expect(submit).not.toBeEmpty();
});
The same technique drives pseudo-localization: intercept the bundle and wrap every value (for example "Submit" becomes "[!! ลรปรพmรฎt !!]"). Pseudo-locales surface hardcoded strings, truncation, and concatenation bugs early, long before real translations arrive, because any untranslated text stands out instantly.
Catching text overflow and layout breaks
German and Finnish strings routinely run 30 to 40 percent longer than English, which overflows fixed-width buttons and breaks navigation bars. Two complementary checks catch this: a functional check that an element is not visually clipped, and a visual snapshot per locale. Use expect.soft so one overflowing element does not stop the run and mask the rest.
import { test, expect, Locator } from '@playwright/test';
// Returns true when the element's content is wider/taller than its box.
async function isClipped(locator: Locator): Promise<boolean> {
return locator.evaluate((el) => {
return el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1;
});
}
test('primary nav items are not clipped in this locale', async ({ page }, testInfo) => {
await page.goto('/');
const items = page.getByRole('navigation').getByRole('link');
for (const item of await items.all()) {
const label = await item.textContent();
expect.soft(await isClipped(item), `"${label}" is clipped in ${testInfo.project.name}`).toBe(false);
}
// Locale-specific visual baseline; first run creates the snapshot.
await expect(page).toHaveScreenshot(`home-${testInfo.project.name}.png`, {
maxDiffPixelRatio: 0.02,
});
});
Naming screenshots per project keeps one baseline per locale, so a German layout regression never compares against the English baseline. The combination of expect.soft plus a descriptive message means a single run tells you every clipped element across the whole navigation, not just the first one.
๐ Level Up Your Playwright
From locators to CI pipelines โ build a production-grade Playwright + TypeScript framework step by step.
Validating right-to-left (RTL) rendering
For Arabic, Hebrew, Farsi, and Urdu, the document direction flips. Beyond translated text you must verify the dir="rtl" attribute is applied and that the visual mirroring actually happens. Computed style and a dedicated RTL screenshot together give you confidence the layout flips correctly.
import { test, expect } from '@playwright/test';
test.use({ locale: 'ar-EG', timezoneId: 'Africa/Cairo' });
test('Arabic locale renders right-to-left', async ({ page }) => {
await page.goto('/?lang=ar');
// The document should declare RTL direction.
await expect(page.locator('html')).toHaveAttribute('dir', 'rtl');
// Computed direction confirms CSS actually applied it.
const direction = await page.locator('body').evaluate(
(el) => getComputedStyle(el).direction,
);
expect(direction).toBe('rtl');
await expect(page).toHaveScreenshot('home-ar-rtl.png', { maxDiffPixelRatio: 0.02 });
});
Locale testing techniques at a glance
| Concern | Real Playwright API | What it validates |
|---|---|---|
| Language & formatting rules | locale in use / project | navigator.language, Accept-Language, Intl output |
| Time zone | timezoneId context option | Date/time rendering for a region |
| Deterministic time | page.clock.install() / setFixedTime() | Relative timestamps, scheduled UI |
| Translation bundle injection | page.route() + route.fulfill() | Missing keys, pseudo-locale, fallbacks |
| Text overflow | locator.evaluate() + expect.soft | Clipped buttons, broken nav |
| Visual per-locale baseline | toHaveScreenshot() | Layout drift, RTL mirroring |
| RTL direction | toHaveAttribute('dir','rtl') | Right-to-left document flow |
Putting Playwright i18n localization testing into your pipeline
The winning pattern for Playwright i18n localization testing is to write each test once against semantic locators and data-testid hooks, derive expectations from Intl rather than hardcoded strings, and let projects fan the suite out across every supported locale. Layer in page.route for bundle control, page.clock for deterministic time, expect.soft for overflow checks, and per-locale screenshots for layout and RTL. Start with your two highest-traffic markets plus one pseudo-locale, wire the projects into CI, and grow coverage from there. The result is a suite that catches a clipped German button or a mis-formatted French price before your users ever do.
FAQ
Does Playwright’s locale option actually change number and date formatting?
Yes. The locale context option sets navigator.language, the Accept-Language header, and the browser’s Intl formatting rules, so Intl.NumberFormat and Intl.DateTimeFormat inside the page produce locale-correct output. Note there is a long-standing edge case where Firefox does not always honor en-US specifically; Chromium handles all locales reliably, so prefer it for formatting assertions.
How do I test a relative timestamp like “5 minutes ago” without flakiness?
Freeze time with page.clock.install({ time }) before page.goto, then advance it deliberately with page.clock.fastForward('00:05') and assert the label updates. Because Date.now() and timers are controlled by Playwright, the relative-time output is fully deterministic regardless of when the test runs.
Should I assert on hardcoded translated strings or compute them?
For formatted values like prices and dates, compute the expectation with the same Intl options the app uses so assertions stay correct across ICU data updates. For static translated copy, intercept the bundle with page.route and assert against your own fixture, which makes the test independent of the live translation backend and lets you exercise missing-key fallbacks.
๐ Master Playwright End to End
Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.
