GetTranslated.AI
Mobile App Development

Localization Testing With Pseudo-Localization: A Platform-by-Platform Setup Guide for iOS, Android, and React Native

Localization Testing With Pseudo-Localization: A Platform-by-Platform Setup Guide for iOS, Android, and React Native

Pseudo-localization is the localization test most teams skip, which is a shame because it catches the kind of layout and string-handling bugs that only surface after you've shipped to a real locale. This guide covers the practical setup — not the theory — for iOS, Android, and React Native, including CI integration and the specific failure modes each platform's tooling won't catch on its own.


What Pseudo-Localization Actually Does

Before diving into setup, it's worth being precise about what pseudo-localization is and isn't. It's not translation. It's a deterministic transform applied to your source strings that simulates the structural properties of localized content without requiring actual translations.

A typical pseudo-localization transform does some combination of the following:

  • Accent overlay: Replaces ASCII characters with visually similar accented equivalents (Hello → Ȟéļļö). This surfaces custom font issues and encoding problems.
  • String expansion: Pads strings to simulate languages that run longer than English — typically 30–40% for Germanic languages, sometimes more for Finnish or Hungarian on shorter strings.
  • Bracket wrapping: Wraps strings in [!! … !!] or similar markers so you can immediately identify any string that appears in the UI without its wrapper — a reliable signal that the string was hardcoded and never externalized.
  • RTL simulation: Wraps strings in Unicode bidi override characters to simulate right-to-left rendering without requiring a real RTL locale.

The value proposition is simple: you run this before you have any real translations, and you catch layout, truncation, and string externalization bugs early — when they're cheap to fix. After you've shipped to German and your product manager is filing tickets about clipped button labels, those bugs are considerably less cheap.

For a broader look at localization testing strategy, Localization Testing 101 covers the full testing lifecycle.


What to Test For (and Why)

Knowing what pseudo-localization surfaces helps you write better test assertions and interpret screenshot diffs correctly.

String expansion is the primary one. German and Finnish routinely run 30–40% longer than English equivalents. Short English strings are often worse — a 6-character label like "Cancel" might expand to 12–14 characters in a real locale. Fixed-width UI elements, numberOfLines = 1 labels, and buttons with intrinsic size constraints are all candidates for truncation.

Hardcoded strings are reliably caught by bracket-wrapping. If [!! !!] brackets appear around some strings but not others in your pseudo-locale UI, the unwrapped strings are hardcoded. There's no ambiguity. This is one of the few localization checks that's nearly binary — the string either went through your localization pipeline or it didn't.

