Common Language Switching Bugs and How to Catch Them
Common Language Switching Bugs and How to Catch Them
Common Language Switching Bugs and How to Catch Them
Language switching is a routine operation for multilingual users, yet it remains a fertile source of defects that escape scripted test suites. When an app changes its locale at runtime, any piece of UI, data format, or behavior that assumes a single language can break in subtle ways—misaligned layouts, truncated text, wrong date formats, or even crashes. The impact ranges from mild annoyance to blocked core flows such as login or checkout, especially for users who rely on a non‑default language for accessibility or legal reasons. Detecting these issues before release requires a combination of systematic manual checks, targeted automated tests, and exploratory techniques that mimic real‑world personas.
This guide walks through the most common language‑switching bug patterns, explains why each occurs, shows what the user sees, provides reproducible steps, and offers concrete fixes and preventive measures. You’ll find‑ings are summarized in two reference tables, and a short checklist helps you embed the practices into your CI pipeline. Throughout, we illustrate how persona‑driven autonomous exploration—such as the approach used by SUSA—surfaces issues that deterministic scripts often miss.
---
1. Understanding Language Switching in Software
1.1 Locale, Language, and Region Concepts
A locale is more than a language code; it bundles language (en), script (Latn), territory (US), and sometimes variant (en_US_POSIX). Mobile platforms expose this through Configuration.locale (Android) or Locale.current (iOS), while web apps read navigator.language or the Accept‑Language header. When a user changes the system language or selects a language inside the app, the framework should reload resources, re‑format numbers/dates, and flip layout direction for right‑to‑left (RTL) scripts.
1.2 Typical Flows That Trigger a Switch
- System‑wide language change in Settings → app restart or foreground resume.
- In‑app language picker that calls
updateConfiguration(Android) orsetApplicationLanguage(iOS) without restarting the activity/view controller. - Web language toggle that rewrites
and reloads JSON i18n bundles via fetch. - Deep link or push notification that opens the app with a
?lang=frquery parameter.
Each entry point can hit a different code path, which is why bugs often appear only for certain triggers.
---
2. Bug Pattern #1: Hard‑coded UI Strings
2.1 Why It Happens
Developers sometimes embed literal text directly in layout XML, storyboards, or JSX instead of referencing a resource file. The intent may be rapid prototyping, but the string stays unchanged when the locale switches.
2.2 Symptom
Labels, button titles, or toast messages remain in the source language (often English) while the rest of the UI translates. Users notice a “mixed language” screen, which can be confusing or appear unprofessional.
2.3 Reproduction Steps
- Set device language to French (fr‑FR).
- Launch the app and navigate to a screen known to contain a hard‑coded string (e.g., a “Submit” button).
- Observe that the button still reads “Submit”.
2.4 Fix & Prevention
- Replace every literal with a reference to a string resource (
@string/submit,NSLocalizedString,t('submit')). - Add a lint rule that flags plain text inside UI files (e.g., Android
MissingTranslation, iOSSwiftLintrulecolon, ESLinti18n-text). - Run pseudo‑localization in CI to catch any remaining hard‑coded strings—they will not acquire the pseudo‑locale accents.
2.5 Code Example
Android (XML)
<!-- Before -->
<Button
android:id="@+id/btnSubmit"
android:text="Submit"
... />
<!-- After -->
<Button
android:id="@+id/btnSubmit"
android:text="@string/btn_submit"
... />
iOS (SwiftUI)
// Before
Button("Submit") { … }
// After
Button(LocalizedStringKey("btn_submit"), action: { … })
Web (React‑i18next)
// Before
<button>Submit</button>
// After
<button>{t('btn_submit')}</button>
---
3. Bug Pattern #2: Missing Fallback Resources
3.1 Why It Happens
When a locale‑specific folder (values-fr, fr.lproj, locales/fr.json) is absent, the framework falls back to the default (values, Base.lproj, locales/en.json). If the default file itself is missing a key, the app may show the raw key or an empty string.
3.2 Symptom
Users see [missing: welcome_title] or blank labels after switching to a language that lacks a translation file. In some cases the app crashes when trying to format a null string.
3.3 Reproduction Steps
- Remove the
values-frfolder from an Android project (or deletefr.jsonfrom the web bundle). - Set device language to French.
- Open the app and look for any screen that uses a string only defined in
values-en.
3.4 Fix & Prevention
- Ensure every string key exists in the base language file; treat the base as the source of truth.
- Use a script that compares keys across all locale files and fails the build on mismatches (e.g.,
i18next‑scanner,android‑lint). - In CI, run a pseudo‑locale that appends conspicuous brackets; any missing key will appear as
[??]and be caught by visual tests.
3.5 Code Example (Key‑check script)
#!/usr/bin/env bash
BASE="locales/en.json"
for f in locales/*.json; do
[[ $f == $BASE ]] && continue
missing=$(jq -r 'keys[] as $k | select(.[$k] | not) | $k' "$BASE" | \
grep -vf <(jq -r 'keys[]' "$f"))
if [[ -n $missing ]]; then
echo "Missing keys in $f:"; echo "$missing"; exit 1
fi
done
---
4. Bug Pattern #3: Incorrect Locale Propagation (Activity/ViewController Not Restarted)
4.1 Why It Happens
On Android, changing Configuration.locale does not automatically recreate the current activity unless you handle onConfigurationChanged. On iOS, updating Locale.current does not trigger a view refresh; you must observe NSLocale.currentDidChangeNotification. Forgetting to reload the UI leaves stale strings.
4.2 Symptom
After picking a new language from an in‑app settings screen, the app’s navigation bar or tab titles stay in the previous language, while newly pushed screens show the correct language.
4.3 Reproduction Steps
- Open the app in English.
- Navigate to Settings → Language → Select Spanish.
- Return to the home screen; observe that the toolbar title remains English.
4.4 Fix & Prevention
- Android: Override
onConfigurationChangedin the activity and callrecreate(); alternatively, avoid handling the change yourself and let the system restart the activity by declaringandroid:configChanges="locale"not in the manifest. - iOS: Register for
NSLocale.currentDidChangeNotificationinviewDidLoadand callview.setNeedsLayout()or reload view‑model data. - Web: After updating
, force a re‑render of the root component (Reactkeychange, Vuev-iftoggle).
4.5 Code Example (Android)
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
// Forces reload of resources with the new locale
recreate()
}
}
---
5. Bug Pattern #4: Date/Time/Formatting Mismatch After Switch
5.1 Why It Happens
Formats for dates, times, numbers, and currencies are locale‑dependent. If the app caches a SimpleDateFormat, DateFormatter, or Intl.DateTimeFormat object created before the locale change, subsequent calls use the outdated pattern.
5.2 Symptom
A user switches to Japanese and sees dates displayed as MM/dd/yyyy instead of yyyy/MM/dd, or currency shown with a dollar sign rather than ¥. In extreme cases, parsing fails and throws an exception.
5.3 Reproduction Steps
- Set device language to German (de‑DE).
- Open a screen that shows a timestamp (e.g., “Last updated: 3/5/2024”).
- Change language to Japanese (ja‑JP) without restarting the app.
- Observe the timestamp still uses the German pattern (
03.05.2024) instead of Japanese (2024/03/05).
5.4 Fix & Prevention
- Instantiate formatters lazily each time they are needed, or listen for locale change events and invalidate cached formatters.
- Prefer the platform’s built‑in formatters (
java.time.format.DateTimeFormatterwithLocale,DateFormatterwithlocale,Intl.DateTimeFormat). - Write unit tests that create a formatter, switch locale via mocking, and assert the output pattern changes.
5.5 Code Example (Kotlin)
fun formatTimestamp(millis: Long): String {
// Always use the current locale
return java.time.Instant.ofEpochMilli(millis)
.atZone(java.time.ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("MM/dd/yyyy")
.withLocale(Locale.getDefault()))
}
---
6. Bug Pattern #5: Right‑to‑Left Layout Breakage
6.1 Why It Happens
RTL languages (Arabic, Hebrew, Urdu) require mirroring of horizontal layout constraints. Hard‑coded paddings, absolute pixel values, or missing android:autoMirrored="true" on drawables cause clipped text, overlapping icons, or misaligned icons.
6.2 Symptom
When the user selects Arabic, the action‑bar icon appears on the left side instead of the right, text fields lose their hint padding, and horizontal scroll views scroll in the wrong direction.
6.3 Reproduction Steps
- Set device language to Arabic (ar‑SA).
- Open a screen containing a
Toolbarwith a navigation icon and anEditText. - Verify that the navigation icon is mirrored and the
EditTexthint starts at the right edge.
6.4 Fix & Prevention
- Use start/end attributes (
paddingStart,paddingEnd) instead of left/right. - Mark drawables as auto‑mirrored in XML (
android:autoMirrored="true"). - For iOS, rely on
UIView.semanticContentAttribute = .forceRightToLeftwhenUIApplication.shared.userInterfaceLayoutDirection == .rightToLeft. - For web, ensure CSS uses
direction: rtlon theelement and logical properties (margin-inline-start,padding-inline-end). - Add automated screenshot tests for each supported locale; tools like
flutter driveorjest-image-snapshotcan flag layout shifts.
6.5 Code Example (Android XML)
<!-- Before -->
<EditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:hint="@string/enter_email" />
<!-- After -->
<EditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:hint="@string/enter_email"
android:importantForAccessibility="yes" />
---
7. Bug Pattern #6: Input Method / Keyboard Issues
7.1 Why It Happens
Changing the system language often switches the default keyboard layout. If an app manually sets inputType or expects a specific key layout (e.g., numeric PIN pad), the new keyboard may lack those keys or produce unexpected characters.
7.2 Symptom
A user switches to a Hindi keyboard and finds that the “Done” button on the soft keyboard does not close the IME, or numeric fields accept Devanagari digits instead of ASCII numerals, causing validation failures.
7.3 Reproduction Steps
- Install a Hindi keyboard (or any non‑Latin layout).
- Set device language to Hindi.
- Focus on a
EditTextconfigured withinputType="numberDecimal". - Attempt to enter a decimal number; observe that the entered text contains Hindi numerals.
7.4 Fix & Prevention
- Use
android:digitsorInputFilterto restrict input to the allowed character set, regardless of keyboard layout. - Listen for
EditorInfo.IME_ACTION_DONEand explicitly hide the keyboard (InputMethodManager.hideSoftInputFromWindow). - On iOS, set
keyboardTypeand enforcetextField.delegateto filter characters. - On the web, use
inputmodeandpatternattributes, and sanitize oninputevent.
7.5 Code Example (Android InputFilter)
val decimalFilter = InputFilter { source, start, end, dest, dstart, dend ->
val result = StringBuilder(dest).apply { replace(dstart, dend, source.subSequence(start, end)) }
if (!result.toString().matches(Regex("\\d*\\.?\\d{0,2}"))) {
return@InputFilter "" // reject change
}
return@InputFilter null
}
editText.filters = arrayOf(decimalFilter)
---
8. Bug Pattern #7: Persistent Preference Overwrites
8.1 Why It Happens
Apps sometimes store the selected language in SharedPreferences (Android) or UserDefaults (iOS) and read it on every launch. If the code writes the preference *after* the system locale has already been applied, the next start may revert to the previously saved value, overriding the user’s system choice.
8.2 Symptom
A user changes the device language to French, launches the app, sees French UI, then exits and relaunches—only to find the UI back in English because the app forced its stored preference.
8.3 Reproduction Steps
- Set device language to French.
- Open the app; confirm UI is French.
- Close the app via recent‑apps swipe.
- Reopen the app; observe language reverts to English.
8.4 Fix & Prevention
- Treat the system locale as the source of truth; only store a language preference when the user makes an explicit in‑app choice *and* provide a clear “Use system setting” option.
- On startup, read the preference first, then fall back to
Locale.getDefault(). If the preference indicates “system”, ignore it. - Write automated tests that simulate a system language change, launch the app, background it, change the system language again, and verify the UI reflects the new system language on resume.
8.5 Code Example (Android Preference Handling)
fun getAppLocale(context: Context): Locale {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val langKey = prefs.getString("pref_language", "")
return if (langKey.isEmpty() || langKey == "system") {
Locale.getDefault()
} else {
Locale.forLanguageTag(langKey)
}
}
---
9. Bug Pattern #8: Cached Assets / CDN Localization Stale
9.1 Why It Happens
Many apps download localized strings or images from a CDN and cache them locally (e.g., using OkHttp cache or NSURLCache). When the user switches language, the app may still serve the previously cached bundle, leading to outdated translations or missing assets.
9.2 Symptom
After switching to Spanish, certain help images still show English screenshots, or a downloaded JSON file contains English keys despite the Accept-Language: es header.
9.3 Reproduction Steps
- Connect to a staging server that serves language‑specific JSON under
/i18n/{lang}.json. - Set device language to English, launch the app, and let it download and cache
en.json. - Change device language to German, force a network refresh (pull‑to‑refresh or toggle airplane mode).
- Observe that the app still displays English strings because it read the stale cache.
9.4 Fix & Prevention
- Include the locale as part of the cache key (
Cache-Control: vary: Accept-Language). - Invalidate the cache explicitly when the locale changes (
urlConnection.removeProperty("Accept-Language")orURLCache.shared.removeAllCachedResponses()). - Use versioned localization bundles (
i18n/v2/es.json) and bump the version when the source changes. - Add an integration test that mocks the server, caches a response, changes the
Accept-Languageheader, and asserts a new request is made.
9.5 Code Example (OkHttp Cache Control)
val client = OkHttpClient.Builder()
.cache(Cache(File(cacheDir, "http-cache"), 10 * 1024 * 1024L))
.addInterceptor { chain ->
val request = chain.request()
.newBuilder()
.header("Accept-Language", Locale.getDefault().language)
.build()
val response = chain.proceed(request)
// Force revalidation if locale changed
response.newBuilder()
.removeHeader("Pragma")
.header("Cache-Control", "no-cache")
.build()
}
.build()
---
10. Bug Pattern #9: Accessibility Labels Not Updated
10.1 Why It Happens
Accessibility labels (contentDescription, accessibilityLabel, aria-label) are often set once in viewDidLoad or XML and never refreshed when the locale changes. Screen‑reader users then hear outdated or missing descriptions.
10.2 Symptom
A TalkBack user switches to Hindi and hears “button” instead of the translated label “送信ボタン”.
10.3 Reproduction Steps
- Enable TalkBack (Android) or VoiceOver (iOS).
- Set device language to Hindi.
- Navigate to a screen with a button that has a hard‑coded
contentDescription. - Listen to the spoken feedback; note that it remains in English.
10.4 Fix & Prevention
- Bind accessibility labels to the same localization source as visible text (
android:contentDescription="@string/send"). - In iOS, update
accessibilityLabelinviewWillAppearor observeNSLocale.currentDidChangeNotification. - On the web, ensure
aria-labeloraria-labelledbyreferences dynamic content that updates with the locale. - Run automated accessibility scans (axe, Accessibility Scanner) for each supported locale in CI.
10.5 Code Example (iOS)
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
sendButton.accessibilityLabel = NSLocalizedString("send_button_label", comment: "")
}
---
11. Bug Pattern #10: Security / Localization Bypass
11.1 Why It Happens
Some apps accept a language override via query parameter (?lang=fr) or HTTP header and directly use it to load resources without validation. An attacker can inject unexpected values (e.g., ../../etc/passwd) leading to path traversal or loading of arbitrary files.
11.2 Symptom
A malicious user crafts a link that forces the app to load a locale file from the filesystem, potentially exposing secrets or causing a crash when the file is not a valid JSON.
11.3 Reproduction Steps
- Find an endpoint that loads
locales/{lang}.jsonbased on a user‑suppliedlangparameter. - Request
https://example.com/api/config?lang=../../etc/passwd. - Observe the app attempts to parse the passwd file, resulting in an error screen or crash.
11.4 Fix & Prevention
- Whitelist allowed language tags (BCP 47) before using them to construct file paths.
- Use a map from language tag to resource identifier rather than concatenating strings into a file system path.
- Return a 400 Bad Request for any tag not in the whitelist.
- Write security tests that attempt fuzzed language values and assert the response is an error, not a server‑side file leak.
11.5 Code Example (Java Whitelist)
private static final Set<String> ALLOWED = Set.of(
"en", "fr", "de", "es", "ja", "zh", "ar"
);
public Locale resolveLocale(String tag) {
if (tag == null || !ALLOWED.contains(tag.toLowerCase(Locale.ROOT))) {
throw new IllegalArgumentException("Unsupported language: " + tag);
}
return Locale.forLanguageTag(tag);
}
---
12. Bug Pattern #11: Cross‑platform Inconsistency (Mobile vs Web)
12.1 Why It Happens
Teams often maintain separate i18n files for native apps and the web portal. When a new feature is added, the translation may be added to Android strings.xml and iOS Localizable.strings but forgotten in the web messages.json. Conversely, the web team may adopt a different naming convention (submitBtn vs btn_submit).
12.2 Symptom
A user switches language on the web and sees the new feature’s button label in English, while the same button in the mobile app shows the correct translation.
12.3 Reproduction Steps
- Add a new string key
welcome_promowith value in all mobile locale files. - Forget to add it to the web
messages.json. - Change language to Spanish on the web portal and navigate to the promo page.
- Observe the label appears as
welcome_promo.
12.4 Fix & Prevention
- Adopt a single source of truth for translations (e.g., a centralized translation management system) and generate platform‑specific files via CI scripts.
- Enforce a naming convention via schema validation (JSON Schema for web, XSD for Android XML).
- Run a script that compares key sets across all platforms and fails the build on mismatches.
- Add end‑to‑end tests that navigate the same flow on mobile (Appium) and web (Playwright) and assert that all visible text matches the expected locale.
12.5 Code Example (Key‑set comparison script)
import json, os, glob
def load_keys(path):
with open(path, encoding='utf-8') as f:
data = json.load(f)
return set(_flatten(data.keys()))
def _flatten(keys):
for k in keys:
if isinstance(k, dict):
yield from _flatten(k.keys())
else:
yield k
web_keys = load_keys('web/locales/en.json')
android_keys = load_keys('android/app/src/main/res/values/strings.xml')
# … similarly for iOS
missing_in_web = android_keys - web_keys
if missing_in_web:
raise SystemExit(f"Web missing keys: {missing_in_web}")
---
13. Manual Testing Checklist for Language Switching
| Step | Action | Persona | Expected Result |
|---|---|---|---|
| 1 | Change system language to a non‑default locale (e.g., Japanese) | Curious novice | App restarts or foregrounds with all UI in Japanese |
| 2 | Open in‑app language picker and select a different language (e.g., Arabic) | Power user | UI updates instantly; layout mirrors correctly |
| 3 | Rotate device while in a language‑specific screen | Elderly | No loss of text; TalkBack/VoiceOver reads correct labels |
| 4 | Enter data in a form using the native keyboard for the selected language | Accessibility user | Input accepted, validation works, keyboard hides on done |
| 5 | Background the app, change system language again, resume | Impatient user | UI reflects the new language without restart |
| 6 | Navigate to a deep link containing ?lang=fr | Adventurous user | App opens and displays French UI, no errors |
| 7 | Take a screenshot after each switch and compare to baseline | QA lead | No missing strings, no overlapped layouts, no truncated text |
| 8 | Verify that dates, times, numbers, and currencies follow locale conventions | Data‑driven user | Formats match CLDR expectations for the locale |
Perform this matrix on every release candidate; any deviation flags a language‑switching defect.
---
14. Automated Detection Strategies
14.1 Unit & Integration Tests
- Locale‑switching test harness – JUnit/TestNG (Android), XCTest (iOS), Jest (web) that sets
Locale.getDefault()ornavigator.languagebefore exercising UI code. - Formatter validation – assert that
DateFormat.getDateTimeInstance().format(date)changes after a locale swap. - Resource‑key coverage – generate a list of all keys used in code (
grep -R "R\.string\." src) and ensure each appears in everyvalues-XXfolder.
14.2 UI Tests with Explicit Locale Switches
- Android (Appium) – after launching the app, execute
driver.executeScript("mobile: shell", {"command": "setprop persist.sys.language fr && stop && start"})to change language, then assert element text. - iOS (XCUITest) – use
XCUIDevice.shared.orientation = .landscapeLeftis unrelated; instead callXCUIApplication().launchArguments += ["-AppleLanguages", "(fr)"]beforelaunch(). - Web (Playwright) –
await page.context().setExtraHTTPHeaders({"Accept-Language": "fr-FR"}); await page.reload();
These tests should be part of the nightly suite; they catch regressions that unit tests miss because they exercise the full resource‑loading pipeline.
14.3 Pseudo‑localization & Visual Diff
- Run the build with a pseudo‑locale (e.g.,
en-XA) that expands strings and adds brackets. - Use tools like
android screenshot testerorStoryshotsto capture UI in pseudo‑locale; any text that does not show the pseudo markers indicates a hard‑coded string. - Integrate visual diff (
pixelmatch,Applitools) to catch layout shifts caused by RTL or length changes.
14.4 CI Gate Example (GitHub Actions)
name: i18n Checks
on: [push, pull_request]
jobs:
locale:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Pseudo-locale Android build
run: ./gradlew assemblePseudoDebug
- name: Run screenshot tests
run: ./gradlew connectedPseudoDebugAndroidTest
- name: Web i18n lint
run: npm run lint:i18n # custom script that checks key coverage
- name: Playwright language switch
run: npx playwright test --project=chromium --grep "@language-switch"
---
15. Persona‑Driven Autonomous Exploration (SUSA)
SUSA explores an application without pre‑written scripts by simulating distinct user personalities. Each persona has a configured behavior model:
| Persona | Traits | Relevance to Language Switching |
|---|---|---|
| Curious | Taps every visible element, opens menus, tries settings | Likely to discover the in‑app language picker and change locale mid‑session |
| Impatient | Performs actions quickly, often backgrounding the app | Triggers resume‑after‑language‑change scenarios that expose stale UI |
| Novice | Follows on‑boarding hints, rarely uses system settings | May rely solely on in‑app picker, highlighting bugs where picker does not propagate |
| Elderly | Uses larger fonts, enables accessibility services | Checks that accessibility labels update with locale |
| Accessibility | Activates TalkBack/VoiceOver, uses alternative input methods | Verifies that input methods and accessibility labels stay correct |
| Power user | Uses deep links, shortcuts, and rapid setting toggles | Exercises intent‑based language overrides and deep‑link language params |
| Adversarial | Attempts malformed inputs, injects unexpected values | Tests language‑parameter validation and guards against injection |
| ... | ... | ... |
During a run, SUSA records every screen visited, every input supplied, and any exception or ANR. When it detects a language change (either via system setting or in‑app toggle), it automatically:
- Captures screenshots before and after the switch.
- Compares text layers using OCR to spot missing translations.
- Checks layout direction via UI hierarchy attributes.
- Logs any crash, ANR, or accessibility warning.
Because SUSA does not rely on predetermined navigation paths, it reaches edge cases such as:
Test Your App Autonomously
Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.
Try SUSA Free