GetTranslated.AI
Mobile App Development

5 Localization Bugs Pseudo-Localization Misses (And the Targeted Tests That Catch Them)

5 Localization Bugs Pseudo-Localization Misses (And the Targeted Tests That Catch Them)

Pseudo-localization is a solid first line of defense — it catches truncated strings, broken layouts, and hardcoded text before a single translator touches your project. The problem is that most teams treat it as the finish line when it's really just the starting gun. The bugs that actually make it to production aren't structural; they're semantic, logical, and locale-specific, and pseudo-localization wasn't designed to find them.

This post is the natural second step after you've run pseudo-localization for the first time. If you're setting up your baseline localization testing process from scratch, Localization Testing 101 covers the foundational layer. What follows here are the five bug categories that reliably slip past pseudo-localization and the specific, targeted tests you can write to catch them before they reach your users.


Bug #1: Plural Forms That Collapse to One String

Pseudo-localization tests your strings as strings. It wraps them in brackets, adds padding, and reverses text — but it does all of this against a single form of each plural rule. In English, that means you're testing the other form and exactly nothing else.

This is fine until you ship to Russian, Polish, Arabic, or any other language where the plural rules are genuinely complex. Russian, for instance, has four plural categories: one, few, many, and other. Arabic has six. Polish has its own idiosyncratic rules where 2–4 take the few form, but 12–14 take many. If your plural string definitions have bugs — missing categories, mismatched format specifiers, or wrong translations in a specific form — pseudo-localization won't surface any of them.

The real failure: A common pattern is an app that ships Russian support with only one and other forms defined. For quantities like 3, 4, or 21, Android's resource system silently falls back, often to the wrong form, producing grammatically broken strings that your QA team (testing in English) never saw.

The targeted test: Write locale-parameterized tests that inject real locale codes and a set of boundary quantity values that probe every plural category. A good standard set is: 0, 1, 2, 5, 11, 21. Those seven values exercise every meaningful boundary in the CLDR plural rules for most languages.

Android (Kotlin):

@RunWith(Parameterized::class)
class PluralFormTest(private val quantity: Int, private val expected: String) {

    companion object {
        @JvmStatic
        @Parameterized.Parameters(name = "quantity={0}")
        fun data() = listOf(
            arrayOf(0,  "0 элементов"),
            arrayOf(1,  "1 элемент"),
            arrayOf(2,  "2 элемента"),
            arrayOf(5,  "5 элементов"),
            arrayOf(11, "11 элементов"),
            arrayOf(21, "21 элемент")
        )
    }

    @Test
    fun pluralFormMatchesExpected() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        val config = Configuration(context.resources.configuration).apply {
            setLocale(Locale("ru"))
        }
        val localizedContext = context.createConfigurationContext(config)
        val result = localizedContext.resources.getQuantityString(
            R.plurals.item_count, quantity, quantity
        )
        assertEquals(expected, result)
    }
}

iOS (Swift):

func testRussianPluralForms() {
    let bundle = Bundle.main
    let quantities = [0, 1, 2, 5, 11, 21]

    for qty in quantities {
        let result = String.localizedStringWithFormat(
            NSLocalizedString("item_count", bundle: bundle, comment: ""),
            qty
        )
        // Assert result is non-empty and contains the quantity
        XCTAssertTrue(result.contains("\(qty)"), "Plural form broken for quantity \(qty)")
    }
}

React Native: Use intl-messageformat or your i18n library's plural resolver directly in Jest, bypassing the UI layer entirely. Test the message compilation with each quantity value against each locale's plural rules.

Also worth reviewing: 6 Ways Claude and ChatGPT Silently Corrupt Your Stringsdict and Android Plurals covers how AI-generated translations introduce plural form bugs that are especially hard to spot in review.


Bug #2: Placeholder Type Mismatches Across Languages

Pseudo-localization wraps your string values in noise characters — it does not reorder or retype your format specifiers. So if your source string is "Welcome, %1$s! You have %2$d messages", pseudo-localization produces something like "[!!! Ŵéłćöḿé, %1$s! Ŷöü ḧàvé %2$d ḿéssàĝés !!!]" — which formats correctly because the placeholders are unchanged.

