Fix 'No MaterialLocalizations found' in Flutter Widget Tests
Your widget tests passed for months. Then you added gen-l10n, replaced hardcoded strings with AppLocalizations.of(context), and every test that pumps a Scaffold, TextField, or dialog now dies with:
The following assertion was thrown building MyWidget:
No MaterialLocalizations found.
MyWidget widgets require MaterialLocalizations to be provided by a
Localizations widget ancestor.
Or the quieter variant: the widget builds, but AppLocalizations.of(context) returns null and your test blows up on a null check.
Both failures have the same root cause: tester.pumpWidget(MyWidget()) pumps your widget with no Localizations ancestor. In the real app, MaterialApp injects one using its localizationsDelegates. In a test, nobody does — unless you do it yourself. The usual Stack Overflow answer stops at "wrap it in MaterialApp." That fixes the crash and leaves you with English-only tests that can't catch a broken Arabic plural or a missing German key. Let's do the whole job.
Why the error appears the moment you localize
Material widgets look up three localization objects from the tree:
MaterialLocalizations— tooltips, dialog labels, date pickersWidgetsLocalizations— text direction (LTR vs RTL)CupertinoLocalizations— iOS-style widgets
Before you localized, a bare MaterialApp in your test provided English defaults (DefaultMaterialLocalizations) automatically, and some tests got away with pumping widgets with no app wrapper at all — until one widget (often TextField, Tooltip, or showDialog) asserted. Adding gen-l10n raises the bar: now your own widgets also call AppLocalizations.of(context), which is resolved through the exact same Localizations inherited widget. No delegates in the tree, no AppLocalizations.
So a test-worthy wrapper needs all four delegates:
localizationsDelegates: [
AppLocalizations.delegate, // your generated strings
GlobalMaterialLocalizations.delegate, // Material widgets
GlobalWidgetsLocalizations.delegate, // text direction
GlobalCupertinoLocalizations.delegate, // Cupertino widgets
],
Good news: gen-l10n already generates that exact list for you as AppLocalizations.localizationsDelegates, alongside AppLocalizations.supportedLocales. Use them in tests just like in main.dart.
One import note before the code: since Flutter 3.32 the synthetic package:flutter_gen import is deprecated (and removed in later releases) — localizations are generated into your source tree, by default next to your ARB files. If your tests still import package:flutter_gen/gen_l10n/app_localizations.dart, switch to the real path, e.g. package:my_app/l10n/app_localizations.dart.
A reusable pumpLocalizedWidget() helper
Instead of hand-wrapping MaterialApp in every test, add one extension to test/helpers/pump_localized_widget.dart:
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/l10n/app_localizations.dart';
extension PumpLocalized on WidgetTester {
Future<void> pumpLocalizedWidget(
Widget widget, {
Locale locale = const Locale('en'),
}) async {
await pumpWidget(
MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: widget,
),
);
// Delegates load via SynchronousFuture, so one extra frame is enough.
await pump();
}
}
Usage is one line, and the locale: parameter is the whole point — flutter test runs with an en_US platform locale by default, so without it you'd only ever test English:
testWidgets('greeting renders', (tester) async {
await tester.pumpLocalizedWidget(const GreetingCard());
expect(find.byType(GreetingCard), findsOneWidget);
});
If your widget under test needs a Scaffold (SnackBars, drawers), wrap home: Scaffold(body: widget) in the helper — same delegates, same shape.
Assert translated strings without hardcoding English
expect(find.text('Welcome'), findsOneWidget) silently couples your test to app_en.arb. Rename the English copy and the test breaks; break the Spanish copy and the test stays green. The generated app_localizations.dart ships a better tool: a top-level lookupAppLocalizations(Locale locale) function that returns the messages object for any supported locale — no widget tree required.
testWidgets('greeting shows the localized title in Spanish', (tester) async {
const locale = Locale('es');
await tester.pumpLocalizedWidget(const GreetingCard(), locale: locale);
final l10n = lookupAppLocalizations(locale);
expect(find.text(l10n.welcomeTitle), findsOneWidget);
});
Now the test asserts "the widget shows whatever the ARB file says for this locale," which is the actual contract.
The Arabic RTL case
For RTL locales, the string check alone isn't enough — you also want proof that GlobalWidgetsLocalizations flipped the text direction, because that's what drives EdgeInsetsDirectional, Row ordering, and icon mirroring:
testWidgets('greeting is RTL in Arabic', (tester) async {
const locale = Locale('ar');
await tester.pumpLocalizedWidget(const GreetingCard(), locale: locale);
final l10n = lookupAppLocalizations(locale);
expect(find.text(l10n.welcomeTitle), findsOneWidget);
final context = tester.element(find.byType(GreetingCard));
expect(Directionality.of(context), TextDirection.rtl);
});
If this test fails with TextDirection.ltr, you almost always dropped GlobalWidgetsLocalizations.delegate by hand-rolling a partial delegate list — another argument for using the generated AppLocalizations.localizationsDelegates.
Run the same test across every supported locale
testWidgets calls are just Dart, so loop over the generated locale list and every locale becomes its own named test case:
for (final locale in AppLocalizations.supportedLocales) {
testWidgets('GreetingCard renders correctly in $locale', (tester) async {
await tester.pumpLocalizedWidget(const GreetingCard(), locale: locale);
final l10n = lookupAppLocalizations(locale);
expect(find.text(l10n.welcomeTitle), findsOneWidget);
expect(tester.takeException(), isNull); // catches overflow & ICU format errors
});
}
This catches an entire class of bugs English-only tests can't: a German string that overflows a fixed-width button, a malformed ICU placeholder that only exists in one ARB file, a date format that throws for ar.
One honest caveat: missing keys fall back silently
gen-l10n falls back to your template locale for untranslated messages. Delete welcomeTitle from app_ar.arb, and lookupAppLocalizations(Locale('ar')).welcomeTitle cheerfully returns the English text — so the loop above still passes. To make missing keys fail in CI, add a completeness check that compares each locale against the template:
test('no locale silently falls back to English', () {
final en = lookupAppLocalizations(const Locale('en'));
for (final locale in AppLocalizations.supportedLocales) {
if (locale.languageCode == 'en') continue;
final l10n = lookupAppLocalizations(locale);
expect(l10n.welcomeTitle, isNot(en.welcomeTitle),
reason: 'welcomeTitle looks untranslated in $locale');
}
});
Keep an allowlist for strings that legitimately match across locales (brand names, "OK"). Pair it with untranslated-messages-file in l10n.yaml so the report shows up at codegen time too — we cover that option in our l10n.yaml configuration guide.
Quick gotcha checklist
AppLocalizations.of(context)returns null even insideMaterialApp? You forgotAppLocalizations.delegate(use the generatedlocalizationsDelegateslist), or you're reading it from acontextabove theMaterialApp. Setnullable-getter: falseinl10n.yamlto turn the null into a loud assertion.- Pumping a dialog or bottom sheet? Those routes need
MaterialLocalizationstoo — always go through the helper, neverpumpWidget(MyDialog())bare. - Test locale not applying? The locale you pass must resolve against
supportedLocales;Locale('pt', 'BR')won't match if you only supportLocale('pt')exactly — check your resolution logic. More null-and-crash scenarios are in our Flutter localization errors guide.
Tests catch rendering — your ARB files still need guarding
The per-locale loop verifies what renders, but some bugs live upstream in the ARB files themselves: an Arabic plural string missing the few/many categories renders fine in your en test run and produces wrong copy for real users. That's a gap FlutterLocalisation closes at edit time — its ARB editor validates ICU plural syntax and flags locales missing a plural category the language actually requires (Arabic, Polish, Russian are the classic offenders), and its translation management keeps every app_<locale>.arb in sync so the completeness test above stays green. There's a free tier, so the whole pipeline — editor validation plus the tests in this post — costs nothing to set up.
Try FlutterLocalisation free and stop finding missing translations in production.