← Back to Blog

Fix AppLocalizations.of(context) Null in Widget Tests

fluttertestingl10nwidget-testsarb

Fix AppLocalizations.of(context) Null in Widget Tests

Your app runs fine in every locale. Then you write this:

testWidgets('shows the greeting', (tester) async {
  await tester.pumpWidget(const MaterialApp(home: GreetingCard()));
  expect(find.text('Welcome back'), findsOneWidget);
});

and it dies with Null check operator used on a null value inside AppLocalizations.of(context)! — or it renders empty strings and every find.text returns findsNothing.

The universal advice, wrap it in a MaterialApp with localizationsDelegates, is necessary and fixes maybe half of these. This post covers the other half: the frame-timing problem that leaves appLocalizations.of(context) null in a widget test even when your delegates are wired perfectly.

Step one: the delegates (the half everyone tells you)

Localizations.of is a plain inherited-widget lookup:

// packages/flutter/lib/src/widgets/localizations.dart
static T? of<T>(BuildContext context, Type type) {
  final _LocalizationsScope? scope =
      context.dependOnInheritedWidgetOfExactType<_LocalizationsScope>();
  return scope?.localizationsState.resourcesFor<T?>(type);
}

Two ways that returns null: there is no Localizations scope above your context, or the scope has no resources of that type yet. A bare MaterialApp creates the scope but registers only the default Material/Widgets delegates — nothing there knows about AppLocalizations. So the flutter widget test localizationsDelegates setup you need is:

MaterialApp(
  locale: locale,
  localizationsDelegates: AppLocalizations.localizationsDelegates,
  supportedLocales: AppLocalizations.supportedLocales,
  home: widgetUnderTest,
)

AppLocalizations.localizationsDelegates is generated for you by flutter gen-l10n and already bundles GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate and GlobalWidgetsLocalizations.delegate, so you rarely need to list them by hand.

If your test still fails after this, keep reading.

Step two: the frame that has no data

Localizations loads delegates asynchronously by design. Here is the real logic from _LocalizationsState:

// packages/flutter/lib/src/widgets/localizations.dart (abridged)
void load(Locale locale) {
  Map<Type, dynamic>? typeToResources;
  final typeToResourcesFuture = _loadAll(locale, delegates)
      .then<Map<Type, dynamic>>((value) => typeToResources = value);

  if (typeToResources != null) {
    // All of the delegates' resources loaded synchronously.
    _typeToResources = typeToResources!;
    this.locale = locale;
  } else {
    RendererBinding.instance.deferFirstFrame();
    typeToResourcesFuture.then<void>((value) {
      if (mounted) {
        setState(() { _typeToResources = value; this.locale = locale; });
      }
      RendererBinding.instance.allowFirstFrame();
    });
  }
}

Read the two branches. _loadAll returns a SynchronousFuture only if every delegate's load() returned a SynchronousFuture. If even one returns a real Future, the state takes the else branch: _typeToResources stays exactly as it was — an empty map on first load — and is filled only by a later setState.

That is your null. On the single frame pumpWidget produced, resourcesFor<AppLocalizations> looked in an empty map and handed you null.

await tester.pumpWidget(...) renders exactly one frame. The delegate future completes after it. One more pump and the setState has landed:

await tester.pumpWidget(app);
await tester.pump(); // <- the fix
expect(find.text(l10n.welcomeBack), findsOneWidget);

Which delegates are actually async?

Worth knowing, because it tells you whether you have a live problem or a latent one. Checked against current Flutter and generator sources:

Setup load() returns Null on frame 1?
flutter gen-l10n, default config SynchronousFuture No
gen-l10n with use-deferred-loading: true real Future Yes
flutter_intl / intl_utils (S.delegate) initializeMessages(...).then(...) Yes
Custom delegate reading ARB/JSON via rootBundle real Future Yes
GlobalMaterialLocalizations.delegate SynchronousFuture No

The intl_utils row bites the most teams, because its generated loader is literally this:

static Future<S> load(Locale locale) {
  final localeName = Intl.canonicalizedLocale(/* ... */);
  return initializeMessages(localeName).then((_) { /* ... */ });
}

A .then() on a non-synchronous future is a non-synchronous future. Frame one has nothing in it.

Check your own project in one line:

import 'package:flutter/foundation.dart' show SynchronousFuture;

test('delegate loads synchronously', () {
  final future = AppLocalizations.delegate.load(const Locale('en'));
  print(future is SynchronousFuture); // true => resources exist on frame 1
});

Even when that prints true, keep the extra pump. It costs one frame, it is a no-op when the load is synchronous, and it stops the day someone flips use-deferred-loading from turning into a hundred red tests.

Notice the other half of that else branch: deferFirstFrame(). During an async load Flutter actively holds back the first frame — which is exactly why a flutter golden test localization locale snapshot taken too early captures a blank or half-built surface.

A reusable pumpLocalized() harness

Put the pump discipline in one place. This version also hands back the real, ARB-backed AppLocalizations for the locale, so assertions compare against your actual translations instead of hard-coded English or a mock.

