Fix Null AppLocalizations in Flutter Widget Tests
You added gen-l10n, the app runs fine in three languages, and then flutter test turns red:
_CastError: Null check operator used on a null value
package:my_app/features/home/home_screen.dart:42
Line 42 is AppLocalizations.of(context)!.welcomeTitle. Nothing about your localization is broken. The test is the problem: tester.pumpWidget(const HomeScreen()) builds your widget with no Localizations ancestor carrying your delegate, so AppLocalizations.of(context) returns null and the ! blows up.
The sibling symptom is quieter and worse: the test passes, but every locale renders English, because you wired the delegates and never told the test which locale to use.
Both are fixed by one small helper. Here's the helper, plus the three edge cases that still bite after you have it.
Why AppLocalizations.of(context) is null in a test
AppLocalizations.of(context) is a thin wrapper over Localizations.of<AppLocalizations>(context, AppLocalizations). That walks up the tree looking for a Localizations widget whose delegates produced an AppLocalizations instance. In your app, MaterialApp inserts that Localizations widget for you, using the localizationsDelegates and supportedLocales you passed it.
In a widget test, if you pump a bare widget — or a MaterialApp without localizationsDelegates — there is no delegate of type AppLocalizations, the lookup returns null, and the generated of() signature is nullable (AppLocalizations?) precisely so it can return null. The ! in your production code is what converts that into a crash.
So the fix is not "make localization work in tests." It's "give the test the same Localizations scope your app has."
One pumpLocalized helper for every localized widget test
Put this in test/helpers/pump_localized.dart and stop thinking about it:
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/l10n/app_localizations.dart';
/// Pumps [child] inside a MaterialApp configured exactly like the real app,
/// pinned to [locale].
Future<void> pumpLocalized(
WidgetTester tester,
Locale locale,
Widget child, {
Size surfaceSize = const Size(400, 800),
}) async {
await tester.binding.setSurfaceSize(surfaceSize);
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: child,
),
);
// Second frame: needed if any delegate loads asynchronously.
await tester.pump();
}
Two details worth being precise about.
The generated statics. Modern gen-l10n emits AppLocalizations.localizationsDelegates (its own delegate plus GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, and GlobalWidgetsLocalizations.delegate) and AppLocalizations.supportedLocales. Using them means your test can never drift from your app's real configuration. If your generated file predates those statics, spell the list out yourself — and keep GlobalWidgetsLocalizations.delegate in it, for reasons covered below.
The import path. Since the Flutter 3.32 line, localized messages are generated into your source tree rather than a synthetic package:flutter_gen. With the conventional arb-dir: lib/l10n, the import is package:my_app/l10n/app_localizations.dart, not package:flutter_gen/gen_l10n/app_localizations.dart.
About that extra pump()
This is where a lot of blog advice is vague, so: the delegate gen-l10n generates returns a SynchronousFuture, and Flutter's Localizations widget special-cases that — if every delegate completes synchronously, resources are available on the very first frame and you don't strictly need a second pump.
The extra pump() matters when any delegate is genuinely async: use-deferred-loading: true (web), a custom delegate that reads JSON from assets, or a delegate that hits a network stub. In that case Localizations returns const SizedBox.shrink() from build() until loading completes. Your tree is empty, find.text(...) finds nothing, and the failure reads like "the widget didn't render" rather than "localization is loading." The extra pump costs nothing and removes an entire class of confusing failures. Use await tester.pumpAndSettle() instead if the widget also has animations.
Asserting real translated strings, per locale
Now the actual test. Drive it from a table so adding a locale is one line:
void main() {
const cases = <Locale, String>{
Locale('en'): 'Welcome back',
Locale('fr'): 'Bon retour',
Locale('ar'): 'مرحباً بعودتك',
};
cases.forEach((locale, expected) {
testWidgets('renders the greeting in $locale', (tester) async {
await pumpLocalized(tester, locale, const HomeScreen());
expect(find.text(expected), findsOneWidget);
});
});
}
Hardcode the expected strings. It feels redundant, but asserting against AppLocalizations.of(context)!.welcomeTitle compares the ARB file to itself — a tautology that passes even when a translation is missing and falls back to English.
The exception is placeholders and plurals, where you want to check formatting rather than wording. There, grab the instance from the tree:
testWidgets('formats the item count for fr', (tester) async {
late AppLocalizations l10n;
await pumpLocalized(
tester,
const Locale('fr'),
Builder(builder: (context) {
l10n = AppLocalizations.of(context)!;
return const CartBadge(count: 2);
}),
);
expect(l10n.itemCount(2), '2 articles');
expect(find.text('2 articles'), findsOneWidget);
});
When you can't pass locale: — use localeTestValue
Some widgets under test resolve the locale themselves (a settings screen that reads the system locale, or a localeResolutionCallback you actually want to exercise). Passing MaterialApp.locale short-circuits that logic, so instead override what the platform reports:
testWidgets('follows the system locale', (tester) async {
tester.platformDispatcher.localeTestValue = const Locale('he');
tester.platformDispatcher.localesTestValue = const [Locale('he')];
addTearDown(tester.platformDispatcher.clearLocaleTestValue);
await pumpLocalized(tester, /* no explicit locale */ const Locale('he'), const HomeScreen());
});
Note tester.platformDispatcher, not tester.binding.window. The TestWindow versions of localeTestValue and clearLocaleTestValue were deprecated and removed after Flutter 3.13 as part of the multi-window work; they live on TestPlatformDispatcher now. Setting localesTestValue alongside the singular value matters because locale resolution reads the list. And always clear it in a tear-down — a leaked locale override will make a later, unrelated test fail mysteriously.
RTL: why your Arabic test renders left-to-right
Here's the trap that silently invalidates ar and he runs. Text direction does not come from the Locale object. It comes from WidgetsLocalizations.textDirection, supplied by a delegate.
GlobalWidgetsLocalizations maps locales to direction, returning TextDirection.rtl for exactly six language codes: ar, fa, he, ps, sd, and ur. If that delegate is missing from your test's localizationsDelegates, Flutter falls back to DefaultWidgetsLocalizations, which is unconditionally LTR. Your Arabic strings render — laid out left-to-right. Padding, alignment, and chevrons all point the wrong way, and the test happily passes.
So assert direction explicitly on RTL runs:
testWidgets('lays out RTL in Arabic', (tester) async {
await pumpLocalized(tester, const Locale('ar'), const HomeScreen());
final direction = Directionality.of(tester.element(find.byType(HomeScreen)));
expect(direction, TextDirection.rtl);
});
If you deliberately test a single widget without a MaterialApp, you must wrap it in a Directionality yourself or it throws a different error entirely — but prefer pumpLocalized, which gets this right for free.
Golden tests across multiple locales
The helper composes straight into goldens:
for (final locale in const [Locale('en'), Locale('fr'), Locale('ar')]) {
testWidgets('golden: home @ ${locale.languageCode}', (tester) async {
await pumpLocalized(tester, locale, const HomeScreen());
await expectLater(
find.byType(HomeScreen),
matchesGoldenFile('goldens/home_${locale.languageCode}.png'),
);
});
}
Run flutter test --update-goldens once to record baselines. One caveat: flutter test renders with a built-in test font that has no Arabic or Hebrew glyphs, so RTL goldens come out as boxes unless you load a real font with FontLoader in setUpAll (or use a golden package that does it for you). Boxes still catch layout regressions — mirrored padding, clipped columns from longer German strings — which is most of the value. They just won't verify glyph shaping.
Why initState still crashes in a correct test
Last one, because it survives every fix above. This crashes even with a perfect pumpLocalized:
@override
void initState() {
super.initState();
_title = AppLocalizations.of(context)!.welcomeTitle; // ✗
}
Localizations.of calls context.dependOnInheritedWidgetOfExactType, and Flutter asserts if you do that before initState() completes — you'd be registering a dependency on a widget whose changes you can't yet respond to. Move it down one lifecycle step:
@override
void didChangeDependencies() {
super.didChangeDependencies();
_title = AppLocalizations.of(context)!.welcomeTitle; // ✓
}
This is the right fix for the app too, not just the test: didChangeDependencies re-runs when the user switches language, so your cached string updates. The initState version would stay frozen in the old locale.
Green tests still can't tell you a translation is missing
A subtle limitation: your fr test passes when app_fr.arb is complete and when a key silently falls back to English — because find.text('Welcome back') will match either way. Tests verify wiring; they can't verify that a human actually translated the key, or that Arabic and Polish carry every ICU plural category the language requires.
That's the layer FlutterLocalisation covers. The ARB editor gives you a side-by-side view of app_<locale>.arb files so missing and untranslated keys are visible instead of inferred, and ICU plural validation flags a locale that's missing a category its language actually needs — the dropped few/many in Arabic, Polish, or Russian that no widget test will ever catch.
Try FlutterLocalisation free — keep the ARB files honest, and let pumpLocalized handle the rest.