Placeholder survival matters more than most teams realize. A transform that turns Hello, %@! into [!! Ȟéļļö, %@! !!] is correct. A poorly implemented transform that outputs [!! Ȟéļļö, !!%@!! has broken your format specifier and your app will crash or display garbage at runtime. Test this explicitly.

RTL mirroring gaps are partially caught by bidi simulation, though this is the weakest signal of the three. For serious RTL coverage, you'll want real Arabic or Hebrew testing — see 6 Localization Testing Failures That Only Show Up in RTL Languages for what pseudo-localization's RTL simulation will miss.


iOS: Setting Up Pseudo-Localization

Xcode ships with built-in pseudo-locale support. It's one of the better platform implementations and requires no external dependencies.

Enabling it via Xcode Scheme:

  1. Open your scheme editor: Product → Scheme → Edit Scheme (or Cmd+Shift+,)
  2. Select the Run action in the left sidebar
  3. Open the Options tab
  4. Set App Language to one of the built-in pseudo-locales

Xcode offers three options:
- Double-Length Pseudolanguage — duplicates every string (Button → ButtonButton). Useful for stress-testing layout constraints.
- Accented Pseudolanguage — applies accent overlays. Better for catching encoding and font issues.
- Bounded String Pseudolanguage — wraps strings in [!! … !!]. Your go-to for hardcoded string detection.
- Right-to-Left Pseudolanguage — applies RTL layout mirroring.

You can only select one at a time in the scheme editor, which means you'll want separate CI jobs if you're testing multiple failure modes systematically.

Launch arguments for UI tests:

// XCUIApplication launch with pseudo-locale
let app = XCUIApplication()
app.launchArguments += [
    "-AppleLanguages", "(en)",
    "-NSDoubleLocalizedStrings", "YES"  // Double-Length
]
// For accented pseudo-locale:
// "-NSAccentuateLocalizedStrings", "YES"
// For bounded string:
// "-NSShowNonLocalizedStrings", "YES"  // also highlights non-localized strings
app.launch()

For bidi/RTL simulation:

app.launchArguments += [
    "-AppleTextDirection", "YES",
    "-NSForceRightToLeftWritingDirection", "YES"
]

Running pseudo-locale in CI with xcrun simctl:

# Boot a simulator and set locale via launch arguments in your xcodebuild call
xcodebuild test \
  -scheme MyApp \
  -destination 'platform=iOS Simulator,name=iPhone 15' \
  -testLanguage en \
  OTHER_SWIFT_FLAGS="-DNS_BLOCK_ASSERTIONS" \
  INFOPLIST_PREPROCESSOR_DEFINITIONS="NSDoubleLocalizedStrings=YES"

A more reliable approach for CI is passing launch arguments through your test target's scheme, committed to source control, so the pseudo-locale setting travels with the repo.

What Xcode's pseudo-localization does NOT catch:

  • Custom fonts: A third-party or brand font that lacks proper Unicode coverage will render accented characters incorrectly. Xcode won't flag this — you'll see placeholder boxes or incorrect glyphs.
  • Attributed strings built in code: If you're constructing NSAttributedString programmatically and concatenating localized fragments, the pseudo-locale transform only applies to the individual NSLocalizedString calls, not to the assembled result. Layout bugs from attributed string composition require visual inspection.
  • String interpolation outside NSLocalizedString: Any string constructed with Swift string interpolation that bypasses the localization lookup won't be transformed.

Android: Setting Up Pseudo-Localization

Android's pseudo-locale support is built into the resource system via Gradle and the emulator. The implementation is more integrated than iOS in some ways — particularly for CI — but has a few sharp edges.

Enabling pseudo-locales in build.gradle:

// build.gradle (app module)
android {
    buildTypes {
        debug {
            pseudoLocalesEnabled true
        }
    }
}

This single flag makes two synthetic locales available in debug builds:

  • en-XA: Accented Latin characters with string expansion (~30%). Your primary layout and font testing locale.
  • ar-XB: Fake bidirectional locale. Wraps strings in bidi override characters to simulate RTL layout without requiring real Arabic strings.

Setting the pseudo-locale via ADB:

# Switch emulator/device to en-XA pseudo-locale
adb shell am start \
  -a android.intent.action.MAIN \
  -n com.android.settings/.LanguageSettings

# Or set it directly (requires API 17+, works on emulators):
adb shell setprop persist.sys.locale en-XA
adb shell setprop persist.sys.language en
adb shell setprop persist.sys.country XA
adb shell am broadcast -a android.intent.action.LOCALE_CHANGED

# Verify:
adb shell getprop persist.sys.locale

Espresso test with locale override:

// In your Espresso test setup
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)

@Before
fun setLocale() {
    val locale = Locale("en", "XA")
    Locale.setDefault(locale)
    val config = Configuration()
    config.setLocale(locale)
    InstrumentationRegistry.getInstrumentation().targetContext
        .resources.updateConfiguration(config, null)
}

@Test
fun testButtonLabelsDoNotTruncate() {
    onView(withId(R.id.submit_button))
        .check(matches(isDisplayed()))
        .check(matches(isCompletelyDisplayed()))
}

Gotchas with resource qualifiers:

This is where Android pseudo-localization can quietly mislead you. If you have resource files with values-en/ qualifiers (locale-specific English overrides), those files will shadow the pseudo-locale transform for those strings. The system selects the values-en/ resource before it reaches the pseudo-locale layer.

res/
  values/          ← pseudo-locale transforms these
    strings.xml
  values-en/       ← these shadow the transform; pseudo-locale won't apply
    strings.xml

If your hardcoded-string detection pass is missing strings you expect to catch, check for values-en/ or other qualified resource directories that might be intercepting lookups before pseudo-locale applies.

Also: pseudoLocalesEnabled is a debug build flag. Confirm your CI pipeline is building the debug variant, not a release or staging variant, before wondering why pseudo-locale isn't activating.


React Native: Setting Up Pseudo-Localization

React Native has no native pseudo-locale support. Your i18n library (typically react-i18next, i18n-js, or similar) loads JSON locale files, and the platform's built-in pseudo-locale mechanism doesn't reach into that layer. You have two practical approaches.

Option 1: Build-time string transform script

Write a Node.js script that reads your source locale JSON, applies a pseudo-localization transform to every string value, and writes the result as a new locale file (e.g., en-XA.json). Then configure your i18n library to load that file when the app locale is set to en-XA.

// scripts/pseudo-localize.js
const fs = require('fs');
const path = require('path');

