| name | localization-testing |
| description | Internationalization (i18n) and localization (l10n) testing with Playwright. Use when testing multi-language apps, validating date/number/currency formats across locales, testing RTL layouts, pluralization rules, or translation file completeness.
|
Localization Testing Skill
Guide for testing internationalization (i18n) and localization (l10n) in Playwright.
Core Concepts
Internationalization (i18n): Making your app support multiple languages
Localization (l10n): Adapting your app for specific languages/regions
Quick Start
Testing Multiple Locales
import { test, expect } from '@playwright/test';
test('app displays in Spanish', async ({ page }) => {
await page.goto('/es/products');
await expect(page.getByRole('heading', { name: 'Productos' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Buscar' })).toBeVisible();
});
test('app displays in French', async ({ page }) => {
await page.goto('/fr/products');
await expect(page.getByRole('heading', { name: 'Produits' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Rechercher' })).toBeVisible();
});
Testing Strategies
1. Parameterized Locale Tests
const locales = [
{ code: 'en', name: 'English', heading: 'Products', button: 'Search' },
{ code: 'es', name: 'Spanish', heading: 'Productos', button: 'Buscar' },
{ code: 'fr', name: 'French', heading: 'Produits', button: 'Rechercher' },
{ code: 'de', name: 'German', heading: 'Produkte', button: 'Suchen' },
];
for (const locale of locales) {
test(`product page displays correctly in ${locale.name}`, async ({ page }) => {
await page.goto(`/${locale.code}/products`);
await expect(page.getByRole('heading', { name: locale.heading })).toBeVisible();
await expect(page.getByRole('button', { name: locale.button })).toBeVisible();
});
}
2. Testing Date/Time Formats
test('dates display in US format', async ({ page }) => {
await page.goto('/en-US/orders/123');
await expect(page.getByText('01/31/2026')).toBeVisible();
});
test('dates display in EU format', async ({ page }) => {
await page.goto('/en-GB/orders/123');
await expect(page.getByText('31/01/2026')).toBeVisible();
});
test('dates display in ISO format', async ({ page }) => {
await page.goto('/de-DE/orders/123');
await expect(page.getByText('2026-01-31')).toBeVisible();
});
3. Testing Currency Formats
test('prices display in USD', async ({ page }) => {
await page.goto('/en-US/checkout');
await expect(page.getByText('$29.99')).toBeVisible();
});
test('prices display in EUR', async ({ page }) => {
await page.goto('/de-DE/checkout');
await expect(page.getByText('29,99 €')).toBeVisible();
});
test('prices display in GBP', async ({ page }) => {
await page.goto('/en-GB/checkout');
await expect(page.getByText('£29.99')).toBeVisible();
});
4. Testing Number Formats
test('numbers use US formatting', async ({ page }) => {
await page.goto('/en-US/analytics');
await expect(page.getByText('1,234,567.89')).toBeVisible();
});
test('numbers use European formatting', async ({ page }) => {
await page.goto('/de-DE/analytics');
await expect(page.getByText('1.234.567,89')).toBeVisible();
});
5. Right-to-Left (RTL) Language Testing
test('Arabic layout is RTL', async ({ page }) => {
await page.goto('/ar/products');
const main = page.getByRole('main');
await expect(main).toHaveCSS('direction', 'rtl');
const heading = page.getByRole('heading').first();
await expect(heading).toHaveCSS('text-align', 'right');
});
test('Hebrew layout is RTL', async ({ page }) => {
await page.goto('/he/products');
const main = page.getByRole('main');
await expect(main).toHaveCSS('direction', 'rtl');
});
Locale Fixture Pattern
Create a Locale Fixture
import { test as base } from '@playwright/test';
type LocaleFixtures = {
locale: string;
localePage: Page;
};
export const test = base.extend<LocaleFixtures>({
locale: ['en-US', { option: true }],
localePage: async ({ page, locale }, use) => {
await page.setExtraHTTPHeaders({
'Accept-Language': locale
});
page.goto = (url, options) => {
const localeUrl = `/${locale}${url}`;
return page.goto(localeUrl, options);
};
await use(page);
},
});
export { expect } from '@playwright/test';
Using the Fixture
import { test, expect } from '../fixtures/locale.fixture';
test.use({ locale: 'es-ES' });
test('Spanish product page', async ({ localePage }) => {
await localePage.goto('/products');
await expect(localePage.getByRole('heading', { name: 'Productos' })).toBeVisible();
});
Testing Translation Coverage
Verify All Text is Translated
test('no English text on Spanish page', async ({ page }) => {
await page.goto('/es/products');
const content = await page.textContent('body');
const englishWords = ['product', 'search', 'cart', 'cancel'];
for (const word of englishWords) {
expect(content?.toLowerCase()).not.toContain(word);
}
});
test('all labels are translated', async ({ page }) => {
await page.goto('/fr/checkout');
await expect(page.getByLabel('Nom du produit')).toBeVisible();
await expect(page.getByLabel('Quantité')).toBeVisible();
await expect(page.getByLabel('Adresse')).toBeVisible();
});
Browser Locale Testing
Set Browser Locale
import { test, expect, chromium } from '@playwright/test';
test('test with French browser locale', async () => {
const browser = await chromium.launch();
const context = await browser.newContext({
locale: 'fr-FR',
timezoneId: 'Europe/Paris'
});
const page = await context.newPage();
await page.goto('/products');
await expect(page.getByRole('heading', { name: 'Produits' })).toBeVisible();
await browser.close();
});
Configure in playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'chromium-en-US',
use: {
...devices['Desktop Chrome'],
locale: 'en-US',
timezoneId: 'America/New_York',
},
},
{
name: 'chromium-es-ES',
use: {
...devices['Desktop Chrome'],
locale: 'es-ES',
timezoneId: 'Europe/Madrid',
},
},
{
name: 'chromium-fr-FR',
use: {
...devices['Desktop Chrome'],
locale: 'fr-FR',
timezoneId: 'Europe/Paris',
},
},
],
});
Locale-Specific Test Data
Translation Test Data
export const translations = {
'en-US': {
products: 'Products',
search: 'Search',
addToCart: 'Add to Cart',
cancel: 'Cancel',
},
'es-ES': {
products: 'Productos',
search: 'Buscar',
addToCart: 'Añadir al carrito',
cancel: 'Cancelar',
},
'fr-FR': {
products: 'Produits',
search: 'Rechercher',
addToCart: 'Ajouter au panier',
cancel: 'Annuler',
},
};
import { translations } from '../test-data/translations';
test('search in multiple languages', async ({ page }) => {
for (const [locale, trans] of Object.entries(translations)) {
await page.goto(`/${locale}/products`);
await expect(page.getByRole('heading', { name: trans.products })).toBeVisible();
await expect(page.getByRole('button', { name: trans.search })).toBeVisible();
}
});
Common Localization Issues
Issue 1: Hardcoded Text
test('bad test', async ({ page }) => {
await expect(page.getByText('Submit')).toBeVisible();
});
test('good test', async ({ page, locale }) => {
const translations = {
'en': 'Submit',
'es': 'Enviar',
'fr': 'Soumettre',
};
await expect(page.getByText(translations[locale])).toBeVisible();
});
Issue 2: String Concatenation
const message = `Hello ${userName}, you have ${count} items in cart`;
const message = t('cart.greeting', { userName, count });
Issue 3: Fixed Width Assumptions
test('button width', async ({ page }) => {
const button = page.getByRole('button', { name: 'Submit' });
await expect(button).toHaveCSS('width', '100px');
});
test('button is visible', async ({ page }) => {
const button = page.getByRole('button', { name: translations[locale].submit });
await expect(button).toBeVisible();
});
Pluralization Handling
Testing Plural Forms
test.describe('Pluralization', () => {
const pluralCases = [
{ count: 0, en: 'No items', es: 'Sin artículos', ar: 'لا عناصر' },
{ count: 1, en: '1 item', es: '1 artículo', ar: 'عنصر واحد' },
{ count: 2, en: '2 items', es: '2 artículos', ar: 'عنصران' },
{ count: 5, en: '5 items', es: '5 artículos', ar: '5 عناصر' },
{ count: 11, en: '11 items', es: '11 artículos', ar: '11 عنصرًا' },
{ count: 100, en: '100 items', es: '100 artículos', ar: '100 عنصر' },
];
for (const { count, en } of pluralCases) {
test(`should display correct English plural for ${count} items`, async ({ page }) => {
await page.goto(`/en/cart?itemCount=${count}`);
await expect(page.getByTestId('item-count')).toHaveText(en);
});
}
});
ICU Message Format Testing
test('should handle ICU plural rules correctly', async ({ page }) => {
const icuTestCases = [
{ locale: 'en', count: 1, expected: '1 item in your cart' },
{ locale: 'en', count: 5, expected: '5 items in your cart' },
{ locale: 'pl', count: 2, expected: '2 przedmioty w koszyku' },
{ locale: 'pl', count: 5, expected: '5 przedmiotów w koszyku' },
{ locale: 'ru', count: 1, expected: '1 товар в корзине' },
{ locale: 'ru', count: 3, expected: '3 товара в корзине' },
{ locale: 'ru', count: 5, expected: '5 товаров в корзине' },
];
for (const { locale, count, expected } of icuTestCases) {
await page.goto(`/${locale}/cart?itemCount=${count}`);
await expect(page.getByTestId('cart-summary')).toHaveText(expected);
}
});
Gender-Specific Translations
test('should handle gendered translations', async ({ page }) => {
const genderCases = [
{ locale: 'fr', gender: 'male', expected: 'Bienvenu, Jean' },
{ locale: 'fr', gender: 'female', expected: 'Bienvenue, Marie' },
{ locale: 'de', gender: 'male', expected: 'Willkommen, Herr Schmidt' },
{ locale: 'de', gender: 'female', expected: 'Willkommen, Frau Schmidt' },
];
for (const { locale, gender, expected } of genderCases) {
await page.goto(`/${locale}/profile?gender=${gender}`);
await expect(page.getByTestId('welcome-message')).toHaveText(expected);
}
});
Translation File Testing
Verifying Translation Completeness
import * as fs from 'fs';
import * as path from 'path';
interface TranslationFile {
[key: string]: string | TranslationFile;
}
function getTranslationKeys(obj: TranslationFile, prefix = ''): string[] {
const keys: string[] = [];
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object' && value !== null) {
keys.push(...getTranslationKeys(value as TranslationFile, fullKey));
} else {
keys.push(fullKey);
}
}
return keys;
}
export function findMissingTranslations(
baseLocale: string,
targetLocale: string,
translationsDir: string
): string[] {
const basePath = path.join(translationsDir, `${baseLocale}.json`);
const targetPath = path.join(translationsDir, `${targetLocale}.json`);
const baseKeys = getTranslationKeys(JSON.parse(fs.readFileSync(basePath, 'utf-8')));
const targetKeys = getTranslationKeys(JSON.parse(fs.readFileSync(targetPath, 'utf-8')));
return baseKeys.filter((key) => !targetKeys.includes(key));
}
Testing Translation Files in CI
import { test, expect } from '@playwright/test';
import { findMissingTranslations } from '../utils/translation-validator';
const supportedLocales = ['es', 'fr', 'de', 'ja', 'ar', 'zh'];
for (const locale of supportedLocales) {
test(`translation file for ${locale} should have all keys`, () => {
const missing = findMissingTranslations('en', locale, './src/locales');
expect(
missing,
`Missing ${missing.length} translation keys in ${locale}.json:\n${missing.join('\n')}`
).toHaveLength(0);
});
}
test('should not have unused translation keys', () => {
const enTranslations = JSON.parse(
require('fs').readFileSync('./src/locales/en.json', 'utf-8')
);
const sourceCode = require('fs').readFileSync('./src/**/*.{ts,tsx}', 'utf-8');
for (const key of Object.keys(enTranslations)) {
expect(
sourceCode.includes(key),
`Translation key "${key}" may be unused`
).toBeTruthy();
}
});
Dynamic Translation Loading Test
test('should load translations on demand for lazy-loaded routes', async ({ page }) => {
const translationRequests: string[] = [];
page.on('request', (req) => {
if (req.url().includes('/locales/')) {
translationRequests.push(req.url());
}
});
await page.goto('/fr/products');
expect(translationRequests.some((r) => r.includes('fr/common.json'))).toBeTruthy();
await page.goto('/fr/admin/settings');
expect(translationRequests.some((r) => r.includes('fr/admin.json'))).toBeTruthy();
});
Advanced RTL Testing
Comprehensive RTL Layout Verification
test.describe('RTL Layout Testing', () => {
test('full page layout should mirror for Arabic', async ({ page }) => {
await page.goto('/ar/products');
const htmlDir = await page.getAttribute('html', 'dir');
expect(htmlDir).toBe('rtl');
const nav = page.getByRole('navigation');
const navBox = await nav.boundingBox();
const viewportSize = page.viewportSize();
expect(navBox!.x + navBox!.width).toBeCloseTo(viewportSize!.width, -1);
const sidebar = page.getByRole('complementary');
if (await sidebar.isVisible()) {
const sidebarBox = await sidebar.boundingBox();
expect(sidebarBox!.x + sidebarBox!.width).toBeCloseTo(viewportSize!.width, -1);
}
});
test('bidirectional text should render correctly', async ({ page }) => {
await page.goto('/ar/products');
const productTitle = page.getByTestId('product-title').first();
await expect(productTitle).toBeVisible();
const direction = await productTitle.evaluate(
(el) => window.getComputedStyle(el).direction
);
expect(direction).toBe('rtl');
const unicodeBidi = await productTitle.evaluate(
(el) => window.getComputedStyle(el).unicodeBidi
);
expect(['embed', 'isolate', 'plaintext']).toContain(unicodeBidi);
});
test('form inputs should align correctly in RTL', async ({ page }) => {
await page.goto('/ar/checkout');
const emailInput = page.getByLabel('البريد الإلكتروني');
await expect(emailInput).toBeVisible();
const textAlign = await emailInput.evaluate(
(el) => window.getComputedStyle(el).textAlign
);
expect(['right', 'start']).toContain(textAlign);
const label = page.locator('label[for="email"]');
const labelBox = await label.boundingBox();
const inputBox = await emailInput.boundingBox();
if (labelBox!.y === inputBox!.y) {
expect(labelBox!.x).toBeGreaterThan(inputBox!.x);
}
});
test('icons and arrows should mirror in RTL', async ({ page }) => {
await page.goto('/ar/products');
const backArrow = page.getByTestId('back-arrow');
if (await backArrow.isVisible()) {
const transform = await backArrow.evaluate(
(el) => window.getComputedStyle(el).transform
);
expect(transform).toMatch(/-1/);
}
const searchIcon = page.getByTestId('search-icon');
if (await searchIcon.isVisible()) {
const transform = await searchIcon.evaluate(
(el) => window.getComputedStyle(el).transform
);
expect(transform).not.toMatch(/-1/);
}
});
});
RTL Snapshot Testing
test('RTL layout snapshot should match', async ({ page }) => {
await page.goto('/ar/products');
await expect(page).toHaveScreenshot('products-rtl.png', {
maxDiffPixelRatio: 0.02,
fullPage: true,
});
});
test('LTR layout snapshot should match', async ({ page }) => {
await page.goto('/en/products');
await expect(page).toHaveScreenshot('products-ltr.png', {
maxDiffPixelRatio: 0.02,
fullPage: true,
});
});
Text Expansion & Overflow Testing
Verify UI Handles Long Translations
test.describe('Text Expansion', () => {
test('buttons should not overflow with long translations', async ({ page }) => {
await page.goto('/de/checkout');
const buttons = page.getByRole('button');
const count = await buttons.count();
for (let i = 0; i < count; i++) {
const button = buttons.nth(i);
const box = await button.boundingBox();
const isOverflowing = await button.evaluate((el) => {
return el.scrollWidth > el.clientWidth;
});
expect(
isOverflowing,
`Button "${await button.textContent()}" is overflowing at ${box?.width}px wide`
).toBeFalsy();
}
});
test('table headers should handle long translations', async ({ page }) => {
await page.goto('/de/orders');
const headers = page.getByRole('columnheader');
const count = await headers.count();
for (let i = 0; i < count; i++) {
const header = headers.nth(i);
const isOverflowing = await header.evaluate((el) => {
return el.scrollWidth > el.clientWidth;
});
expect(
isOverflowing,
`Column header "${await header.textContent()}" is overflowing`
).toBeFalsy();
}
});
});
Localization Test Checklist
Translation Coverage
Formatting
Pluralization
RTL Support
Text Expansion
Translation Files
Related Resources