The real-world translation, however, might flip those arguments. In German or Japanese, the natural word order in a translated sentence often demands that %2$d precede %1$s. A translator or an AI translation pipeline that doesn't handle positional specifiers correctly will produce a string like "Willkommen, %2$d! Sie haben %1$s Nachrichten" — which at runtime either crashes or produces visibly corrupted output.

The real failure: A translator drops positional specifiers and uses %s / %d positionally instead of %1$s / %2$d. Now the argument order in your String.format() call maps to the wrong types, and you get either a runtime exception or — worse — a silent type coercion that outputs nonsense.

The targeted test: Write a build-time or CI validation script that parses every translated file, extracts the placeholder signatures from each string, and compares them against the source locale. The comparison should check: same number of placeholders, same type specifiers, and that positional indices (if present) are a complete set.

import re
import json
import sys

PLACEHOLDER_PATTERN = re.compile(r'%(\d+\$)?[sd@df]')

def extract_signature(value: str) -> list[str]:
    return sorted(PLACEHOLDER_PATTERN.findall(value))

def validate_placeholders(source_path: str, target_path: str) -> list[str]:
    errors = []
    with open(source_path) as f:
        source = json.load(f)
    with open(target_path) as f:
        target = json.load(f)

    for key, source_value in source.items():
        if key not in target:
            continue
        source_sig = extract_signature(source_value)
        target_sig = extract_signature(target[key])
        if source_sig != target_sig:
            errors.append(
                f"Key '{key}': source placeholders {source_sig} "
                f"don't match target {target_sig}"
            )
    return errors

if __name__ == "__main__":
    errors = validate_placeholders(sys.argv[1], sys.argv[2])
    for e in errors:
        print(e)
    sys.exit(1 if errors else 0)

This script belongs in your CI pipeline, not your release checklist. It should fail the build. If you're using AI translation, placeholder corruption is one of the more common failure modes — /solutions/safe-ai-translation/ describes how a proper pipeline isolates and re-injects placeholders rather than asking a language model to handle them faithfully.


Bug #3: Date, Number, and Currency Rendering in Edge Locales

Pseudo-localization has no awareness of your formatter objects. NSDateFormatter, DateTimeFormatter, NumberFormat, Intl.NumberFormat — all of these produce their output at runtime from locale data, and pseudo-localization never touches them. You can run a full pseudo-localization pass and have zero signal about whether your app renders 1,234.56 as ١٬٢٣٤٫٥٦ in ar-EG or whether your Swiss German UI shows 1.234,56 or 1'234.56.

The real failure: ar-EG uses Eastern Arabic-Indic numerals (٠١٢٣٤٥٦٧٨٩), not Western Arabic numerals. If your layout allocates space based on the character width of Western digits — or if a custom view does manual digit rendering — it breaks silently with the correct locale configured. Separately, de-CH uses . as the thousands separator and , as the decimal separator, which is the opposite of what de-DE uses. Apps that test only against de fail both.

The targeted test: Write locale-parameterized unit tests that instantiate every formatter in your app with a fixed input value and assert against the expected output string. Eight to ten locale codes covers most edge cases: en-US, de-DE, de-CH, fr-FR, ar-EG, ar-SA, ja-JP, hi-IN, pt-BR, zh-Hans.

iOS (XCTest — parameterized via subTests or a loop):

struct FormatterTestCase {
    let locale: Locale
    let input: Double
    let expectedCurrency: String
}

let cases: [FormatterTestCase] = [
    FormatterTestCase(locale: Locale(identifier: "en-US"), input: 1234.56, expectedCurrency: "$1,234.56"),
    FormatterTestCase(locale: Locale(identifier: "de-DE"), input: 1234.56, expectedCurrency: "1.234,56 €"),
    FormatterTestCase(locale: Locale(identifier: "de-CH"), input: 1234.56, expectedCurrency: "CHF 1'234.56"),
    FormatterTestCase(locale: Locale(identifier: "ar-EG"), input: 1234.56, expectedCurrency: "١٬٢٣٤٫٥٦ ج.م.‏"),
]

