Localization Testing & Pseudolocalization

Your suite is green, every string is externalized, and the German build still ships a “Zahlungsmethode ändern” button with the last four characters cut off. Nothing failed, because nothing was testing the one thing that broke: how the interface behaves when the text is not English. Localization testing closes that gap, and pseudolocalization is the technique that makes it possible on day one — before a single word has been translated.

A pseudo-locale is a synthetic locale generated from your source catalogue. Every literal character is replaced with an accented look-alike, the string is padded to simulate translation growth, and the whole thing is wrapped in markers. The result is still readable to an English speaker, which means anyone on the team can spot a defect, while being different enough from English that four distinct classes of bug become visible at a glance.

How a pseudo-locale catalogue is produced The build reads the source catalogue, walks each message with an ICU-aware parser so argument names are never altered, applies accenting, length expansion and boundary markers to the literal text only, and writes the result as a separate pseudo-locale bundle that ships alongside the real ones. Generating a pseudo-locale bundle at build time Source catalogue en.json parse ICU-aware walk skip arguments transform Accent + expand + wrap [!!! Ŝàvé çĥàñĝéŝ !!!] Pseudo bundle en-XA.json Placeholders and ICU argument names pass through untouched — only literal text is transformed.
The transform runs on literal text only; every {argument} passes through byte-identical.

This page sits downstream of message externalization and upstream of any real translation work. It belongs to Core i18n Architecture & Locale Negotiation because what it verifies is the architecture — that the resolver, the catalogue and the layout survive contact with a locale that is not the one the interface was designed in.

Prerequisites

Concept & spec — what a pseudo-locale is and is not

A pseudo-locale is a locale in every mechanical sense: it has a BCP 47 tag, a catalogue file and an entry in your supported list. It simply has no human language behind it. Two tags are conventional and worth adopting rather than inventing your own: en-XA for the accented, expanded Latin variant, and ar-XB for a right-to-left variant that reverses the text run while keeping Latin glyphs readable. Both use the XA/XB private-use region subtags, which are valid BCP 47 and will never collide with a real region.

The transform itself has four independent parts, and it matters that they are independent because each one tests something different.

Accenting maps each Latin character to a visually similar accented form — a to à, S to Ŝ. Any string that renders as plain unaccented English after this transform did not come from the catalogue, which makes it a hardcoded string. This is the only automated check that reliably finds text baked into a component, an image alt attribute, or an error thrown from a utility function.

Length padding appends filler so the string is longer than the source by a fixed factor. Real translations run longer than English in most European languages, and the growth is not uniform: short strings expand proportionally far more than long ones, because a single word rarely has a short synonym in the target language. Padding by thirty to fifty percent for short strings and less for long ones approximates real behaviour closely enough to find every fixed-width container in the interface.

Boundary markers wrap the string in a recognisable delimiter — commonly square brackets with exclamation marks. A marker appearing in the middle of a rendered sentence proves the sentence was assembled from more than one catalogue entry, which is the concatenation anti-pattern that makes correct translation impossible in any language with grammatical agreement.

Bidi wrapping inserts right-to-left control characters, or generates a mirrored variant outright, to reveal components that assume a direction. Punctuation drifting to the wrong end of a line, or a numeric range reading backwards, both point at a missing direction attribute or a hardcoded physical CSS property.

The four pseudolocalization techniques and their targets Accenting every character reveals strings that were never externalized, because they stay plain English. Length padding reveals containers that cannot grow. Boundary markers reveal sentences assembled from fragments, because a marker appears mid-line. Bidi wrapping reveals components that never set a direction, because punctuation lands on the wrong end. What each pseudo technique is actually testing Catches Looks like when it fails Accenting hardcoded source strings plain unaccented English Length padding fixed-width containers clipped or wrapped labels Boundary markers concatenated fragments a marker in the middle of a line Bidi wrapping missing dir handling punctuation on the wrong end
Each technique targets one defect class — running only the accenting step tests only one of the four.

