Localization testing is not “translate 5 strings, ship it”. It's layout mirroring, variable-width scripts, plural rule libraries, format APIs, and Unicode normalization — each a distinct class of bug with its own test strategy. Five categories, plus the pseudo-locale cheat that catches most of them for free.
The coverage stat: a typical app with 20 supported locales running only English tests catches maybe 30 % of i18n bugs. The cheap wins (logical CSS, Intl.*, Unicode normalization) get you to ~80 % without extra translators.
1. RTL — layout mirroring for Arabic, Hebrew, Farsi
Not just text direction. The entire layout flips — navigation, icons, scrollbars, dropdowns, progress bars.
Bugs to catch: Icons that don't mirror (arrows, chevrons), padding/margin on wrong side (left-padding instead of inline-start), floating elements stuck to the wrong edge, progress bars filling right-to-left when they should fill start-to-end.
Example
import { test, expect } from '@playwright/test';
test.describe('RTL layout survives Arabic locale', () => {
test.use({ locale: 'ar-SA' });
test('navigation mirrors', async ({ page }) => {
await page.goto('/');
// HTML element must carry dir="rtl"
const dir = await page.getAttribute('html', 'dir');
expect(dir).toBe('rtl');
// Back button chevron must point RIGHT in RTL (visually = back in reading order)
const chevron = page.getByTestId('back-chevron');
const transform = await chevron.evaluate(
(el) => getComputedStyle(el).transform,
);
// Either a scaleX(-1) or a rotate — NOT identity matrix
expect(transform).not.toBe('none');
});
test('logical properties used, not left/right', async ({ page }) => {
await page.goto('/');
const aside = page.getByRole('complementary');
// Padding should be inline-start / inline-end, not left / right
const paddingStart = await aside.evaluate(
(el) => getComputedStyle(el).paddingInlineStart,
);
expect(paddingStart).not.toBe('0px');
});
});
Trap: Using margin-left / padding-right hard-codes LTR assumptions. Use logical properties (margin-inline-start, padding-inline-end) or Tailwind's ms-/me-/pe-/ps- prefixes. A single `text-align: right` can survive flipping; `float: right` won't.
2. CJK — Chinese, Japanese, Korean text width
One CJK character is ~2× the visual width of a Latin character. Layouts tuned to English word-wrapping break on CJK.
Bugs to catch: Labels that fit 'Submit' but clip '送信する', word-break across ideograms (no whitespace means no break), line-height collapse for CJK fonts, vertical-writing-mode (writing-mode: vertical-rl) for formal Japanese text.
Example
test.use({ locale: 'ja-JP' });
test('button text does not clip in Japanese', async ({ page }) => {
await page.goto('/checkout');
const submit = page.getByRole('button', { name: /送信|submit/i });
const text = await submit.textContent();
// clientWidth is rendered, scrollWidth is intrinsic — must match
const { rendered, intrinsic } = await submit.evaluate((el) => ({
rendered: el.clientWidth,
intrinsic: el.scrollWidth,
}));
// If rendered < intrinsic, the text is being clipped
expect(rendered, `"${text}" is clipped in button`)
.toBeGreaterThanOrEqual(intrinsic);
});
test('long CJK text wraps correctly', async ({ page }) => {
await page.goto('/products/42');
// East Asian "word break: keep-all" vs "break-word" differ visually —
// visual regression is the only reliable assertion here
await expect(page.getByTestId('description'))
.toHaveScreenshot('description-ja.png', { maxDiffPixelRatio: 0.01 });
});
Trap: Using `white-space: nowrap` on Latin UIs that appear fine will silently break CJK: an 8-character Japanese phrase may exceed a 200px container. CSS `overflow-wrap: break-word` is not enough — `word-break: break-all` or `line-break: anywhere` may be needed for formal CJK.
3. Pluralization — CLDR plural rules per locale
English has 2 plural forms (one, other). Russian has 4. Arabic has 6. A hard-coded `count === 1 ? 'item' : 'items'` is wrong for ~40 % of users.
Bugs to catch: '1 сообщений' (Russian) instead of '1 сообщение' — singular form unused. Zero handling missing ('0 items' reads fine in English, 'ноль сообщения' wrong in Russian). Ordinal vs cardinal pluralization conflated (1st / 2nd / 3rd rules differ from 1/2/3).
Trap: Interpolating numbers directly: `"You have " + count + " items"`. Even English breaks: 'You have 1 items'. Use ICU MessageFormat, Fluent, or Intl.PluralRules — they encapsulate CLDR rules correctly for every locale.
4. Date, number, currency — Intl API is the baseline
Every locale formats differently. `1,234.56` (en) is `1.234,56` (de) is `1 234,56` (fr). Hand-rolled formatters always lose.
Bugs to catch: Date as `01/02/2026` ambiguous (Jan 2 in US, Feb 1 elsewhere). Currency symbol wrong side of number ($5 vs 5 €). Thousands separator swallowed by CSV export. Week-start-day varies (Sunday vs Monday) — calendar widgets shift.
Example
// Always use Intl.*
const price = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(1234.56);
// → "1.234,56 €"
const date = new Intl.DateTimeFormat('ja-JP', {
dateStyle: 'long',
}).format(new Date('2026-04-21'));
// → "2026年4月21日"
// Test every locale for every format
test('currency renders in locale-appropriate format', async ({ browser }) => {
const cases = [
{ locale: 'en-US', amount: 1234.56, expect: '$1,234.56' },
{ locale: 'de-DE', amount: 1234.56, expect: '1.234,56\u00A0€' }, // NBSP!
{ locale: 'ja-JP', amount: 1234, expect: '¥1,234' },
{ locale: 'en-IN', amount: 100000, expect: '₹1,00,000' }, // lakh format
];
for (const { locale, amount, expect: expected } of cases) {
const ctx = await browser.newContext({ locale });
const page = await ctx.newPage();
await page.goto(`/price?amount=${amount}`);
await expect(page.getByTestId('price')).toHaveText(expected);
}
});
Trap: Non-breaking space (U+00A0) in currency and units: '1.234,56 €' uses NBSP, not a regular space. Test assertions with regular spaces silently miss the bug. Inspect the raw bytes with .textContent, not the rendered-looking string.
5. Unicode — normalization, emoji, combining marks
Two strings that look identical may have different byte representations. User search, duplicate detection, sorting all depend on normalization.
Bugs to catch: 'é' as a single codepoint (U+00E9, NFC) vs 'e' + combining acute (U+0065 U+0301, NFD) — unequal by default. Emoji with skin-tone modifiers has length > 1 via .length but 1 visible character. Sorting 'ä' places differently in German (after 'a') vs Swedish (after 'z').
Example
// NFC normalization — default for most web input; NFD is decomposed
const cafe1 = 'café'; // one char
const cafe2 = 'cafe' + '\u0301'; // four chars, combining
console.log(cafe1 === cafe2); // false
console.log(cafe1 === cafe2.normalize('NFC')); // true
// Always normalize user input BEFORE storage + comparison
const email = form.email.value.normalize('NFC').toLowerCase();
// Emoji length trap
const flag = '🇯🇵';
console.log(flag.length); // 4 (two surrogate pairs)
console.log([...flag].length); // 2 (two Regional Indicator codepoints)
// Use Intl.Segmenter for correct character count
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const graphemes = Array.from(segmenter.segment(flag));
console.log(graphemes.length); // 1 (user-perceived character)
// Sorting: locale matters
['ä', 'z', 'a'].sort(new Intl.Collator('de').compare);
// → ['a', 'ä', 'z'] (German: ä after a)
['ä', 'z', 'a'].sort(new Intl.Collator('sv').compare);
// → ['a', 'z', 'ä'] (Swedish: ä after z)
// Test: signup form rejects duplicate "café" variants
test('user signup normalizes email', async ({ request }) => {
const nfc = { email: 'café@test.dev', password: 'x' };
const nfd = { email: 'cafe\u0301@test.dev', password: 'x' };
await request.post('/api/signup', { data: nfc });
const res = await request.post('/api/signup', { data: nfd });
expect(res.status(), 'NFD variant must be seen as duplicate').toBe(409);
});
Trap: Comparing strings with === on user input without normalization. Your DB indexes on 'café' but users paste 'café' with NFD; they never match. Normalize at every boundary — storage, query, comparison, display.
Bonus — the pseudo-locale cheat
Add a fake locale that visually resembles English but has accented characters and +30 % length padding. Catches most layout bugs without needing real translations — costs nothing, integrates into every test run.
Pseudo-locale generator
// Before shipping to real translators, catch 70% of bugs with pseudo-locale:
// Replace every string with a same-visual-length expanded pseudo-translation.
function toPseudo(str) {
const map = { a: 'á', e: 'ë', i: 'ï', o: 'ø', u: 'ü', A: 'Á', E: 'Ë',
I: 'Ï', O: 'Ø', U: 'Ü', c: 'ç', n: 'ñ' };
const converted = [...str].map((ch) => map[ch] ?? ch).join('');
// Surround + pad by 30 % to catch width issues
return `[→${converted} ${'✦'.repeat(Math.ceil(str.length * 0.3))}←]`;
}
// Serve pseudo-locale when ?lang=pseudo
// All your UI now reads: [→ẄëlçØmë ✦✦✦←]
//
// Layouts that break here WILL break in DE, JA, AR, RU.
// Costs nothing — no translators, no time, no real localization data needed.