func testCurrencyFormatting() {
    let formatter = NumberFormatter()
    formatter.numberStyle = .currency

    for tc in cases {
        formatter.locale = tc.locale
        let result = formatter.string(from: NSNumber(value: tc.input)) ?? ""
        XCTAssertEqual(result, tc.expectedCurrency, "Currency formatting failed for \(tc.locale.identifier)")
    }
}

Android (JUnit Parameterized):

@RunWith(Parameterized::class)
class CurrencyFormatTest(
    private val localeTag: String,
    private val amount: Double,
    private val expected: String
) {
    companion object {
        @JvmStatic
        @Parameterized.Parameters(name = "{0}")
        fun data() = listOf(
            arrayOf("en-US", 1234.56, "$1,234.56"),
            arrayOf("de-DE", 1234.56, "1.234,56 €"),
            arrayOf("ar-EG", 1234.56, "١٬٢٣٤٫٥٦ ج.م.‏")
        )
    }

    @Test
    fun currencyFormatsCorrectly() {
        val locale = Locale.forLanguageTag(localeTag)
        val formatter = NumberFormat.getCurrencyInstance(locale)
        assertEquals(expected, formatter.format(amount))
    }
}

For a thorough treatment of cross-platform formatter behavior, Date, Number, and Currency Formatting Across iOS, Android, and React Native is worth reading before you write your test matrix.


Bug #4: RTL Layout Breaks That Only Appear With Real Text

The pseudo-localization RTL locale (ar-XB) is useful precisely because it mirrors your string content using Latin characters with bidi overrides. That means layout direction flips are exercised without requiring real translations. What it does not test is how your layouts handle actual Arabic or Hebrew text — with real ligatures, zero-width joiners, bidirectional mark characters, and mixed-direction content.

The real failure: A custom TextView subclass that measures its own text width for an animated underline effect works fine with ar-XB (which is still ASCII under the hood) but breaks with real Arabic because the glyph-cluster boundaries don't align with character offsets the way they do in Latin text. Flex layouts with mixed LTR content (a price in a RTL product card, for example) can also behave differently when the strings contain actual RTL characters rather than bidi-overridden Latin ones.

The targeted test: Screenshot tests using real ar and he locale strings, running on an emulator or device with those locales actually set. UI tests are not sufficient here — you need pixel-level comparison or at minimum view-hierarchy assertions.

Key assertions to automate:

  • Text alignment: TextView / UILabel gravity should be END for RTL content; assert textAlignment is not hardcoded to LEFT or .left
  • Icon mirroring: back arrows, forward chevrons, and directional icons should be mirrored; assert the autoMirrored attribute on Android, and use UIImage.imageFlippedForRightToLeftLayoutDirection() on iOS
  • Scroll direction: RecyclerView / UICollectionView horizontal scroll should start from the right; assert layoutManager.reverseLayout on Android
  • Flex layout start/end: Any component using flexDirection: 'row' in React Native needs to validate that start maps to the right side under RTL
// iOS: Assert label is not hardcoding left alignment
func testProductTitleAlignmentInArabic() {
    let label = ProductTitleView().titleLabel
    XCTAssertNotEqual(label.textAlignment, .left,
        "Title label hardcodes .left — use .natural or .leading for RTL support")
}

For a comprehensive checklist of what to validate before shipping Arabic or Hebrew support, The RTL Localization Pre-Release Checklist covers 27 specific items that go well beyond what screenshot testing alone catches.


Bug #5: Locale Fallback Chain Failures

Pseudo-localization tests exactly one locale. It does not exercise the fallback logic that kicks in when a user's device locale doesn't match any of your shipping locales exactly. This is a surprisingly deep source of production bugs, and it's one of the least-tested code paths in most localization setups.

The real failure: A user sets their device to pt-BR. Your app ships both pt and pt-BR resources. But a configuration error in your resource structure — or an incorrect assumption about how the OS resolves locale chains — causes pt-BR strings to fall back to pt (European Portuguese) instead of the regional variant. The user sees "Ficheiros" instead of "Arquivos". Less obviously: zh-Hant-HK may fall back to zh-Hans (Simplified Chinese) on Android if your resource qualifiers aren't structured correctly, which is a significant content error, not just a style difference.

The targeted test: Explicitly test your three-tier fallback chain — regional (pt-BR) → language (pt) → base (en) — for every locale you ship. The test should verify which resource file is actually serving each string for a given device locale setting.