What a pseudo-locale is not is a substitute for reading the real language. It verifies that the machinery is correct and the layout is elastic. It cannot tell you that the German word chosen for “submit” means something closer to “surrender” in your product’s context. Those are different tests with different costs, and the point of pseudolocalization is to make the cheap one exhaustive so the expensive one can focus on meaning.

Typical length change relative to English source text Compared with English source text, German UI strings typically run about a third longer, French and Spanish about a quarter longer, Russian and Arabic somewhat less, while Japanese is usually shorter. Short strings expand proportionally more than long ones, which is why buttons and labels break first. Typical text expansion over English German 35% French 25% Spanish 25% Russian 20% Arabic 25% Japanese -10%
Short strings expand the most in proportion — which is why a button breaks long before a paragraph does.

Step-by-step implementation

1. Write an ICU-aware transform

The transform must never touch anything inside an argument. Parsing the message and walking only its literal parts is the difference between a pseudo-locale that works and one that throws at runtime because {count} became {çôûñt}.

import { parse, TYPE } from '@formatjs/icu-messageformat-parser';

const ACCENTS: Record<string, string> = {
  a: 'à', c: 'ç', e: 'é', i: 'ì', n: 'ñ', o: 'ô', s: 'ŝ', u: 'ù', y: 'ý',
  A: 'À', C: 'Ç', E: 'É', I: 'Ì', N: 'Ñ', O: 'Ô', S: 'Ŝ', U: 'Ù', Y: 'Ý',
};

// Short strings need a bigger factor: a single word rarely shrinks in translation.
function padFactor(len: number): number {
  if (len <= 10) return 1.6;
  if (len <= 30) return 1.4;
  return 1.25;
}

function accent(text: string): string {
  const mapped = [...text].map((ch) => ACCENTS[ch] ?? ch).join('');
  const target = Math.round(text.length * padFactor(text.length));
  const filler = '·'.repeat(Math.max(0, target - text.length));
  return mapped + filler;
}

export function pseudo(message: string): string {
  const ast = parse(message);           // throws on malformed ICU — a useful gate in itself
  const out = ast.map((node) =>
    node.type === TYPE.literal ? accent(node.value) : renderBack(node)
  );
  return `[!! ${out.join('')} !!]`;      // markers wrap the whole message, once
}

2. Generate the bundle as a build artifact

The pseudo catalogue is derived data and must never be committed, edited or translated. Generating it in the build guarantees it is always in step with the source and can never drift.

{
  "scripts": {
    "i18n:pseudo": "node scripts/make-pseudo.mjs locales/en.json locales/en-XA.json",
    "build": "npm run i18n:extract && npm run i18n:pseudo && vite build"
  }
}

3. Register the pseudo-locale, but only where it belongs

The pseudo tag must be resolvable in development and CI, and must be unreachable in production. Gating it on the build mode keeps it out of the supported list that drives your language switcher and your hreflang annotations.

const REAL_LOCALES = ['en', 'de', 'fr', 'ja', 'ar'] as const;

export const SUPPORTED = import.meta.env.PROD
  ? REAL_LOCALES
  : [...REAL_LOCALES, 'en-XA', 'ar-XB'];

4. Assert the structural invariants

Two assertions catch most transform regressions: every message still parses, and every argument name survives. Both are cheap enough to run on every push.

import { parse } from '@formatjs/icu-messageformat-parser';
import source from '../locales/en.json';
import pseudo from '../locales/en-XA.json';

test('pseudo bundle preserves structure', () => {
  for (const [key, msg] of Object.entries(source)) {
    const pseudoMsg = pseudo[key];
    expect(pseudoMsg, `missing key ${key}`).toBeDefined();
    expect(() => parse(pseudoMsg)).not.toThrow();
    expect(argNames(pseudoMsg)).toEqual(argNames(msg));
  }
});

5. Render components under the pseudo-locale