// test/support/pump_localized.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] in a fully localized MaterialApp for [locale] and returns
  /// the real AppLocalizations instance built from your ARB files.
  Future<AppLocalizations> pumpLocalized(
    Widget child, {
    Locale locale = const Locale('en'),
    Size surfaceSize = const Size(400, 800),
  }) async {
    view.physicalSize = surfaceSize * view.devicePixelRatio;
    addTearDown(view.reset);

    await pumpWidget(
      MaterialApp(
        locale: locale,
        localizationsDelegates: AppLocalizations.localizationsDelegates,
        supportedLocales: AppLocalizations.supportedLocales,
        home: Scaffold(body: child),
      ),
    );

    // pumpWidget renders exactly one frame. If any delegate's load() returned
    // a real Future, Localizations still has no resources on that frame and
    // AppLocalizations.of(context) is null. This second pump lets the
    // completed futures land and rebuilds the subtree.
    await pump();

    return AppLocalizations.delegate.load(locale);
  }
}

Usage — no string literals, so a copy tweak in the ARB doesn't break the test:

testWidgets('greets the user in French', (tester) async {
  final l10n = await tester.pumpLocalized(
    const GreetingCard(userName: 'Amelie'),
    locale: const Locale('fr'),
  );

  expect(find.text(l10n.welcomeBack('Amelie')), findsOneWidget);
});

Asserting against l10n.welcomeBack(...) rather than 'Welcome back' matters more than it looks. It is the difference between a test that verifies your widget wired up the right key, and a test that breaks every time a translator touches the copy.

Run every locale through the same test

void main() {
  for (final locale in AppLocalizations.supportedLocales) {
    final tag = locale.toLanguageTag();

    testWidgets('CheckoutSummary renders in $tag', (tester) async {
      final l10n = await tester.pumpLocalized(
        const CheckoutSummary(itemCount: 3),
        locale: locale,
        surfaceSize: const Size(360, 640), // narrow: surfaces overflow first
      );

      expect(find.text(l10n.checkoutTitle), findsOneWidget);
      expect(find.textContaining(l10n.itemsInCart(3)), findsOneWidget);
    });
  }
}

Two regressions this catches cheaply:

  • Layout overflow. German and Finnish strings routinely run 30–40% longer than English. A RenderFlex overflowed error throws a FlutterError, which flutter_test records and fails the test on — no extra assertion needed.
  • Locale-specific formatting crashes — plurals, dates and number formats that only blow up in one language.

One thing the loop does not catch: a missing key. When an ARB omits a message, gen-l10n inherits the template-locale implementation, so your widget and your l10n instance both return English and the test passes happily. Catch those in l10n.yaml instead:

arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
synthetic-package: false        # generate into lib/, required from Flutter 3.32
nullable-getter: false          # of() throws instead of silently returning null
untranslated-messages-file: l10n_untranslated.json

nullable-getter: false is worth it on its own: it converts silent blank text into a loud crash at the exact call site.

Golden tests per locale

for (final locale in AppLocalizations.supportedLocales) {
  final tag = locale.toLanguageTag();

  testWidgets('golden: checkout ($tag)', (tester) async {
    await tester.pumpLocalized(const CheckoutSummary(itemCount: 3), locale: locale);
    await tester.pumpAndSettle();

    await expectLater(
      find.byType(CheckoutSummary),
      matchesGoldenFile('goldens/checkout_$tag.png'),
    );
  });
}

Two caveats. flutter test ships a single test font that draws every glyph as a box, so goldens only prove layout unless you load your real fonts once in test/flutter_test_config.dart:

// test/flutter_test_config.dart
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';

Future<void> testExecutable(FutureOr<void> Function() testMain) async {
  TestWidgetsFlutterBinding.ensureInitialized();
  await (FontLoader('Inter')
        ..addFont(rootBundle.load('assets/fonts/Inter-Regular.ttf')))
      .load();
  return testMain();
}

And RTL locales legitimately mirror, so ar and he goldens differ structurally from their LTR siblings — that is the point of running them.

The remaining null causes, quickly

  • Context above the scope. Calling AppLocalizations.of(context) in the same build that returns the MaterialApp uses a context above Localizations. Wrap in a Builder, or use onGenerateTitle for MaterialApp.title.
  • A hand-rolled delegate list that quietly drops AppLocalizations.delegate.
  • Locale resolution surprises. The generated isSupported matches on languageCode only, so Locale('pt', 'BR') works if pt is supported — but a locale outside supportedLocales resolves to your first supported locale, giving you the wrong language rather than a null.

Keep the ARB files honest

Tests catch what's broken in code. They can't tell you your Polish file is missing the few plural category, or that a Russian string quietly kept its English text. That is an ARB-level problem, and it's what FlutterLocalisation handles: an ARB editor for your app_<locale>.arb files with ICU plural-syntax validation that flags locales missing a plural category the language actually requires.

More on the tooling: Flutter l10n.yaml configuration guide · Flutter localization testing guide · features · pricing.

Try FlutterLocalisation free — manage every locale's ARB in one editor, so the strings your pumpLocalized() loop asserts against are strings you can trust.