iOS:

func testPortugueseBrazilDoesNotFallBackToEuropean() {
    let bundle = Bundle.main
    let ptBRLocale = Locale(identifier: "pt-BR")

    // Simulate locale resolution
    guard let path = bundle.path(forResource: "pt-BR", ofType: "lproj"),
          let localizedBundle = Bundle(path: path) else {
        XCTFail("pt-BR lproj not found — fallback will occur")
        return
    }

    let result = localizedBundle.localizedString(forKey: "files_label", value: nil, table: nil)
    XCTAssertEqual(result, "Arquivos", "pt-BR is falling back to pt (EU) — got '\(result)'")
}

Android:

@Test
fun testTraditionalChineseHKDoesNotFallbackToSimplified() {
    val config = Configuration()
    config.setLocale(Locale.forLanguageTag("zh-Hant-HK"))
    val context = ApplicationProvider.getApplicationContext<Context>()
        .createConfigurationContext(config)

    val result = context.getString(R.string.files_label)
    // Simplified Chinese would return "文件"; Traditional should return "檔案"
    assertNotEquals("文件", result)
    assertEquals("檔案", result)
}

React Native: If you're using a library like i18next, the fallback chain is configured in your i18n.js setup. Write a Jest test that initializes the i18n instance with a specific locale, calls t() for a key that only exists in the regional variant, and asserts the expected value. Then deliberately remove the regional resource and assert that the fallback is the correct language root — not en.


Building a Targeted Localization Test Suite

These five test categories work best when they're layered on top of pseudo-localization, not used as replacements for it. Pseudo-localization catches structural issues cheaply and fast. Targeted tests catch semantic and logic bugs that require real locale data.

Recommended CI split:

Test Type Run on Every PR Run Pre-Release
Pseudo-localization (structural) ✓ ✓
Plural form boundary tests ✓ ✓
Placeholder signature validation ✓ ✓
Locale-parameterized formatter tests ✓ ✓
RTL screenshot tests (real locale) — ✓
Fallback chain integration tests — ✓

Plural and placeholder tests are fast and deterministic — they belong on every PR. Formatter tests add maybe 10–15 seconds to a test suite and are worth it on every build. Screenshot tests and fallback integration tests are slower, require emulators or devices, and are better suited to a pre-release gate.

Test matrix for pre-release:

Aim to cover at minimum: en-US, de-DE, fr-FR, ja-JP, ar-SA, he-IL, pt-BR, zh-Hant-TW, ru-RU. Cross that with your two primary form factors (phone + tablet) and the minimum supported OS version. That's roughly 18 configurations — manageable in a nightly CI run, and the surface area where most production locale bugs hide.

You can also enforce many of these checks statically at build time without running device tests at all. Build-Time Locale Validation: 8 Checks Your CI Pipeline Should Run Before Every Release covers the build-time layer in detail, and it composes cleanly with the runtime test suite described here.


Conclusion

Pseudo-localization is a necessary first pass. It's not a complete strategy. The bugs it misses are exactly the ones that are hard to explain to a product manager: "Yes, the app is translated. No, it doesn't work for Russian speakers choosing between 3 or 4 items."

Here's the five-item checklist this post adds to your testing suite:

  1. Plural boundary tests — inject real locale + {0, 1, 2, 5, 11, 21} quantity values against every plural string
  2. Placeholder signature validation — script that compares format specifier types and positions across source and all translated files
  3. Locale-parameterized formatter tests — cycle 8–10 locale codes through every date, number, and currency formatter in the app
  4. RTL screenshot tests with real locale strings — not ar-XB; actual ar and he content on real or emulated devices
  5. Fallback chain assertions — explicitly test regional → language → base resolution for every locale you ship

The next step after locking down this test suite is moving validation earlier in the pipeline — specifically, catching translation errors before they enter your resource files at all. We'll cover that in the next post in this series, including how to validate AI-generated translations for format correctness and plural completeness before a single string reaches your codebase. If you're already thinking about that layer, 5 Things That Must Happen Between Your LLM Output and Your Release Branch is a useful preview of what that validation looks like in practice.

Ready to localize your app?

Get started free — no credit card required.

Start Translating →