.agents/skills/playwright-best-practices/testing-patterns/i18n.md
// 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',
},
},
],
})
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()
})
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')
})
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()
})
}
// fixtures/i18n.ts
import {test as base} from '@playwright/test'
type LocaleFixtures = {
localePage: (locale: string) => Promise<Page>
}
export const test = base.extend<LocaleFixtures>({
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('€')
})
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')
})
// 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',
},
},
],
})
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')
})
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)
})
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})
})
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('محمد ([email protected])')
// Verify text doesn't overlap or break
const box = await nameField.boundingBox()
expect(box!.width).toBeGreaterThan(100) // Content not collapsed
})
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()
}
})
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()
}
})
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()
}
})
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)
}
})
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([])
})
// 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'}},
],
})
// test file
test('homepage visual', async ({page}) => {
await page.goto('/')
// Snapshot auto-saved to {projectName}/homepage.png
await expect(page).toHaveScreenshot('homepage.png')
})
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')
})
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-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 |