const ACCENT_MAP = {
  a: 'à', b: 'ƀ', c: 'ç', d: 'ď', e: 'é', f: 'ƒ',
  g: 'ĝ', h: 'ĥ', i: 'î', j: 'ĵ', k: 'ķ', l: 'ļ',
  m: 'ɱ', n: 'ñ', o: 'ö', p: 'þ', q: 'q', r: 'ŗ',
  s: 'š', t: 'ţ', u: 'ü', v: 'v', w: 'ŵ', x: 'x',
  y: 'ý', z: 'ž',
  A: 'À', B: 'Ɓ', C: 'Ç', D: 'Ď', E: 'É', F: 'Ƒ',
  G: 'Ĝ', H: 'Ĥ', I: 'Î', J: 'Ĵ', K: 'Ķ', L: 'Ļ',
  M: 'Ɱ', N: 'Ñ', O: 'Ö', P: 'Þ', Q: 'Q', R: 'Ŗ',
  S: 'Š', T: 'Ţ', U: 'Ü', V: 'V', W: 'Ŵ', X: 'X',
  Y: 'Ý', Z: 'Ž',
};

// Regex to protect placeholders: {{var}}, %s, %d, %(name)s, etc.
const PLACEHOLDER_RE = /(\{\{[^}]+\}\}|%[sdif]|%\([^)]+\)[sdif])/g;

function pseudoLocalizeString(str) {
  // Split on placeholders to avoid transforming them
  const parts = str.split(PLACEHOLDER_RE);
  const transformed = parts.map((part, i) => {
    if (PLACEHOLDER_RE.test(part)) {
      PLACEHOLDER_RE.lastIndex = 0; // reset stateful regex
      return part; // preserve placeholders
    }
    PLACEHOLDER_RE.lastIndex = 0;
    // Accent characters
    let accented = part.split('').map(c => ACCENT_MAP[c] || c).join('');
    // Expand by ~35% with trailing padding
    const padLength = Math.ceil(part.length * 0.35);
    accented += 'xxxxxx'.slice(0, padLength);
    return accented;
  });
  return `[!! ${transformed.join('')} !!]`;
}

function transformObject(obj) {
  if (typeof obj === 'string') return pseudoLocalizeString(obj);
  if (typeof obj === 'object' && obj !== null) {
    return Object.fromEntries(
      Object.entries(obj).map(([k, v]) => [k, transformObject(v)])
    );
  }
  return obj;
}

const sourcePath = path.resolve(__dirname, '../src/locales/en.json');
const outputPath = path.resolve(__dirname, '../src/locales/en-XA.json');

const source = JSON.parse(fs.readFileSync(sourcePath, 'utf8'));
const transformed = transformObject(source);
fs.writeFileSync(outputPath, JSON.stringify(transformed, null, 2));
console.log(`Pseudo-locale written to ${outputPath}`);

Wire this into your package.json as a pre-build step:

{
  "scripts": {
    "pseudo-locale": "node scripts/pseudo-localize.js",
    "start:pseudo": "npm run pseudo-locale && PSEUDO_LOCALE=true react-native start"
  }
}

Option 2: pseudolocalization npm package

The pseudolocalization package handles the character substitution and string expansion for you, and is actively maintained. It's a reasonable choice if you don't want to own the transform logic.

npm install --save-dev pseudolocalization
// scripts/pseudo-localize.js (using the package)
const { pseudolocalize } = require('pseudolocalization');
const fs = require('fs');

const source = JSON.parse(fs.readFileSync('./src/locales/en.json', 'utf8'));

function transformValues(obj) {
  return Object.fromEntries(
    Object.entries(obj).map(([k, v]) => [
      k,
      typeof v === 'string' ? pseudolocalize(v) : transformValues(v)
    ])
  );
}

fs.writeFileSync(
  './src/locales/en-XA.json',
  JSON.stringify(transformValues(source), null, 2)
);

Once the en-XA.json file exists, tell your i18n library to treat it as a valid locale and launch your simulator with the locale set to en-XA. On iOS simulator, use the Edit Scheme → App Language → Other approach to pass a custom locale identifier. On Android emulator, use the ADB locale override command from the Android section above.

For a deeper look at React Native-specific localization gotchas, React Native vs. iOS vs. Android: Platform-Specific Localization Gotchas covers the broader landscape.


Automating Pseudo-Localization in CI

Pseudo-localization tests are fast enough to run on every PR, which is where they're most valuable — catching new hardcoded strings or layout regressions before they merge.

Where to slot it in your pipeline:

  • PR check: Run pseudo-locale UI tests (screenshot diffs, hardcoded string detection). These should be fast — under 5 minutes for most apps. Fail the PR if new unwrapped strings appear or if screenshot diffs exceed your threshold.
  • Nightly: Run the full pseudo-locale matrix — expansion, bidi simulation, RTL mirroring — with more comprehensive coverage. Save screenshots for visual review.

GitHub Actions: Android en-XA Espresso run

name: Pseudo-Locale UI Tests (en-XA)

on:
  pull_request:
    branches: [main, develop]

