Testing Multilingual Apps with Playwright: A Practical i18n Guide
Testing multilingual applications is one of those things teams either do well or skip entirely. When you skip it, you find out the hard way: a Japanese user reports that your checkout button disappears, a German customer can't read dates, or your Arabic support launch breaks the entire layout.
Playwright makes multilingual testing tractable. Its browser context API lets you configure locale, timezone, and language settings per context — meaning you can run the same test scenarios across 20 locales without spinning up 20 browsers.
The Core Playwright API for Locale Testing
Every multilingual Playwright test starts with browser context configuration:
const context = await browser.newContext({
locale: 'de-DE',
timezoneId: 'Europe/Berlin'
});This sets both navigator.language (which JS reads) and Accept-Language headers (which servers read). Most well-built i18n systems will respond to one or both of these signals.
For RTL languages, you may also need to set the HTML dir attribute — but if your app's locale detection is working correctly, it should handle that automatically. Test it:
const context = await browser.newContext({ locale: 'ar-SA' });
const page = await context.newPage();
await page.goto('/');
const dir = await page.evaluate(() => document.documentElement.dir);
expect(dir).toBe('rtl');Structuring Multi-Locale Test Suites
The naive approach — duplicate tests for each locale — doesn't scale. A better pattern uses parameterized tests with locale fixtures:
// locales.js
export const TEST_LOCALES = [
{ locale: 'en-US', currency: '$', dateFormat: 'MM/DD/YYYY', dir: 'ltr' },
{ locale: 'de-DE', currency: '€', dateFormat: 'DD.MM.YYYY', dir: 'ltr' },
{ locale: 'ja-JP', currency: '¥', dateFormat: 'YYYY/MM/DD', dir: 'ltr' },
{ locale: 'ar-SA', currency: 'ر.س', dateFormat: 'DD/MM/YYYY', dir: 'rtl' },
{ locale: 'he-IL', currency: '₪', dateFormat: 'DD.MM.YYYY', dir: 'rtl' },
];
// tests/i18n/checkout.spec.js
import { TEST_LOCALES } from '../locales';
for (const { locale, currency, dateFormat, dir } of TEST_LOCALES) {
test(`checkout displays correct currency for ${locale}`, async ({ browser }) => {
const context = await browser.newContext({ locale });
const page = await context.newPage();
await page.goto('/checkout');
const priceText = await page.locator('[data-testid="total-price"]').textContent();
expect(priceText).toContain(currency);
});
}This pattern means adding a new locale takes one line in locales.js, and every test in the suite automatically covers it.
Testing Locale Detection Logic
Before testing content correctness, verify that locale detection works at all. Most apps detect locale via one of three mechanisms:
1. Browser Accept-Language header: Playwright sends this automatically when you set locale.
2. URL-based locale: /de/checkout, /en/checkout. Test that routing works:
test('redirects to correct locale URL', async ({ browser }) => {
const context = await browser.newContext({ locale: 'de-DE' });
const page = await context.newPage();
await page.goto('/');
await expect(page).toHaveURL(/\/de\//);
});3. Cookie or localStorage: Some apps store locale preference after the user sets it. Test the persistence:
test('locale preference persists across sessions', async ({ browser }) => {
const context = await browser.newContext({ locale: 'en-US' });
const page = await context.newPage();
// Set German in the UI
await page.goto('/settings');
await page.selectOption('[data-testid="language-select"]', 'de');
await page.click('[data-testid="save-settings"]');
// New page (simulates reopening browser tab)
const page2 = await context.newPage();
await page2.goto('/dashboard');
// Should still be German
await expect(page2.locator('[data-testid="nav-home"]')).toHaveText('Startseite');
});Testing Date and Number Formatting
Formatting bugs are some of the most common i18n defects — and the hardest to catch without explicit tests.
test('dates display in locale-correct format', async ({ browser }) => {
const localeExpectations = [
{ locale: 'en-US', pattern: /\d{1,2}\/\d{1,2}\/\d{4}/ }, // 1/2/2025
{ locale: 'de-DE', pattern: /\d{1,2}\.\d{1,2}\.\d{4}/ }, // 1.2.2025
{ locale: 'ja-JP', pattern: /\d{4}\/\d{1,2}\/\d{1,2}/ }, // 2025/1/2
];
for (const { locale, pattern } of localeExpectations) {
const context = await browser.newContext({ locale });
const page = await context.newPage();
await page.goto('/transactions');
const dateText = await page.locator('[data-testid="transaction-date"]').first().textContent();
expect(dateText).toMatch(pattern);
await context.close();
}
});For numbers, verify thousand separators and decimal separators:
test('large numbers use locale-correct formatting', async ({ browser }) => {
const cases = [
{ locale: 'en-US', expected: '1,234,567.89' },
{ locale: 'de-DE', expected: '1.234.567,89' },
{ locale: 'fr-FR', expected: '1 234 567,89' }, // non-breaking space
];
for (const { locale, expected } of cases) {
const context = await browser.newContext({ locale });
const page = await context.newPage();
await page.goto('/analytics');
const numText = await page.locator('[data-testid="revenue-total"]').textContent();
expect(numText).toContain(expected);
await context.close();
}
});RTL Layout Testing
RTL testing is where many teams shortcut and regret it. The visual nature of RTL bugs (mirroring issues, overlapping elements, wrong text alignment) makes screenshot comparison the most practical approach.
test('Arabic layout is correctly mirrored', async ({ browser }) => {
const context = await browser.newContext({
locale: 'ar-SA',
viewport: { width: 1280, height: 800 }
});
const page = await context.newPage();
await page.goto('/dashboard');
// Check structural direction
const htmlDir = await page.evaluate(() => document.documentElement.dir);
expect(htmlDir).toBe('rtl');
// Check that nav is on the right
const nav = await page.locator('nav').boundingBox();
const content = await page.locator('[data-testid="main-content"]').boundingBox();
expect(nav.x).toBeGreaterThan(content.x); // nav should be to the right
// Visual snapshot for review
await expect(page).toHaveScreenshot('dashboard-ar-SA.png');
});The key assertions for RTL:
document.documentElement.dir === 'rtl'- Navigation/sidebar position is on the right
- Input cursor position is on the right side of fields
- Progress bars fill from right to left
- Back buttons/arrows point right
Testing Translation Completeness
One of the most common i18n bugs: a translation key exists in English but is missing in another locale. Playwright can help you catch this at test time:
test('no missing translation keys visible in German', async ({ browser }) => {
const context = await browser.newContext({ locale: 'de-DE' });
const page = await context.newPage();
// Navigate through key pages
const pagesToCheck = ['/', '/dashboard', '/settings', '/checkout'];
for (const path of pagesToCheck) {
await page.goto(path);
// Missing keys usually appear as the raw key or a default fallback marker
const bodyText = await page.locator('body').textContent();
// Customize this pattern to match your i18n library's missing-key format
expect(bodyText).not.toMatch(/\[MISSING:[^\]]+\]/);
expect(bodyText).not.toMatch(/translation_missing/);
}
});Text Expansion and Layout Testing
German, Finnish, and Dutch translations can be 30–80% longer than English equivalents. Test that your UI doesn't break:
test('buttons do not overflow with longer German labels', async ({ browser }) => {
const context = await browser.newContext({ locale: 'de-DE' });
const page = await context.newPage();
await page.goto('/onboarding');
const buttons = await page.locator('button').all();
for (const button of buttons) {
const box = await button.boundingBox();
const parentBox = await button.locator('..').boundingBox();
// Button should not overflow its container
if (box && parentBox) {
expect(box.x + box.width).toBeLessThanOrEqual(parentBox.x + parentBox.width + 1);
}
}
});Running Multi-Locale Tests in CI
For CI, split locale tests into tiers:
PR checks (fast, ~2min): Pseudo-localization smoke test + 2–3 critical locale checks (en-US, de-DE, ja-JP). Catches ~80% of i18n defects.
Nightly (thorough, ~15min): Full locale matrix across all supported languages. Catches edge cases and RTL issues.
Pre-release (complete): Full suite + visual snapshot review for RTL locales.
With Playwright's built-in parallelization, 20-locale test suites complete much faster than you'd expect — contexts are lightweight and can run concurrently.
Using HelpMeTest for Multilingual Testing
HelpMeTest lets you write multilingual tests in plain English without dealing with Playwright context setup directly:
# Test German locale
Set browser locale to de-DE
Navigate to the checkout page
Verify the total price shows Euro symbol
Verify dates are displayed in DD.MM.YYYY format
Take a screenshot and flag any layout overflowThe test runner handles the locale configuration and runs your suite across configured locales automatically. This is particularly useful for teams without deep Playwright expertise who still need solid multilingual coverage.
Summary
Multilingual testing with Playwright is most effective when you:
- Use parameterized test suites to cover multiple locales without code duplication
- Test locale detection before testing content correctness
- Explicitly assert date, number, and currency formats for each locale
- Use screenshot comparison for RTL layout validation
- Test translation completeness by checking for missing-key markers
- Run a fast locale subset on every PR and the full suite nightly
The hardest part is getting started — once you have the locale fixture pattern set up, adding new test cases and new locales is straightforward.