| name | fl-testing |
| description | Writes unit and widget tests for blocs, repositories, and screens using bloc_test + mocktail |
| license | MIT |
| metadata | {"audience":"flutter-developers","framework":"flutter","pattern":"testing"} |
Testing Skill
When to use
- Adding coverage for a new bloc, repository, or screen.
- Locking down a fixed bug with a regression test.
- Verifying a state-machine path that's easy to break.
Layout
Tests live next to the package they cover:
apps/main/test/ # app-level tests
core/test/ # core-level tests
modules/data_source/test/
plugins/<plugin>/test/
A common shape inside apps/main/test/:
test/
├── unit/
│ ├── bloc/
│ ├── domain/
│ └── data/
├── widget/
└── helpers/
Running tests
fvm flutter test
fvm flutter test test/unit/bloc/foo_bloc_test.dart
make coverage_main
Use project make targets when they exist. For direct commands, check .fvm_cache; when USING_FVM=1, invoke fvm flutter test, fvm flutter analyze, or fvm dart analyze instead of local tools. Do not assume every branch has aggregate analyze/test targets.
E2E smoke checks
Browser (Playwright)
Use Playwright MCP browser tools only when the user asks for E2E or Playwright verification of Flutter web changes. Don't add repo-level Node/Playwright infrastructure unless asked.
Build only the affected web package from the repo root, using that package's normal FVM build command. Serve the resulting build/web directory with SPA fallback before navigating, because path URL strategy routes must resolve to index.html.
For assertions, verify the behavior the user requested: URL changes, visible/semantic UI state, route state intentionally exposed by the app, and browser console errors. A missing favicon 404 can be noted separately from app failures.
Native (flutter_skill)
Use the flutter-skill MCP only when the user asks for E2E or spec verification on a native debug build, same rule as Playwright. The project already includes the flutter_skill dep in apps/main, the debug-only binding in AppDelegate.run, and the flutter-skill MCP server registration in .mcp.json.
If no debug session is running, start one yourself when a simulator/emulator is available and the local Flutter/FVM toolchain is available:
fvm flutter run -t lib/main.dart --flavor dev -d <id>
Ask the user only for prerequisites you cannot perform yourself, such as installing/loading the flutter-skill MCP server or booting a missing simulator.
See AGENTS.md under "E2E testing (flutter_skill)" for the version pin and wrapper script.
Driving flow: inspect_interactive for the element tree, then tap / enter_text / screenshot / wait_for_idle. The inspector returns refs as key:<name> (preferred) or text:<label> when no Key is set; use tap_at for coordinate-driven taps.
Bloc tests
Use bloc_test for sequence assertions and mocktail for collaborators.
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class _MockUsecase extends Mock implements FeatureUsecase {}
void main() {
late _MockUsecase usecase;
late FeatureBloc bloc;
setUp(() {
usecase = _MockUsecase();
bloc = FeatureBloc(null, usecase);
});
tearDown(() => bloc.close());
group('FeatureBloc', () {
test('initial state is FeatureInitial with empty data', () {
expect(bloc.state, isA<FeatureInitial>());
expect(bloc.state.data.detail, isNull);
});
blocTest<FeatureBloc, FeatureState>(
'GetFeatureEvent loads detail and stays in FeatureInitial',
build: () {
when(() => usecase.getById('1'))
.thenAnswer((_) async => Item(id: '1', name: 'A'));
return bloc;
},
act: (b) => b.add(GetFeatureEvent('1')),
expect: () => [
isA<FeatureInitial>().having((s) => s.detail?.id, 'detail.id', '1'),
],
);
});
}
Note: this template's blocs use abstract class state hierarchies, not freezed unions, so assert with isA<FeatureInitial>() plus having(...) rather than equality on a sealed union.
Repository tests
class _MockApi extends Mock implements UserApiClient {}
void main() {
late _MockApi api;
late UserRepositoryImpl repo;
setUp(() {
api = _MockApi();
repo = UserRepositoryImpl(api);
});
test('getUser delegates to api client', () async {
final user = UserModel(id: '1', name: 'A');
when(() => api.getUser('1')).thenAnswer((_) async => user);
expect(await repo.getUser('1'), user);
verify(() => api.getUser('1')).called(1);
});
}
Widget tests
Wrap the screen in the same providers it gets in production: a BlocProvider (with a mocked bloc) and MaterialApp.router or a plain MaterialApp with Localizations if your widget reads context.l10n.
class _MockBloc extends MockBloc<FeatureEvent, FeatureState>
implements FeatureBloc {}
void main() {
late _MockBloc bloc;
setUp(() => bloc = _MockBloc());
Widget pump(Widget child) => MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: BlocProvider<FeatureBloc>.value(value: bloc, child: child),
);
testWidgets('shows empty state when items is empty', (tester) async {
when(() => bloc.state).thenReturn(
FeatureInitial(data: const _StateData()),
);
await tester.pumpWidget(pump(const FeatureScreen()));
expect(find.byType(EmptyData), findsOneWidget);
});
}
For interactions, drive a real bloc through the actions extension instead of mocking — it catches more bugs.
Mocktail conventions
- Always register
Fallback values for any value-typed argument matchers (registerFallbackValue(...)).
- Prefer
verifyNever/verifyInOrder over loose verify.
- Don't share
Mock instances between tests — recreate in setUp.
Checklist
Common mistakes
- Asserting on
state == FeatureLoaded(...) — equality is reference-based on these abstract state classes; use isA<>().having(...).
- Forgetting
registerFallbackValue for typed arguments and getting cryptic mocktail errors.
- Pumping a widget without a
MaterialApp ancestor; localizations and themes blow up.
Related