Flutter Widget Tests: Fix 'No MaterialLocalizations found'
You ship a gen-l10n app, you write your first widget test for a localized screen, and pumpWidget throws one of two things:
No MaterialLocalizations found.
SettingsScreen widgets require MaterialLocalizations to be provided by a Localizations widget ancestor.
or, one line later in your own code:
Null check operator used on a null value
AppLocalizations.of(context)!.settingsTitle
Both have the same cause. Your widget was pumped naked, with no MaterialApp above it, so there is no Localizations widget in the tree and nothing to answer Localizations.of<T> lookups. This post gives you a copy-paste pumpLocalized() helper that fixes both, then a parameterised test that walks every supported locale and asserts the translated text, including plural forms, so a missing string fails on your machine before it fails in CI.
Why the test fails when the app does not
In production, MaterialApp builds a Localizations widget from localizationsDelegates and supportedLocales. Material widgets such as Scaffold, TextField, BackButton and Tooltip call MaterialLocalizations.of(context) for their accessibility labels and assert when it is missing. The Flutter docs state it plainly: a TextField without a MaterialLocalizations ancestor hits an assertion, and the fix is a MaterialApp or an explicit Localizations widget.
Your generated AppLocalizations works the same way. With gen-l10n's default nullable-getter: true, AppLocalizations.of(context) returns AppLocalizations?, and it is null whenever the delegate was never registered. In a test, tester.pumpWidget(SettingsScreen()) registers nothing, so the ! in your widget code blows up.
The fix is to pump the widget inside the same shell the app uses. The mistake people make is copying MaterialApp(home: widget) without the delegates, which silences the Material error but still leaves AppLocalizations.of(context) null.
The pumpLocalized() helper
Put this in test/helpers/pump_localized.dart. Adjust the import to match your l10n.yaml. If you set synthetic-package: false (the only supported mode since Flutter 3.32 dropped the synthetic package:flutter_gen), import from your output-dir, typically lib/l10n/app_localizations.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 {
/// Pumps [child] inside a MaterialApp wired exactly like the real app,
/// forced to [locale]. Defaults to the first supported locale.
Future<void> pumpLocalized(
Widget child, {
Locale? locale,
ThemeData? theme,
}) async {
await pumpWidget(
MaterialApp(
locale: locale ?? AppLocalizations.supportedLocales.first,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
theme: theme,
home: child,
),
);
// Delegates load synchronously for gen-l10n, but settle anyway so the
// helper also works with async custom delegates.
await pumpAndSettle();
}
}
Two details matter here.
AppLocalizations.localizationsDelegates is a static list the generator emits for you. It already contains AppLocalizations.delegate, GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate and GlobalWidgetsLocalizations.delegate. Using it instead of a hand-written list means your test can never drift from the app.
locale: forces the locale regardless of the test host's system language. Without it, MaterialApp resolves against the test binding's default locale (en_US), and your German assertions pass or fail depending on nothing you control.
Using it looks like this:
testWidgets('settings title renders in German', (tester) async {
await tester.pumpLocalized(
const SettingsScreen(),
locale: const Locale('de'),
);
expect(find.text('Einstellungen'), findsOneWidget);
});
No No MaterialLocalizations found, no null AppLocalizations.of(context), and the test reads like the behaviour it checks.
Looping every supported locale
Hard-coding 'Einstellungen' is fine for one screen. For a smoke test that proves every locale actually has every string, compare the rendered text against the generated class itself. gen-l10n emits a top-level lookupAppLocalizations(Locale) function that returns the right subclass synchronously, so you can compute the expected value for each locale without a BuildContext.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/l10n/app_localizations.dart';
import 'package:my_app/screens/settings_screen.dart';
import 'helpers/pump_localized.dart';
void main() {
for (final locale in AppLocalizations.supportedLocales) {
group('SettingsScreen [$locale]', () {
final l10n = lookupAppLocalizations(locale);
testWidgets('renders translated title and labels', (tester) async {
await tester.pumpLocalized(const SettingsScreen(), locale: locale);
expect(find.text(l10n.settingsTitle), findsOneWidget);
expect(find.text(l10n.notificationsLabel), findsOneWidget);
expect(find.text(l10n.signOut), findsOneWidget);
});
testWidgets('plural item count renders for 0, 1 and many', (tester) async {
for (final count in [0, 1, 2, 5, 21]) {
await tester.pumpLocalized(
ItemCountBadge(count: count),
locale: locale,
);
final expected = l10n.itemCount(count);
expect(
find.text(expected),
findsOneWidget,
reason: 'itemCount($count) in $locale should render "$expected"',
);
}
});
});
}
}
A few things to notice.
The loop is a plain Dart for around group, which is the idiomatic way to parameterise flutter test. Each locale shows up as its own group in the output, so a failure in [pl] is obvious at a glance.
The plural test deliberately runs 0, 1, 2, 5 and 21. Those five numbers hit zero, one, two, few, many and other across English, Arabic, Polish and Russian, so a dropped plural category produces a visible mismatch instead of silently falling through to other.
For a widget that takes arguments, build it per count rather than reaching into state. pumpLocalized re-pumps a fresh MaterialApp each time, which is fast and avoids stale text from the previous iteration.
What this catches, and what it does not
If app_pl.arb is missing notificationsLabel, gen-l10n falls back to the template locale. The Polish group then renders English, l10n.notificationsLabel also returns English, and the assertion passes. That is the one gap in this approach: it proves the rendering pipeline, not the completeness of the ARB.
To close it, add untranslated-messages-file: untranslated.json to l10n.yaml and fail CI when the file is non-empty:
flutter gen-l10n
test "$(cat untranslated.json)" = "{}" || { echo 'Untranslated keys:'; cat untranslated.json; exit 1; }
That gives you two layers: the ARB check catches missing keys, and the per-locale widget test catches broken plural syntax, placeholders that throw at runtime, and UI that overflows or clips with longer translations.
Golden tests per locale
The same helper drives goldens. Pass locale and a fixed surface size, then name the golden by locale:
testWidgets('settings golden [$locale]', (tester) async {
await tester.binding.setSurfaceSize(const Size(390, 844));
await tester.pumpLocalized(const SettingsScreen(), locale: locale);
await expectLater(
find.byType(SettingsScreen),
matchesGoldenFile('goldens/settings_${locale.languageCode}.png'),
);
});
Goldens are the fastest way to spot a German label that wraps to three lines or an Arabic screen whose icons did not mirror. Keep them on a Linux CI runner only, since font rasterisation differs across operating systems.
Keep the ARB files honest
The widget test above is only as good as the ARB files behind it. Plural strings are where teams get bitten most: a translator drops few from the Polish entry, the file is still valid JSON, gen-l10n compiles it, and the bug surfaces as a wrong word on a real device.
FlutterLocalisation's ARB editor validates ICU plural syntax as you edit and flags a locale that is missing a plural category the language actually needs, so the Polish few or the Arabic two never reaches git push. Pair that with the per-locale test loop here and your localization bugs die locally. Plans, including the free tier, are on the pricing page, and the rest of the Flutter i18n tutorials cover ARB structure, placeholders and locale switching in depth.
Try FlutterLocalisation free and run your next flutter test knowing every locale behind it is complete.