Flutter Localization Testing: How to Test Multilingual Apps (Complete Guide)
Testing localized Flutter apps is one of the most overlooked aspects of internationalization. You've set up your ARB files, configured l10n.yaml, and added translations for 10 languages — but how do you know they actually work? Missing translations, broken placeholders, truncated text, and RTL layout bugs silently ship to production when you don't test for them.
This guide covers everything: unit testing translation strings, widget testing with locale switching, golden tests across multiple languages, integration testing for RTL layouts, and CI/CD automation to catch localization bugs before they reach users.
Why Localization Testing Matters
Consider these real-world bugs that localization testing catches:
- Missing translations — A key exists in English but not in German. Users see raw keys or blank text.
- Broken placeholders —
"Welcome, {username}!"works, but"Willkommen, {benutzername}!"uses the wrong placeholder name. - Text overflow — "Submit" fits a button, but "Abschicken" (German) overflows.
- Plural rule errors — English has 2 plural forms, Arabic has 6. Broken
selectsyntax crashes the app. - RTL layout breaks — Icons, padding, and alignment that look perfect in LTR are mirrored incorrectly in Arabic or Hebrew.
- Date/number format bugs — Dates show as MM/DD in a locale expecting DD/MM.
Manual QA across every language is impractical. Automated tests catch these issues in seconds.
Setting Up the Test Environment
Step 1: Test Dependencies
# pubspec.yaml
dev_dependencies:
flutter_test:
sdk: flutter
integration_test:
sdk: flutter
Step 2: Helper Function for Localized Widget Testing
Every localized widget test needs the localization delegates. Create a shared helper:
// test/helpers/localized_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
/// Wraps a widget with MaterialApp and localization delegates for testing.
Widget buildLocalizedWidget(
Widget child, {
Locale locale = const Locale('en'),
}) {
return MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
locale: locale,
home: Scaffold(body: child),
);
}
/// Wraps with a specific locale and returns the AppLocalizations instance.
Widget buildLocalizedApp(
Widget Function(AppLocalizations l10n) builder, {
Locale locale = const Locale('en'),
}) {
return MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
locale: locale,
home: Builder(
builder: (context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(body: builder(l10n));
},
),
);
}
Unit Testing Translations
Test That All Keys Exist for All Locales
This is the single most valuable test you can write. It catches missing translations before they reach users:
// test/l10n/translation_completeness_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
void main() {
group('Translation completeness', () {
// Test every supported locale loads without error
for (final locale in AppLocalizations.supportedLocales) {
testWidgets('${locale.languageCode} locale loads successfully',
(tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: locale,
home: Builder(
builder: (context) {
// This will throw if the locale fails to load
final l10n = AppLocalizations.of(context)!;
// Access a known key to verify it resolves
expect(l10n.appTitle, isNotEmpty);
return const SizedBox();
},
),
),
);
});
}
});
}
Test Placeholder Substitution
Ensure placeholders resolve correctly — don't just test that the string isn't empty, test the actual output:
// test/l10n/placeholder_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
void main() {
group('Placeholder substitution', () {
testWidgets('welcome message includes username', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: Builder(
builder: (context) {
final l10n = AppLocalizations.of(context)!;
final result = l10n.welcomeMessage('Alice');
expect(result, contains('Alice'));
return Text(result);
},
),
),
);
});
testWidgets('welcome message works in every locale', (tester) async {
for (final locale in AppLocalizations.supportedLocales) {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: locale,
home: Builder(
builder: (context) {
final l10n = AppLocalizations.of(context)!;
// Should not throw and should contain the placeholder value
final result = l10n.welcomeMessage('TestUser');
expect(result, contains('TestUser'),
reason: '${locale.languageCode} missing placeholder');
return const SizedBox();
},
),
),
);
}
});
});
}
Test Plural Rules
Plural rules are the #1 source of localization crashes. Test every plural form:
// test/l10n/plural_test.dart
void main() {
group('Plural rules', () {
testWidgets('English plurals: zero, one, other', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: Builder(
builder: (context) {
final l10n = AppLocalizations.of(context)!;
expect(l10n.messageCount(0), contains('No messages'));
expect(l10n.messageCount(1), contains('1 message'));
expect(l10n.messageCount(5), contains('5 messages'));
expect(l10n.messageCount(100), contains('100 messages'));
return const SizedBox();
},
),
),
);
});
// Arabic has 6 plural forms: zero, one, two, few, many, other
testWidgets('Arabic plurals: all six forms', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('ar'),
home: Builder(
builder: (context) {
final l10n = AppLocalizations.of(context)!;
// These should not throw
expect(() => l10n.messageCount(0), returnsNormally);
expect(() => l10n.messageCount(1), returnsNormally);
expect(() => l10n.messageCount(2), returnsNormally); // dual
expect(() => l10n.messageCount(5), returnsNormally); // few
expect(() => l10n.messageCount(11), returnsNormally); // many
expect(() => l10n.messageCount(100), returnsNormally); // other
return const SizedBox();
},
),
),
);
});
});
}
Widget Testing with Locale Switching
Test a Widget Renders Correctly in Multiple Locales
// test/widgets/home_page_localized_test.dart
import 'package:flutter_test/flutter_test.dart';
import '../helpers/localized_widget.dart';
import 'package:your_app/pages/home_page.dart';
void main() {
group('HomePage localization', () {
testWidgets('displays English content', (tester) async {
await tester.pumpWidget(
buildLocalizedWidget(
const HomePage(),
locale: const Locale('en'),
),
);
await tester.pumpAndSettle();
expect(find.text('Welcome'), findsOneWidget);
expect(find.text('Get Started'), findsOneWidget);
});
testWidgets('displays Spanish content', (tester) async {
await tester.pumpWidget(
buildLocalizedWidget(
const HomePage(),
locale: const Locale('es'),
),
);
await tester.pumpAndSettle();
expect(find.text('Bienvenido'), findsOneWidget);
expect(find.text('Comenzar'), findsOneWidget);
});
testWidgets('displays Arabic content with RTL', (tester) async {
await tester.pumpWidget(
buildLocalizedWidget(
const HomePage(),
locale: const Locale('ar'),
),
);
await tester.pumpAndSettle();
// Verify text direction is RTL
final directionality = tester.widget<Directionality>(
find.byType(Directionality).first,
);
expect(directionality.textDirection, TextDirection.rtl);
});
});
}
Test Runtime Locale Switching
// test/widgets/locale_switcher_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
void main() {
testWidgets('locale switch updates displayed text', (tester) async {
final localeNotifier = ValueNotifier(const Locale('en'));
await tester.pumpWidget(
ValueListenableBuilder<Locale>(
valueListenable: localeNotifier,
builder: (_, locale, __) {
return MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: locale,
home: Builder(
builder: (context) {
final l10n = AppLocalizations.of(context)!;
return Column(
children: [
Text(l10n.appTitle),
ElevatedButton(
onPressed: () {
localeNotifier.value = const Locale('es');
},
child: const Text('Switch'),
),
],
);
},
),
);
},
),
);
await tester.pumpAndSettle();
// Verify English
expect(find.text('My App'), findsOneWidget);
// Switch to Spanish
await tester.tap(find.text('Switch'));
await tester.pumpAndSettle();
// Verify Spanish
expect(find.text('Mi App'), findsOneWidget);
});
}
Golden Tests for Visual Localization Verification
Golden tests capture pixel-perfect screenshots and compare them against reference images. They're invaluable for catching:
- Text overflow in longer translations
- RTL layout mirroring issues
- Font rendering differences across locales
Basic Golden Test Across Locales
// test/golden/localized_golden_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
import '../helpers/localized_widget.dart';
void main() {
group('Golden tests - localized layouts', () {
final testLocales = [
const Locale('en'),
const Locale('es'),
const Locale('de'), // German: longer words
const Locale('ja'), // Japanese: different character set
const Locale('ar'), // Arabic: RTL
];
for (final locale in testLocales) {
testWidgets('home page - ${locale.languageCode}', (tester) async {
// Set a consistent surface size for golden tests
tester.view.physicalSize = const Size(1080, 1920);
tester.view.devicePixelRatio = 1.0;
await tester.pumpWidget(
buildLocalizedWidget(
const HomePage(),
locale: locale,
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile(
'goldens/home_page_${locale.languageCode}.png',
),
);
// Reset view size
addTearDown(() => tester.view.resetPhysicalSize());
});
}
});
}
Generate and Update Golden Files
# Generate golden reference images (first time)
flutter test --update-goldens test/golden/
# Run golden tests to compare against references
flutter test test/golden/
Golden Test Tips
- Fix the screen size — Different CI machines have different defaults. Always set
tester.view.physicalSize. - Use
pumpAndSettle— Animations can cause flaky golden tests. - Separate CI goldens from local — Font rendering differs between macOS and Linux. Generate goldens on the same OS as your CI.
- Test the most overflow-prone widgets — Buttons, app bars, and table headers are where long translations break layouts.
RTL Layout Testing
RTL (right-to-left) languages like Arabic, Hebrew, Persian, and Urdu require special attention:
Test Text Direction
testWidgets('Arabic layout uses RTL direction', (tester) async {
await tester.pumpWidget(
buildLocalizedWidget(
const SettingsPage(),
locale: const Locale('ar'),
),
);
await tester.pumpAndSettle();
// Find a Row or specific widget and check its direction
final mediaQuery = tester.widget<Directionality>(
find.byType(Directionality).first,
);
expect(mediaQuery.textDirection, TextDirection.rtl);
});
Test Icon Mirroring
Some icons should mirror in RTL (back arrow) and some shouldn't (checkmark):
testWidgets('back button mirrors in RTL', (tester) async {
await tester.pumpWidget(
buildLocalizedWidget(
Scaffold(
appBar: AppBar(
leading: const BackButton(),
title: const Text('Test'),
),
),
locale: const Locale('ar'),
),
);
await tester.pumpAndSettle();
// The BackButton icon should be arrow_forward in RTL (mirrored)
final icon = tester.widget<Icon>(find.byType(Icon).first);
expect(icon.icon, Icons.arrow_forward);
});
Test Padding and Alignment
testWidgets('padding is mirrored in RTL', (tester) async {
await tester.pumpWidget(
buildLocalizedWidget(
const Padding(
// Use EdgeInsetsDirectional for RTL-aware padding
padding: EdgeInsetsDirectional.only(start: 16, end: 8),
child: Text('Test'),
),
locale: const Locale('ar'),
),
);
await tester.pumpAndSettle();
final padding = tester.widget<Padding>(find.byType(Padding));
final resolved = (padding.padding as EdgeInsetsDirectional)
.resolve(TextDirection.rtl);
// In RTL, "start" becomes "right"
expect(resolved.right, 16);
expect(resolved.left, 8);
});
Integration Testing Across Locales
For end-to-end testing of the full localized app:
// integration_test/localization_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:your_app/main.dart' as app;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Full app localization', () {
testWidgets('navigate through app in English', (tester) async {
app.main();
await tester.pumpAndSettle();
// Verify home page loads with English text
expect(find.text('Welcome'), findsOneWidget);
// Navigate to settings
await tester.tap(find.byIcon(Icons.settings));
await tester.pumpAndSettle();
// Verify settings page is localized
expect(find.text('Settings'), findsOneWidget);
expect(find.text('Language'), findsOneWidget);
});
testWidgets('switch language and verify UI updates', (tester) async {
app.main();
await tester.pumpAndSettle();
// Navigate to language settings
await tester.tap(find.byIcon(Icons.settings));
await tester.pumpAndSettle();
// Switch to Spanish
await tester.tap(find.text('Español'));
await tester.pumpAndSettle();
// Verify the entire UI updated
expect(find.text('Configuración'), findsOneWidget);
expect(find.text('Idioma'), findsOneWidget);
});
});
}
CI/CD Automation for Localization Tests
GitHub Actions Workflow
# .github/workflows/localization-tests.yml
name: Localization Tests
on:
pull_request:
paths:
- 'lib/l10n/**'
- 'l10n.yaml'
- 'test/l10n/**'
jobs:
localization-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
- name: Install dependencies
run: flutter pub get
- name: Generate localizations
run: flutter gen-l10n
- name: Check for untranslated strings
run: |
if [ -f lib/l10n/untranslated.txt ] && [ -s lib/l10n/untranslated.txt ]; then
echo "❌ Missing translations found:"
cat lib/l10n/untranslated.txt
exit 1
fi
- name: Run localization unit tests
run: flutter test test/l10n/
- name: Run widget tests
run: flutter test test/widgets/
- name: Run golden tests
run: flutter test test/golden/
Pre-Commit Hook for ARB Validation
Create a script that validates ARB files before committing:
#!/bin/bash
# .git/hooks/pre-commit (or use husky/lefthook)
echo "Validating ARB files..."
# Check that all ARB files are valid JSON
for file in lib/l10n/*.arb; do
if ! python3 -c "import json; json.load(open('$file'))" 2>/dev/null; then
echo "❌ Invalid JSON in $file"
exit 1
fi
done
# Regenerate and check for errors
flutter gen-l10n 2>&1 | tee /tmp/gen_l10n_output.txt
if grep -q "Error" /tmp/gen_l10n_output.txt; then
echo "❌ gen-l10n produced errors"
exit 1
fi
echo "✅ ARB files valid"
Automated Translation Coverage Report
Track how complete your translations are across all locales:
// test/l10n/coverage_report_test.dart
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('Translation coverage report', () {
final arbDir = Directory('lib/l10n');
final arbFiles = arbDir.listSync().whereType<File>().where(
(f) => f.path.endsWith('.arb'),
);
// Read template file
final templateFile = arbFiles.firstWhere(
(f) => f.path.contains('app_en.arb'),
);
final template =
jsonDecode(templateFile.readAsStringSync()) as Map<String, dynamic>;
final templateKeys = template.keys
.where((k) => !k.startsWith('@') && !k.startsWith('@@'))
.toSet();
print('Template (en): ${templateKeys.length} keys\n');
print('${'Locale'.padRight(10)} ${'Keys'.padRight(8)} ${'Missing'.padRight(10)} Coverage');
print('-' * 45);
for (final file in arbFiles) {
final content =
jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
final locale = content['@@locale'] as String? ?? 'unknown';
final keys = content.keys
.where((k) => !k.startsWith('@') && !k.startsWith('@@'))
.toSet();
final missing = templateKeys.difference(keys);
final coverage =
((keys.length / templateKeys.length) * 100).toStringAsFixed(1);
print(
'${locale.padRight(10)} '
'${keys.length.toString().padRight(8)} '
'${missing.length.toString().padRight(10)} '
'$coverage%',
);
if (missing.isNotEmpty) {
for (final key in missing.take(5)) {
print(' ⚠️ Missing: $key');
}
if (missing.length > 5) {
print(' ... and ${missing.length - 5} more');
}
}
}
});
}
Running this test produces a report like:
Template (en): 150 keys
Locale Keys Missing Coverage
---------------------------------------------
en 150 0 100.0%
es 148 2 98.7%
⚠️ Missing: newFeatureTitle
⚠️ Missing: newFeatureDescription
de 145 5 96.7%
⚠️ Missing: newFeatureTitle
⚠️ Missing: newFeatureDescription
⚠️ Missing: settingsExport
⚠️ Missing: settingsImport
⚠️ Missing: onboardingStep3
ar 142 8 94.7%
⚠️ Missing: newFeatureTitle
⚠️ Missing: newFeatureDescription
... and 6 more
Testing Checklist
Use this checklist for every localized Flutter app:
- All locales load without errors
- Placeholders resolve with actual values (not raw
{variable}text) - Plural forms work for every locale's rules (especially Arabic, Russian, Polish)
- RTL layouts render correctly for Arabic/Hebrew
- Text doesn't overflow in German, Finnish, or other long-word languages
- Date/number formats match locale expectations
- Locale switching updates the entire UI tree
- Golden tests pass for critical screens in key locales
- CI pipeline validates ARB completeness on every PR
- Translation coverage is above your team's threshold (e.g., 95%)
Common Testing Pitfalls
Pitfall 1: Testing Only English
// ❌ Bad: Only tests default locale
testWidgets('shows welcome text', (tester) async {
await tester.pumpWidget(const MyApp());
expect(find.text('Welcome'), findsOneWidget);
});
// ✅ Good: Tests multiple locales
for (final locale in [const Locale('en'), const Locale('de'), const Locale('ar')]) {
testWidgets('shows welcome text in ${locale.languageCode}', (tester) async {
await tester.pumpWidget(buildLocalizedWidget(const HomePage(), locale: locale));
await tester.pumpAndSettle();
// Verify a text widget exists (content varies by locale)
expect(find.byType(Text), findsWidgets);
});
}
Pitfall 2: Hardcoding Expected Translations
// ❌ Bad: Hardcoded translation (breaks if translation changes)
expect(find.text('Bienvenido'), findsOneWidget);
// ✅ Good: Use the l10n object to get expected text
final l10n = AppLocalizations.of(tester.element(find.byType(HomePage)))!;
expect(find.text(l10n.welcomeTitle), findsOneWidget);
Pitfall 3: Not Testing Edge Cases
// ✅ Test with empty strings, very long strings, special characters
testWidgets('handles edge case inputs', (tester) async {
// ... setup ...
final l10n = AppLocalizations.of(context)!;
// Test with special characters
expect(() => l10n.welcomeMessage("O'Brien"), returnsNormally);
expect(() => l10n.welcomeMessage('José María'), returnsNormally);
expect(() => l10n.welcomeMessage('用户'), returnsNormally);
expect(() => l10n.welcomeMessage(''), returnsNormally);
});
Conclusion
Localization testing isn't optional — it's the difference between a polished multilingual app and one that embarrasses your brand in every market you launch in. Start with the translation completeness test (catches 80% of bugs), add widget tests for critical screens, and use golden tests for visual verification.
The investment pays off immediately: every missing translation, broken placeholder, and RTL bug you catch in CI is one fewer bug report from your users.
Tired of manually managing ARB files across languages? FlutterLocalisation keeps your translations complete, consistent, and in sync across every locale. Visual editing, AI-powered translations, and automatic validation — so your tests pass the first time. Try it free.