jobs:
  pseudo-locale-android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v3
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Enable KVM (for emulator performance)
        run: |
          echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
            | sudo tee /etc/udev/rules.d/99-kvm4all.rules
          sudo udevadm control --reload-rules
          sudo udevadm trigger --name-match=kvm

      - name: Run Espresso tests with en-XA pseudo-locale
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 33
          target: default
          arch: x86_64
          profile: pixel_6
          script: |
            adb shell setprop persist.sys.locale en-XA
            adb shell am broadcast -a android.intent.action.LOCALE_CHANGED
            ./gradlew connectedDebugAndroidTest \
              -Pandroid.testInstrumentationRunnerArguments.class=com.example.PseudoLocaleTestSuite

      - name: Upload screenshots on failure
        if: failure()
        uses: actions/upload-artifact@v3
        with:
          name: pseudo-locale-screenshots
          path: app/build/outputs/androidTest-results/

Screenshot diffing for regression detection:

Screenshot-based regression testing with pseudo-locale is high-signal. If a layout that passed last week now shows truncation in the same pseudo-locale, something changed. Tools like Paparazzi (Android) or swift-snapshot-testing (iOS) integrate cleanly with pseudo-locale runs. Commit baseline screenshots for the pseudo-locale variant and fail the build on diff.

Hardcoded string detection in CI:

For Android, any string rendered without the [!!...!!] brackets in bounded-string mode is a hardcoded string. You can automate detection by taking a screenshot during the Espresso run and running OCR or pixel analysis to find text that doesn't match the bracket pattern. This is more setup than it sounds, but a simpler proxy is to grep your codebase for common hardcoded patterns as a pre-build check — see Build-Time Locale Validation: 8 Checks Your CI Pipeline Should Run Before Every Release for a practical checklist.


What Pseudo-Localization Won't Catch

This is worth being explicit about, because pseudo-localization is sometimes oversold.

Real pluralization edge cases require real locale data. The difference between en-XA and actual Polish or Russian pluralization rules is the difference between "the string renders" and "the correct plural form is selected." Polish has four plural forms; Russian has three. Pseudo-localization won't surface pluralization bugs at all — you need actual locale data for that. See 6 Ways Claude and ChatGPT Silently Corrupt Your stringsdict and Android Plurals for what can go wrong there.

Bidirectional text logic errors require real Arabic or Hebrew. The ar-XB pseudo-locale and iOS RTL simulation will flip your layout and catch mirroring gaps, but they won't catch issues in how your app handles mixed LTR/RTL strings, bidirectional punctuation, or text cursor behavior in RTL input fields. For those, use actual Arabic or Hebrew and consult The RTL Localization Pre-Release Checklist.

Date, number, and currency formatting issues are completely invisible to pseudo-localization. These require locale-specific formatting tests with real locale data. If your app displays 1,234.56 when a German user expects 1.234,56, no pseudo-locale run will tell you that.

Semantic translation errors are obviously out of scope — pseudo-localization isn't translation. Placeholder survival is testable, but whether {{count}} items translates correctly in context requires actual translated strings.


Conclusion: Pseudo-Localization Setup Checklist

Pseudo-localization is a 30-minute setup for a test that catches bugs that would otherwise cost you hours of post-ship debugging. Here's the per-platform summary:

iOS checklist:
- [ ] Add pseudo-locale scheme to Xcode (Run → Options → App Language)
- [ ] Add launch arguments for UI test target (NSDoubleLocalizedStrings, NSShowNonLocalizedStrings)
- [ ] Commit scheme file to source control so CI picks it up
- [ ] Run bounded-string variant to catch hardcoded strings
- [ ] Audit custom fonts and attributed strings manually — pseudo-locale won't cover these

Android checklist:
- [ ] Add pseudoLocalesEnabled true to debug buildType in build.gradle
- [ ] Verify no values-en/ or qualified resource directories shadow pseudo-locale strings
- [ ] Write Espresso tests asserting key views are isCompletelyDisplayed() under en-XA
- [ ] Add ADB locale override to CI emulator setup
- [ ] Run ar-XB separately for RTL mirroring checks

React Native checklist:
- [ ] Write or adopt a build-time pseudo-localization transform script
- [ ] Protect format specifiers and interpolation placeholders in the transform
- [ ] Output en-XA.json (or equivalent) and register it with your i18n library
- [ ] Add pseudo-locale as a pre-build npm script
- [ ] Run on both iOS simulator and Android emulator

When to graduate to full locale testing:

Pseudo-localization gets you through development and PR review. Before a release targeting a new locale — especially one with distinct script systems, complex pluralization, or RTL layout requirements — run tests with real locale data and real translated strings. Pseudo-localization is a filter, not a substitute for locale-specific QA. The 5 Critical Localization Decisions That Will Make or Break Your Mobile App post covers how to think about that graduation point in the broader context of your localization strategy.

The bugs pseudo-localization catches are legitimately common — truncated labels, hardcoded strings, layout constraints that never got stress-tested — and they're nearly free to catch early. There's not much reason to wait.

Ready to localize your app?

Get started free — no credit card required.

Start Translating →