Advanced¶
Deep configuration for golden_matrix: shared run configs, typed scenarios, filtering rules, RTL, tolerance, skipping, wrappers, dependency injection, post-pump state, custom theme data, dry-run previews, and font loading.
See also: Sampling · Devices · Reports · CI integration · Migration guide · Home.
Shared run config (MatrixRunConfig)¶
matrixGolden, screenMatrixGolden and componentMatrixGolden accept the same sixteen options. Declare them once and reuse:
const ciRun = MatrixRunConfig(
axes: MatrixAxes(themes: [MatrixTheme.light, MatrixTheme.dark]),
reportFormats: {MatrixReportFormat.json, MatrixReportFormat.junit},
tolerance: 0.001,
printSummary: false,
);
matrixGolden('PrimaryButton', scenarios: [...], config: ciRun);
matrixGolden('Badge', scenarios: [...], config: ciRun, skip: true);
componentMatrixGolden('Chip', scenarios: [...], config: ciRun);
The config is const-constructible, so it can live next to your MatrixPreset.
Precedence. An argument passed directly to the function always wins over the same field of the config — including when it repeats the parameter's own default. skip: false next to a config with skip: true runs the tests. Three layers resolve left to right:
| Setting | Winner |
|---|---|
axes |
argument → config.axes → preset.axes → const MatrixAxes() |
sampling |
argument → config.sampling → preset.sampling → full |
rules |
argument → config.rules, merged after preset.rules |
maxCombinations |
argument → config.maxCombinations — presets carry no cap |
| everything else | argument → config → the parameter's default |
Only rules merge, and only with the preset's. Between argument and config a list replaces: merging would make it impossible to narrow a shared config down.
What the config does not carry. Mode-specific options stay on their own functions: captureScale (viewport capture), pixelRatio and padding (intrinsic capture), extraLocalizationsDelegates, wrapChild / wrapApp, appBuilder. A field that two of three entry points quietly ignore is worse than a slightly longer call site.
Typed scenarios¶
A common case is one widget rendered across several state-manager states
(loading / loaded / error / empty from a Cubit, Bloc, Riverpod provider, or
ChangeNotifier). MatrixScenario.typed<T> attaches a compile-time-checked
payload to each scenario and feeds it to a builder you can reuse across all
of them — no stringly-typed switch on the scenario name, no per-scenario
BlocProvider boilerplate.
sealed class UserState { ... } // your state-manager state
Widget buildUserList(UserState state) => BlocProvider<UserCubit>(
create: (_) => UserCubit()..emit(state),
child: const UserListScreen(),
);
matrixGolden(
'UserList',
scenarios: [
MatrixScenario.typed('loading', payload: const UserState.loading(), builder: buildUserList),
MatrixScenario.typed('loaded', payload: UserState.loaded([alice, bob]), builder: buildUserList),
MatrixScenario.typed('error', payload: const UserState.error('Timeout'), builder: buildUserList),
MatrixScenario.typed('empty', payload: const UserState.empty(), builder: buildUserList),
],
axes: const MatrixAxes(themes: [MatrixTheme.light, MatrixTheme.dark]),
);
T is inferred from payload/builder (write MatrixScenario.typed<UserState>(...)
to pin it). Each scenario can carry a different payload type — the typedness is
per-scenario, so it composes with every axis (themes × locales × devices × scales).
Non-breaking
MatrixScenario.typed is additive — the plain MatrixScenario(name, builder: () => widget)
constructor is unchanged. Internally the typed builder is wrapped into the
zero-argument builder (it closes over the payload), so nothing else in the
matrix changes. The payload is also exposed as scenario.payload (typed as
Object?) for introspection.
Mocks, GetIt, and per-combination setup¶
Each combination is its own testWidgets block, so setUp/tearDown run
per-combination (fresh mocks) and setUpAll runs once per matrix. That layers
cleanly with the typed builder, which is the right home for per-scenario stub
variations:
late MockUserRepository repo;
setUpAll(() => provideDummy<Either<Failure, List<User>>>(const Right([])));
setUp(() {
repo = MockUserRepository();
GetIt.I.registerSingleton<UserRepository>(repo);
});
tearDown(() => GetIt.I.reset());
Widget build(UserPayload p) {
when(repo.fetchUsers()).thenAnswer((_) async =>
p.error != null ? Left(Failure(p.error!)) : Right(p.users));
return BlocProvider<UserCubit>(
create: (_) => UserCubit(repo: repo)..loadUsers(),
child: const UserListScreen(),
);
}
matrixGolden('UserList', scenarios: [
MatrixScenario.typed('loaded', payload: UserPayload(users: [alice, bob]), builder: build),
MatrixScenario.typed('empty', payload: const UserPayload(), builder: build),
MatrixScenario.typed('error', payload: const UserPayload(error: 'net'), builder: build),
]);
Rules¶
Filter the generated combinations with predicates. Rules compose and run sequentially.
MatrixRule.exclude((c) => c.theme.name == 'dark' && c.textScale > 1.5)
MatrixRule.includeOnly((c) => c.device.name == 'phoneSmall' || c.device.name == 'tablet')
Passed to a test via the rules list:
matrixGolden(
'ProfileCard',
scenarios: [
MatrixScenario('loading', builder: () => const ProfileCard.loading()),
MatrixScenario('data', builder: () => ProfileCard(user: fakeUser)),
MatrixScenario('error', builder: () => const ProfileCard.error('Timeout')),
],
axes: MatrixAxes(
themes: [MatrixTheme.light, MatrixTheme.dark],
locales: [Locale('en'), Locale('ru'), Locale('ar')],
textScales: [1.0, 2.0],
devices: [MatrixDevice.iphoneSE, MatrixDevice.galaxyA51, MatrixDevice.tablet],
),
rules: [
MatrixRule.exclude((c) => c.locale.languageCode != 'ar' && c.direction == TextDirection.rtl),
],
);
Direction / RTL auto-inference¶
Right-to-left locales get TextDirection.rtl with no manual setup. The rule is
the locale's script subtag when it has one, and its language's default
script otherwise — so ar, he, fa, ur, ps, sd, ug, dv, ckb,
syr, nqo and the rest of CLDR's RTL languages are RTL, az-Arab is RTL
because of its script, and ar-Latn (romanised Arabic) is LTR for the same
reason.
Combinations expose c.direction, which you can read in rules (see above) or
flip on a one-off combination with copyWith:
Deciding direction yourself¶
Any table over locales is wrong for someone: a custom language subtag, a
private-use locale, or a design that mirrors on an app flag rather than on the
locale. MatrixAxes.directionResolver replaces the built-in inference:
TextDirection myDirection(Locale locale) =>
locale.languageCode == 'xx' ? TextDirection.rtl : TextDirection.ltr;
matrixGolden(
'Button',
scenarios: [...],
axes: const MatrixAxes(
locales: [Locale('en'), Locale('xx')],
directionResolver: myDirection, // top-level fn keeps the axes const
),
);
An explicit directions list still wins: that axis enumerates both directions
regardless of locale, which is a different question from "which way does this
locale read".
ku changed direction in 1.6.0
ku used to be hardcoded RTL. Kurmanji Kurdish — what ku means in CLDR —
is written in the Latin alphabet; the Arabic-script variant is ckb. If you
test ku, its goldens are renamed from _rtl_ to _ltr_ and need
--update-goldens once.
Capture resolution (captureScale)¶
Goldens are captured at the device's logical size: a phoneSmall
(375×667 @ 2x) golden is a 375×667 PNG. MatrixDevice.pixelRatio drives layout
and MediaQuery — not the file's resolution (see
Devices).
Pass captureScale when you want a denser raster — supersampled marketing
shots, or reviewing fine detail:
matrixGolden(
'Hero',
scenarios: [...],
axes: const MatrixAxes(devices: [MatrixDevice.phoneSmall]),
captureScale: 2.0, // 750×1334 instead of 375×667
);
Same parameter on screenMatrixGolden(). In component mode the equivalent is
componentMatrixGolden(pixelRatio: ...), which also defaults to 1.0 — both
levels write logical-size goldens unless you ask for more.
Changing the scale invalidates existing goldens
The comparator fails on differing dimensions before it ever compares
pixels (image sizes do not match), so regenerate with
flutter test --update-goldens. The scale is deliberately not part of
the golden path — two calls that write the same path with different scales
will fight over the file.
File size scales with the square of the value: captureScale: 3.0 is ~9× the
pixels. Raise it for the handful of goldens that need it, not matrix-wide.
Tolerance¶
Allow small pixel differences for stable CI:
matrixGolden(
'Widget',
scenarios: [...],
axes: axes,
tolerance: 0.05 / 100, // 0.05% pixel diff allowed
);
Skip¶
Conditionally skip tests (e.g. platform-specific golden files):
Custom Wrapper (wrapChild)¶
Override the default Scaffold(body: Center(child:)) layout. wrapChild runs inside the auto-built MaterialApp.home, so it's the right level for layout shells (padding, alignment), Theme overrides, or scoped providers that live below MaterialApp:
matrixGolden(
'Widget',
scenarios: [...],
axes: axes,
wrapChild: (child) => child, // no Scaffold, no Center
);
App-level decorator (wrapApp)¶
Wrap the auto-built MaterialApp from outside. This is the seam for DI providers that must sit above MaterialApp — ProviderScope (Riverpod), BlocProvider / MultiBlocProvider, MultiProvider, or any root-level InheritedWidget (e.g. brand theme scopes). The callback receives the current combination so providers can vary per scenario:
matrixGolden(
'ProfileCard',
scenarios: [...],
axes: axes,
// Riverpod
wrapApp: (app, combination) => ProviderScope(
overrides: [
userRepoProvider.overrideWithValue(FakeUserRepo()),
],
child: app,
),
);
matrixGolden(
'CounterCard',
scenarios: [
MatrixScenario('zero', builder: () => const CounterCard()),
MatrixScenario('high', builder: () => const CounterCard()),
],
axes: axes,
// Bloc, varying state by scenario
wrapApp: (app, c) => BlocProvider<CounterBloc>.value(
value: FakeCounterBloc(c.scenario.name == 'high' ? 99 : 0),
child: app,
),
);
wrapApp is complementary to wrapChild:
wrapAppsits above MaterialApp (DI providers)wrapChildsits inside MaterialApp.home (layout shells)
Use them together when needed. When wrapApp is omitted, the widget tree is identical to previous versions — existing goldens unaffected.
For full-screen tests where you want even more control, use screenMatrixGolden with its appBuilder.
Post-pump state: setup, freezeAnimations, captureAfter¶
Three orthogonal parameters (available on both matrixGolden and screenMatrixGolden) for snapshotting non-initial states.
setup — interact before capture¶
matrixGolden(
'LoginForm',
scenarios: [MatrixScenario('validation_error', builder: () => const LoginForm())],
axes: axes,
setup: (tester, combination) async {
await tester.enterText(find.byKey(emailKey), 'bad-email');
await tester.tap(find.byKey(submitKey));
await tester.pumpAndSettle();
},
);
Runs after pumpAndSettle, before the golden is captured. Use to tap, scroll, enter text, expand menus — anything needed to bring the widget into the visual state you want to snapshot.
freezeAnimations — kill infinite shimmer/skeletons¶
matrixGolden(
'UserCardSkeleton',
scenarios: [MatrixScenario('loading', builder: () => const UserCardSkeleton())],
axes: axes,
freezeAnimations: true, // halts Tickers below — snapshot is stable
);
Wraps the widget tree in TickerMode(enabled: false). Halts every AnimationController / Ticker, including shimmer, skeleton loaders, Lottie, breathing dots, marquee — all the things that otherwise make pumpAndSettle hang or produce non-deterministic frames.
captureAfter — snapshot a specific frame¶
matrixGolden(
'SlideInDialog',
scenarios: [MatrixScenario('mid_slide', builder: () => const SlideInDialog())],
axes: axes,
captureAfter: const Duration(milliseconds: 150), // catch dialog half-open
);
Pumps the test clock for the given duration after settling (and after setup), before capture. Use to catch a deterministic mid-animation frame.
Composing them¶
All three combine cleanly:
matrixGolden(
'FormAfterSubmit',
scenarios: [...],
axes: axes,
setup: (tester, _) async {
await tester.tap(submitButton);
await tester.pump(); // one frame so the loader appears
},
freezeAnimations: true, // freeze the loader spinner
);
Custom Theme Data¶
Attach arbitrary context to themes — custom theme systems, brand configs, feature flags:
matrixGolden(
'Widget',
scenarios: [...],
axes: MatrixAxes(
themes: [
MatrixTheme.custom('light', ThemeData.light(), data: MyTheme.light()),
MatrixTheme.custom('dark', ThemeData.dark(), data: MyTheme.dark()),
],
),
);
// Access in screenMatrixGolden appBuilder:
appBuilder: (combination) {
final myTheme = combination.theme.data as MyTheme;
return MyThemeProvider(theme: myTheme, child: MaterialApp(...));
}
Dry-run preview¶
Inspect what a matrixGolden / screenMatrixGolden call would do — combination counts, sampled list, golden paths, collisions — without rendering widgets or writing files:
final preview = previewMatrixGolden(
name: 'PrimaryButton',
scenarios: [MatrixScenario('default', builder: () => const PrimaryButton())],
axes: MatrixAxes(
themes: [MatrixTheme.light, MatrixTheme.dark],
locales: [Locale('en'), Locale('ar')],
),
sampling: MatrixSampling.pairwise,
);
print(preview);
// PrimaryButton
// Scenarios: 1 (default)
// Raw combinations: 4
// After rules: 4
// After sampling (pairwise): 4
// Combinations:
// 1. default | light ltr en 1.0x phoneSmall
// -> goldens/primarybutton/default/light_en_ltr_1x_phonesmall.png
// ...
preview.afterSamplingCount; // 4
preview.goldenPaths; // list of paths the runner would write
preview.duplicatePaths; // non-empty when scenarios collide on the same path
Use it to sanity-check scenarioTags, estimate CI cost before adding a new axis, or catch golden-path collisions before they silently overwrite each other.
To preview a componentMatrixGolden call instead, pass component: true. Component paths carry no device segment and the devices axis is collapsed to its first value, so the counts and paths only match the real run with that flag set:
previewMatrixGolden(
name: 'PrimaryButton',
scenarios: [MatrixScenario('default', builder: () => const PrimaryButton())],
component: true,
);
// -> goldens/primarybutton/default/light_en_ltr_1x.png (no device)
The runners no longer wait for you to ask: matrixGolden, screenMatrixGolden and componentMatrixGolden all print a warning while registering tests when a configuration produces no combinations at all, or when two combinations claim the same golden file.
Font loading¶
Set up font loading once in test/flutter_test_config.dart so real fonts (Roboto + app fonts) render instead of Ahem squares:
// test/flutter_test_config.dart
import 'dart:async';
import 'package:golden_matrix/golden_matrix.dart';
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
await loadAppFonts();
return testMain();
}
Layout-deterministic tests (since 0.18.0)
loadAppFonts(textFonts: false) loads only icon fonts and uses Ahem
placeholders for text. Text geometry becomes predictable across
macOS/Linux CI, while icons still render with real glyphs for review.
Variable fonts and font failures (since 1.1.2)
Fonts whose filename contains [ / ] (Geist[wght].ttf — the variable-font
convention) now load: loadAppFonts() retries the percent-decoded asset key
that flutter_tools double-encodes. And if a family still fails, it is
skipped with a warning instead of throwing out of testExecutable and
failing the entire test file before any test runs.
See Font namespacing.