A snapshot taken under en-XA fails when a container clips, because the padded string no longer fits. Assert on layout facts rather than on the text itself — the text is deliberately unstable.

test('primary action survives expansion', async () => {
  const { getByRole } = render(<Checkout />, { locale: 'en-XA' });
  const button = getByRole('button', { name: /Ŝàvé/ });
  expect(button.scrollWidth).toBeLessThanOrEqual(button.clientWidth);
});

6. Compare screenshots across three locales

The final tier renders the same route in English, the pseudo-locale and a real right-to-left locale, and diffs the results. This is what catches mirrored icons, misaligned punctuation and overlapping absolute positioning that no unit test models.

- name: Visual sweep
  run: |
    for loc in en en-XA ar; do
      npx playwright test --grep @visual -- --locale="$loc"
    done
Four tiers of localization testing by cost Cheap structural assertions that the pseudo bundle parses and preserves arguments run on every push. Component snapshots rendered under the pseudo locale also run on every push. Screenshot comparison across English, the pseudo locale and Arabic runs when the interface changes. A human review in the real target language happens before that locale launches. Where each check runs 1 Unit tests, every push pseudo bundle parses and keeps every argument 2 Component snapshots, every push render under en-XA, assert no clipping class 3 Visual diff, on UI changes screenshot en, en-XA and ar side by side 4 Manual sweep, before a locale launch a human reads the real language
Automation catches structure; only the last tier catches meaning, and it is the only one you cannot skip.

Configuration reference

Option Type Description / default
expansion number | (len) => number Growth factor applied to literal text. A flat 1.3 is common; a length-aware function models real translations better. Default 1.3.
accentMap Record<string, string> Character substitutions. Keep every glyph inside the font subset you actually ship, or the test fails on missing glyphs rather than on layout.
markers [string, string] Opening and closing delimiters. Default ['[!! ', ' !!]']. Must be visually distinct from any real punctuation.
skipKeys string[] Keys to pass through untransformed — typically brand names, currency codes and anything asserted on by another test.
bidi boolean Wrap output in RLE/PDF control characters to force a right-to-left run. Default false; use the ar-XB variant instead for full mirroring.
emitTag string Tag the generated bundle is written under. Default en-XA; never a real locale.

Framework variants

React / Next.js. Generate the bundle before the build and add the pseudo tag to the locale list only outside production. Under the App Router, the pseudo-locale is just another segment, which means the router, the middleware and the metadata generation all get exercised by it — a genuine advantage over injecting pseudo strings at runtime. Snapshot tests should render through the same provider the app uses, so a missing provider is itself caught.

Vue / Nuxt. Register the pseudo catalogue as an extra locale in the module configuration and exclude it from the generated hreflang list, or crawlers will discover a language that does not exist. Because Nuxt reads locale files at build time, the transform must run before the module resolves its catalogues; a pre-build script is the reliable ordering.

Angular. The build-time model makes this straightforward: emit an en-XA XLIFF target alongside the real ones and add a matching configuration in angular.json. The pseudo build is then a full, separate artifact, which is exactly what you want for a visual sweep — it exercises the same inlining path production uses. Keep the configuration out of the default build target so it is never deployed by accident.

Node.js backend. Server-rendered strings, emails and PDF templates are where hardcoded text hides longest, because nobody looks at them in a browser. Run the pseudo-locale through the same rendering entry points and assert that the output contains no unaccented Latin words above a small allowlist of brand terms. It is a blunt check, and it finds things nothing else does.

Verification

Three signals together mean the system works. The structural test proves the transform preserves every key and argument. A component snapshot under the pseudo-locale proves the layout has slack. A screenshot diff against a right-to-left locale proves direction is handled at the root rather than per component.

# 1. Structure — must be silent
npx vitest run i18n/pseudo

# 2. Any unaccented English left in the rendered pseudo build is a hardcoded string
npx playwright test --grep @pseudo -- --locale=en-XA

