Localization Testing 101: How to Catch Translation Bugs Before They Reach Production
Localization bugs have a particularly nasty quality: they're invisible until they're not. A truncated German button label, a broken Arabic layout, a missing plural form in Polish — none of these trip your crash reporter, and by the time a user in Munich or Cairo notices, you're already looking at a coordinated fix across dev and translation teams. This guide lays out a three-layer testing strategy — pseudo-localization, layout testing, and string validation — that gives you a repeatable system for catching these issues before they ship. It's aimed at mobile developers on iOS, Android, and React Native who are either setting up a localization QA process for the first time or formalizing something that's been running on instinct.
Layer 1: Pseudo-Localization — Your First Line of Defense
Pseudo-localization is the practice of replacing your source strings with transformed versions that simulate the visual and structural properties of translated text — without involving any actual translation. Characters get accented or replaced, strings get padded to simulate expansion, and text direction gets flipped for RTL testing. The goal is to surface layout and encoding issues while your source strings are still the only strings you have.
This matters because it decouples two concerns that are often conflated: "does the translation exist?" and "will the UI handle translated text correctly?" You don't need a German translator to discover that your tab bar label clips at more than 12 characters. Pseudo-localization tells you that in five minutes.
Enabling pseudo-locales on Android
Android ships two built-in pseudo-locales you can enable via developer options or directly in your build config:
en-XA: Accented English with string expansion (~30% padding)
ar-XB: Pseudo-bidi (right-to-left mirroring for RTL testing)
To enable them programmatically in your debug build:
<!-- res/values/developer_config.xml -->
<!-- No config needed — enable in Settings > Developer Options > Force RTL layout -->
For automated testing, you can force locale in your instrumented tests:
// Kotlin — force pseudo-locale in UI tests
val context = InstrumentationRegistry.getInstrumentation().targetContext
val config = Configuration(context.resources.configuration)
config.setLocale(Locale.forLanguageTag("en-XA"))
context.createConfigurationContext(config)
Enabling pseudo-localization on iOS
In Xcode, edit your scheme and set the Application Language to "Double-Length Pseudolanguage" or "Right-to-Left Pseudolanguage" under the Options tab. No code changes required — it's a scheme-level flag:
Xcode → Product → Scheme → Edit Scheme → Run → Options
Application Language: Double-Length Pseudolanguage
Application Region: (leave as-is for layout testing)
React Native
React Native doesn't have a native pseudo-locale mechanism, so you'll need to handle this at the i18n library level. If you're using i18next or react-i18next, you can write a pseudo-locale JSON file that pads all values:
{
"welcome_message": "[!!Ŵéļćőmé ţő őūŕ áppļīćáţīőņ!!]",
"submit_button": "[!!Šūbmīţ!!]",
"cancel": "[!!Čáņćéļ!!]"
}
A simple script can generate this from your source en.json automatically as part of your build process.
What to look for
Once pseudo-localization is running, the failure modes to watch for are:
- Text truncation: Any label, button, or navigation title that clips or truncates under the padded pseudo strings
- Hardcoded strings: Text that doesn't get pseudo-translated is hardcoded — these are strings you'll never be able to localize without a code change
- UI overflow: Text that breaks outside its container, overlaps adjacent elements, or causes layout reflow
- Missing RTL mirroring: Under
ar-XB, elements that should mirror (back buttons, progress indicators, list row arrows) but don't
The single most common mistake teams make with pseudo-localization is running it once at project kickoff and never again. New screens get built, new components get added, and hardcoded strings quietly accumulate. Pseudo-localization should be part of your release cycle, not a one-time audit.
Layer 2: Layout Testing Across Locales
Once you have real translations — or even just estimates based on language family expansion ratios — layout testing becomes your primary tool for catching visual regressions.
String expansion by language family
Different languages expand your English source strings by predictably different amounts. These aren't hard rules, but they're reliable enough to design against:
| Language |
Typical expansion vs. English |
| German |
+20–35% |
| Finnish |
+30–45% |
| French |
+15–25% |
| Russian |
+15–20% |
| Arabic |
Bidirectional reflow + potential expansion |
| Japanese/Chinese |
Often shorter, but character-level wrapping behaves differently |
The implication: if your UI handles German, it probably handles most European languages. Arabic is a different category entirely — it's not just expansion, it's a complete reflow of the layout direction. See our deep-dive on scaling Arabic and Hebrew support for specifics on the RTL layer.
Screenshot/snapshot testing for localized strings
The most practical approach to layout testing at scale is snapshot testing with locale injection. You're capturing a reference screenshot per locale, then diffing against it on every CI run.
iOS — XCTest with UITestingLocale
// XCTestCase — set locale before launching app
override func setUp() {
super.setUp()
let app = XCUIApplication()
app.launchArguments += ["-AppleLanguages", "(de)"]
app.launchArguments += ["-AppleLocale", "de_DE"]
app.launch()
}
func testHomeScreenGerman() {
let app = XCUIApplication()
// Assert key elements are visible and not clipped
XCTAssert(app.navigationBars["Startseite"].exists)
XCTAssert(app.buttons["Weiter"].isHittable)
}
For full screenshot diffing, pair this with swift-snapshot-testing from Point-Free or iOSSnapshotTestCase.
Android — Robolectric locale injection
@Test
fun homeScreen_german_noTruncation() {
val germanLocale = Locale("de", "DE")
val config = RuntimeEnvironment.getApplication()
.resources.configuration
config.setLocale(germanLocale)
RuntimeEnvironment.getApplication()
.createConfigurationContext(config)
val scenario = launchActivity<HomeActivity>()
scenario.onActivity { activity ->
val button = activity.findViewById<Button>(R.id.submit_button)
assertFalse("Button text truncated", button.layout?.getEllipsisCount(0) ?: 0 > 0)
}
}
React Native — Detox locale flags
// detox.config.js — define locale-specific device configurations
{
"devices": {
"simulator_de": {
"type": "ios.simulator",
"device": { "type": "iPhone 14" },
"bootArgs": "-AppleLanguages (de) -AppleLocale de_DE"
}
}
}
The five components most likely to break
If you're triaging where to focus first, these are the components that fail most often under localization:
- Navigation titles — often constrained width, German and Finnish titles are long
- Tab bar labels — extremely tight, any expansion past ~10 characters risks truncation or line-wrapping on smaller devices
- Dynamic buttons — buttons whose label comes from data (e.g., "Add 3 items to cart") are a double hazard: string expansion and dynamic content
- Form labels — inline labels next to input fields have almost no overflow tolerance
- Notification strings — OS-truncated at the system level, no layout control available to you
Building a locale matrix without doubling test time
The trick is not to run every test in every locale — that's combinatorially expensive and rarely catches issues that locale-targeted tests don't. Instead:
- Run pseudo-locale tests on every build (fast, no translations required)
- Run your five highest-risk locales (typically: German, French, Arabic, Japanese, and one CJK) on every PR that touches UI components
- Run the full locale matrix nightly or pre-release
Tag your UI tests with @LocaleSensitive or equivalent so your CI configuration can selectively run locale tests without touching the full suite.
Layer 3: String Validation and Structural Checks
Automated string validation is the cheapest category of localization testing — it runs in seconds, requires no device or simulator, and catches the class of bugs that human reviewers reliably miss because they're structural, not visual.
Checks every pipeline should run
- Placeholder parity: Every format specifier in the source string (
%s, %1$d, {name}, %(count)d) must appear in the translated string. Missing or reordered positional arguments cause crashes at runtime. See why placeholders break during translation for a full breakdown of failure modes.
- Missing keys: Any key present in the source locale but absent in a translated locale will silently fall back to the source language — or crash, depending on your i18n library's configuration.
- Untranslated fallback leakage: The inverse — keys that have been "translated" but still contain the original English string, often because a translator skipped them.
- Duplicate keys: Duplicate keys in
.strings, strings.xml, or JSON files produce inconsistent behavior across platforms — Android Lint flags some of these, but not all.
Format-specific pitfalls
iOS .stringsdict
Malformed plural rules are the most common .stringsdict bug. Every NSStringPluralRuleType entry must include at least an other key, and the format specifier must be consistent across all plural forms:
<!-- BROKEN: mismatched format specifier in 'one' form -->
<key>%#@items@</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>one</key>
<string>%d item</string> <!-- %d, not %#@items@ -->
<key>other</key>
<string>%#@items@ items</string>
</dict>
For a complete guide to plural rules across platforms, our posts on iOS pluralization with stringsdict, Android plurals, and React Native pluralization cover each in depth.
Android strings.xml
XML entity encoding issues are common when translation is done outside of a proper toolchain. Unescaped ampersands, apostrophes in single-quoted strings, and invalid Unicode escapes all produce either build errors or silent display corruption:
<!-- BROKEN: unescaped apostrophe in single-quoted string -->
<string name="error_message">'It's broken'</string>
<!-- CORRECT -->
<string name="error_message">It\'s broken</string>
<!-- BROKEN: unescaped ampersand -->
<string name="company_name">Smith & Jones</string>
<!-- CORRECT -->
<string name="company_name">Smith & Jones</string>
This is one of the more insidious failure modes with AI-assisted translation pipelines — see 5 ways AI translation corrupts mobile resource files for a catalog of what goes wrong.
React Native JSON
Invalid JSON is the simplest failure mode — a trailing comma, an unescaped quote, a missing bracket. These fail loudly at parse time, which is actually preferable to silent corruption. More subtle is inconsistent nesting structure between your source and translated files, which breaks key resolution at runtime without throwing an error.
// Source en.json — flat structure
{ "auth.login.button": "Log In" }
// Broken de.json — accidentally nested during translation
{ "auth": { "login": { "button": "Anmelden" } } }
Tooling
- Android Lint: Built-in i18n rules cover missing translations, extra translations, and some format string issues. Run with
./gradlew lint and pay attention to MissingTranslation and ExtraTranslation.
- ibtool: Validates
.strings and .stringsdict files: ibtool --validate MyLocalizable.stringsdict
- i18n-lint (OSS): Catches placeholder mismatches and missing keys across JSON-based i18n files
- android-resource-checker (OSS): Validates
strings.xml consistency across locale folders
- Custom scripts: For placeholder parity and structural checks, a 50-line Python or Node script checking your specific format specifiers is often more reliable than a general-purpose tool
CI/CD integration: gate vs. warn
Not every check should block a build. A reasonable split:
| Check |
CI behavior |
| Missing keys in primary locales (e.g., German, French) |
Gate — fail the build |
| Placeholder parity errors |
Gate — these cause runtime crashes |
| Untranslated fallback leakage |
Warn — log to Slack/issue tracker |
| Missing keys in long-tail locales |
Warn — surface for triage |
| Duplicate keys |
Gate — behavior is undefined |
If you're building this into a CI pipeline, our post on string extraction automation and CI/CD pipelines covers the integration mechanics in detail.
Building a Localization QA Checklist for Your Team
A testing strategy only works if it's executable. Here's a practical checklist organized by phase:
Pre-translation
- [ ] Pseudo-locale test passes on all new screens (no truncation, no hardcoded strings)
- [ ] All new string keys follow naming convention and include developer comments/context
- [ ] Placeholder format is consistent with platform convention (%1$s for Android, %@ for iOS, named params for React Native)
- [ ] Plural strings use the correct platform format (.stringsdict, <plurals>, ICU)
- [ ] No duplicate keys in source locale files
Post-translation
- [ ] Placeholder parity check passes for all languages
- [ ] No missing keys in primary supported locales
- [ ] XML/JSON structural validation passes
- [ ] Untranslated fallback check run, results triaged
- [ ] Layout snapshot tests pass for German, French, Arabic (minimum)
Pre-release gate
- [ ] Full locale matrix layout tests run and reviewed
- [ ] RTL layout verified under ar-XB pseudo-locale and at minimum one real RTL locale
- [ ] Notification strings tested at OS truncation limits
- [ ] Dynamic string combinations tested (plurals + format specifiers in same string)
- [ ] Sign-off from both engineering and localization leads
Assigning ownership
| Activity |
Owner |
| Pseudo-localization testing |
Platform engineer on the feature team |
| Layout snapshot review |
QA engineer or feature engineer pre-PR |
| String validation (CI) |
Automated — owned by localization platform team |
| Post-translation review |
Localization manager or in-market reviewer |
| Pre-release gate sign-off |
Engineering lead + localization lead |
Cadence recommendations
For small teams (1–2 engineers touching localization): Run pseudo-localization before every release, run layout tests for your top 3 locales before release, and run string validation on every PR that touches resource files. Don't try to automate everything at once — start with one lint rule and one snapshot test, then expand.
For teams with continuous localization pipelines: Pseudo-locale tests and string validation should run on every PR. Layout tests for primary locales should run on every PR touching UI. Full locale matrix runs nightly and pre-release. If you're managing localization at scale and evaluating when to formalize the process further, the patterns in cross-platform string management apply directly here.
Conclusion
Localization testing isn't a one-time audit you do before launching in a new market. It's a repeatable system — three layers, each catching a different class of bug, each runnable on a cadence that fits your team's velocity. The cost of not having it is silent failures that affect entire markets and require coordinated fixes across engineering and translation teams.
If you're starting from zero, here are two things you can do this week that will immediately improve your signal:
- Enable pseudo-locale in one Xcode scheme or one Android build variant. Run it against your most complex screen. You'll almost certainly find something.
- Add a placeholder parity check to your CI pipeline. It's a small script, it runs in seconds, and it blocks the class of bug that causes runtime crashes in production.
The next post in this series goes deeper on RTL-specific layout testing — systematic approaches to verifying Arabic and Hebrew layouts, including component-level mirroring checks, bidirectional text edge cases, and how to structure your test suite to cover RTL without maintaining a parallel codebase. If RTL is already causing you pain, the RTL layout testing checklist is a good place to start while you wait.
Localization bugs are uniquely costly precisely because they're so easy to miss. A testing system that catches them early isn't overhead — it's what makes shipping to multiple markets feel like a routine operation instead of a coordinated risk.