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.
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.
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.
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
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
hreflangannotations, 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.
Related
- Pseudo-locale strings breaking ICU placeholders — why the transform has to walk the message AST rather than its characters.
- Detecting hardcoded strings in a React app — three detection methods whose blind spots do not overlap.
- Text expansion clipping in fixed-width buttons — asserting scrollWidth against clientWidth under a padded pseudo-locale.
- Screenshot diffing RTL layouts in CI — pinning fonts, animations and the clock so a mirrored baseline can be trusted.
- RTL & Bidirectional Layout Engineering — the direction defects the mirrored pseudo-locale is designed to surface.
- ICU Message Format Deep Dive — the message structure the transform must preserve byte-for-byte.
- String Catalog Governance — the catalogue conventions that make a clean pseudo transform possible.
- Fallback Chain Configuration — how the pseudo-locale resolves when a key is missing from it.
- GitHub Actions i18n CI Gates — wiring these checks into the pipeline that already gates your catalogues.