# 3. Expected output
#   ✓ pseudo bundle preserves structure (48 keys)
#   ✓ no hardcoded strings in rendered output
#   ✓ no clipped containers at 1.6× expansion

Choosing which real locales to test

Pseudolocalization tells you the machinery works. It cannot tell you the machinery works for Japanese, because a synthetic Latin locale exercises none of the properties that make Japanese hard. Once the pseudo tier is clean, pick a small set of real locales whose properties are genuinely different from each other, and test those.

Four properties matter, and one locale per property is usually enough. A long, compounding language — German is the standard choice — stresses layout the way nothing else does. A right-to-left language such as Arabic or Hebrew exercises direction, mirroring and bidirectional isolation. A non-Latin script with different line-breaking behaviour — Japanese, Thai or Chinese — exercises fonts, word wrapping and input handling; Thai in particular has no spaces between words, which breaks any layout that assumes a break opportunity exists. And a language with many plural categories such as Polish or Russian exercises the message layer rather than the layout.

Testing those four covers a surprising amount of the risk of a fifty-locale product, because the remaining locales are variations on properties already exercised. Adding a fifth or sixth locale to the automated sweep has sharply diminishing returns compared with spending the same effort on coverage of more routes in the four you already have.

The exception is any locale with a regulatory or contractual commitment behind it. If a market requires specific legal wording, that locale needs a review pass regardless of how similar it is to one you already test, and that review is about content rather than layout.

Common pitfalls

  • Transforming argument names. A naive character-map over the raw string turns {count} into {çôûñt} and every message throws at render. Parse first, transform literals only.
  • Committing the pseudo bundle. Once it is a file in the repository someone will edit it, and it will drift from the source. Generate it in the build and add it to .gitignore.
  • Shipping the pseudo-locale. A pseudo tag left in the production supported list ends up in a language switcher, in hreflang annotations, and eventually in a search index. Gate it on the build mode.
  • Testing the text instead of the layout. Assertions that match pseudo strings break every time the expansion factor changes. Assert on overflow, on element counts, on the presence of markers — never on the accented text.
  • Padding uniformly. A flat factor under-tests buttons and over-tests paragraphs. Length-aware padding models real translation growth much more closely.
  • Skipping the right-to-left variant. Accenting and padding find nothing about direction. Mirrored layout defects need a mirrored locale — see RTL & bidirectional layout engineering.

FAQ

What is pseudolocalization actually for?

It tests the internationalization machinery before any translation exists. Accented, expanded, marker-wrapped strings make four defect classes visible at a glance: hardcoded text that never reached the catalogue, containers that cannot grow, sentences assembled from fragments, and components that assume a text direction. All four are cheaper to fix before translators are involved than after.

Which locale tag should a pseudo-locale use?

Use en-XA for the accented and expanded variant and ar-XB for the right-to-left variant. The XA and XB region subtags are private-use in BCP 47, so they are structurally valid, will never collide with a real region, and match the convention Chrome and Android already use — which means tooling tends to recognise them.

Can pseudolocalization replace testing in real languages?

No. It verifies structure and layout, not meaning. A pseudo-locale cannot tell you that a term is wrong in context, that a tone is too informal for the market, or that a legal phrase has a specific required wording. Its value is that it makes the mechanical failures exhaustive and automatic, so human review time goes to the judgement calls only a speaker of the language can make.

How much expansion should I simulate?

Between thirty and sixty percent for short strings and around twenty-five percent for long ones. German is the usual worst case in European languages, running roughly a third longer than English, and short UI labels expand proportionally more than prose. If a container survives a sixty percent expansion on its shortest label, real translations will not break it.

Should the pseudo build run on every pull request?

The cheap parts should: parsing the pseudo bundle and rendering component snapshots under it cost seconds. The screenshot sweep across three locales is better placed on changes that touch the interface, because it is slower and its failures need a human to interpret. Splitting the tiers this way keeps the fast feedback fast without losing the coverage.

Part of Core i18n Architecture & Locale Negotiation.