Implement Value-Focused Testing
You need to write tests that protect real business value β not chase coverage numbers. This guide shows how to use BDD-style tests for business domain code and standard unit tests for technical components.
Prerequisites
Section titled βPrerequisitesβ- A MOFA architecture project set up
flutter_testandmocktaildependencies added- Basic knowledge of Riverpod testing
dev_dependencies: flutter_test: sdk: flutter mocktail: ^1.0.0Always use flutter_test β never use just the test package. This ensures proper Flutter environment setup.
Testing Philosophy
Section titled βTesting PhilosophyβMOFA uses two test languages, chosen by what the code does:
| Code Type | Test Style | Examples |
|---|---|---|
| Business domain | BDD Given/When/Then | Services, complex Notifiers, Repository integration, Widget user scenarios |
| Technical components | Standard unit test | Convertors, CacheHandlerSpecs, StreamConvertors, pure functions |
Tests prove the system works. They are not a proxy metric for quality.
Step 1: Create a Test Context
Section titled βStep 1: Create a Test ContextβFor any test file with more than 3 tests, create a TestContext class. It replaces scattered setUp/tearDown blocks with a focused object that owns mocks, stubs, factories, assertions, and cleanup.
import 'package:flutter_test/flutter_test.dart';import 'package:mocktail/mocktail.dart';import 'package:riverpod/riverpod.dart';
class MockEventRepository extends Mock implements IEventRepository {}class MockAttendeeRepository extends Mock implements IAttendeeRepository {}
class EventOrchestratorServiceTestContext { EventOrchestratorServiceTestContext() { container = ProviderContainer( overrides: [ eventRepositoryProvider.overrideWith((ref) => mockEventRepo), attendeeRepositoryProvider.overrideWith((ref) => mockAttendeeRepo), ], ); }
late final ProviderContainer container; final mockEventRepo = MockEventRepository(); final mockAttendeeRepo = MockAttendeeRepository();
// ββ Stubbing ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ void stubEmptyRepos() { when(() => mockEventRepo.getEvents()).thenAnswer((_) async => []); when(() => mockAttendeeRepo.getAttendees()).thenAnswer((_) async => []); }
void stubWithEvents(List<EventModel> events) { when(() => mockEventRepo.getEvents()).thenAnswer((_) async => events); }
void stubEventRepoFailure(Exception error) { when(() => mockEventRepo.getEvents()).thenThrow(error); }
// ββ Factories βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ EventOrchestratorService get service => container.read(eventOrchestratorServiceProvider.notifier);
static List<EventModel> sampleEvents([int count = 3]) => List.generate(count, (i) => EventModel.test(id: 'event-$i'));
// ββ Assertions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ void verifyEventRepoAccessed({int times = 1}) { verify(() => mockEventRepo.getEvents()).called(times); }
void verifyAttendeeRepoNeverAccessed() { verifyNever(() => mockAttendeeRepo.getAttendees()); }
// ββ Cleanup βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ void dispose() => container.dispose();}Why TestContext? It eliminates copy-pasted mock setup, makes stub configuration explicit per test, and puts assertion helpers next to the mocks they verify.
Step 2: Write BDD-Style Business Domain Tests
Section titled βStep 2: Write BDD-Style Business Domain TestsβFor services, complex notifiers, repository integration, and widget user scenarios, use Given/When/Then naming. Group tests by behavior scenario, not by method.
import 'package:flutter_test/flutter_test.dart';
void main() { late EventOrchestratorServiceTestContext ctx;
setUp(() => ctx = EventOrchestratorServiceTestContext()); tearDown(() => ctx.dispose());
group('Dashboard loading', () { test('Given events and attendees exist, ' 'When getDashboard is called, ' 'Then it returns a merged dashboard from both repos', () async { ctx.stubWithEvents(EventOrchestratorServiceTestContext.sampleEvents()); ctx.stubEmptyRepos(); // attendees default to empty
final result = await ctx.service.getDashboard();
expect(result.events, hasLength(3)); ctx.verifyEventRepoAccessed(); });
test('Given both repos are empty, ' 'When getDashboard is called, ' 'Then it returns an empty dashboard', () async { ctx.stubEmptyRepos();
final result = await ctx.service.getDashboard();
expect(result.events, isEmpty); expect(result.attendees, isEmpty); }); });
group('Dashboard error handling', () { test('Given the event repository fails, ' 'When getDashboard is called, ' 'Then it propagates the error without calling attendee repo', () async { ctx.stubEventRepoFailure(Exception('Network error'));
expect(() => ctx.service.getDashboard(), throwsA(isA<Exception>())); ctx.verifyAttendeeRepoNeverAccessed(); }); });}Key points:
- Groups describe behavior scenarios (
Dashboard loading,Dashboard error handling), not method names - Test names use the Given/When/Then format β the name itself documents the scenario
- No
// Arrange,// Act,// Assertcomments β the Given/When/Then name already provides that structure
Step 3: Write Standard Unit Tests for Technical Components
Section titled βStep 3: Write Standard Unit Tests for Technical ComponentsβFor Convertors, CacheHandlerSpecs, StreamConvertors, and pure functions, use standard unit test naming. These components are simple enough that BDD adds noise.
Convertor Tests
Section titled βConvertor Testsβvoid main() { group('AttendeeModelConvertor', () { const convertor = AttendeeModelConvertor();
test('converts GQL data to domain model with all fields', () { final gqlData = _buildGqlAttendeeData( id: 'test-id', firstName: 'John', lastName: 'Doe', );
final result = convertor.convert(gqlData);
expect(result.id, equals('test-id')); expect(result.firstName, equals('John')); expect(result.lastName, equals('Doe')); });
test('maps null fields to empty string defaults', () { final gqlData = _buildGqlAttendeeData(id: null, firstName: null); final result = convertor.convert(gqlData);
expect(result.id, equals('')); expect(result.firstName, equals('')); }); });}CacheHandlerSpecs Tests
Section titled βCacheHandlerSpecs Testsβgroup('AttendeeCacheHandlerSpecs', () { test('upsert handler writes updated data to cache', () { final mockProxy = MockCacheProxy(); final mockResponse = MockOperationResponse();
when(() => mockResponse.data).thenReturn(testMutationData); when(() => mockProxy.readQuery(any())).thenReturn(testQueryData);
AttendeeCacheHandlerSpecs.handlers['attendeeUpsertCacheHandler']!( mockProxy, mockResponse, );
verify(() => mockProxy.writeQuery(any(), any())).called(1); });});Step 4: Write Table-Driven Tests for State Machines
Section titled βStep 4: Write Table-Driven Tests for State MachinesβWhen a component has multiple state transitions that can be listed exhaustively, use a table-driven pattern. Define a case class, enumerate all cases, and loop.
class NotifierStateCase { const NotifierStateCase({ required this.description, required this.arrange, required this.act, required this.expectedState, });
final String description; final void Function(AttendeeListTestContext ctx) arrange; final Future<void> Function(AttendeeListNotifier notifier) act; final bool Function(AsyncValue<List<AttendeeModel>> state) expectedState;}
final cases = [ NotifierStateCase( description: 'initial load with data -> shows data', arrange: (ctx) => ctx.stubWithAttendees([AttendeeModel.test()]), act: (notifier) => notifier.loadAttendees(), expectedState: (state) => state.hasValue && state.value!.isNotEmpty, ), NotifierStateCase( description: 'initial load fails -> shows error', arrange: (ctx) => ctx.stubAttendeeRepoFailure(Exception('fail')), act: (notifier) => notifier.loadAttendees(), expectedState: (state) => state.hasError, ), NotifierStateCase( description: 'refresh after error -> recovers with data', arrange: (ctx) => ctx.stubRecovery([AttendeeModel.test()]), act: (notifier) async { await notifier.loadAttendees(); // fails await notifier.refresh(); // succeeds }, expectedState: (state) => state.hasValue, ),];
void main() { group('AttendeeListNotifier state transitions', () { for (final c in cases) { test(c.description, () async { final ctx = AttendeeListTestContext(); c.arrange(ctx); await c.act(ctx.notifier); expect(c.expectedState(ctx.state), isTrue, reason: 'Failed: ${c.description}'); ctx.dispose(); }); } });}Step 5: Test Notifiers
Section titled βStep 5: Test NotifiersβNotifiers use both styles depending on complexity:
- Simple CRUD notifiers (direct repository access) β standard unit test
- Complex flow notifiers (service delegation, multi-step) β BDD-style
Simple CRUD Notifier (Standard Unit)
Section titled βSimple CRUD Notifier (Standard Unit)βvoid main() { group('AttendeeListNotifier', () { late MockAttendeeRepository mockRepository; late ProviderContainer container;
setUp(() { mockRepository = MockAttendeeRepository(); container = ProviderContainer( overrides: [ attendeeRepositoryProvider.overrideWith((ref) => mockRepository), ], ); });
tearDown(() => container.dispose());
test('loads attendees from repository on init', () async { final items = [AttendeeModel.test()]; when(() => mockRepository.getAttendees()).thenAnswer((_) async => items);
await container.read(attendeeListNotifierProvider.future);
final state = container.read(attendeeListNotifierProvider); expect(state.value, equals(items)); }); });}Complex Flow Notifier (BDD)
Section titled βComplex Flow Notifier (BDD)βvoid main() { late DashboardNotifierTestContext ctx;
setUp(() => ctx = DashboardNotifierTestContext()); tearDown(() => ctx.dispose());
group('Dashboard refresh with stale cache', () { test('Given cached data exists and is stale, ' 'When the user pulls to refresh, ' 'Then fresh data replaces the stale cache', () async { ctx.stubStaleCacheWithFreshFetch();
await ctx.notifier.refresh();
expect(ctx.state.value, isNotNull); ctx.verifyCacheInvalidated(); ctx.verifyFreshDataFetched(); }); });}Step 6: Test Widgets as User Scenarios
Section titled βStep 6: Test Widgets as User ScenariosβWidget tests for user interaction use BDD. Widget tests for rendering-only (e.g., βdisplays correct textβ) use standard unit.
User Scenario (BDD)
Section titled βUser Scenario (BDD)βvoid main() { group('User list screen', () { testWidgets( 'Given the user sees an error, ' 'When they tap Retry, ' 'Then the list reloads and shows data', (tester) async { final ctx = UserListScreenTestContext(); ctx.stubFailureThenSuccess();
await tester.pumpWidget(ctx.buildWidget()); await tester.pumpAndSettle();
expect(find.text('Retry'), findsOneWidget);
ctx.stubWithUsers(UserListScreenTestContext.sampleUsers()); await tester.tap(find.text('Retry')); await tester.pumpAndSettle();
expect(find.byType(ListTile), findsNWidgets(2)); }); });}Rendering-Only (Standard Unit)
Section titled βRendering-Only (Standard Unit)βtestWidgets('displays item name and description', (tester) async { final item = FeatureItem(id: '1', name: 'Test', description: 'Desc');
await tester.pumpWidget( MaterialApp(home: Scaffold(body: FeatureCard(item: item))), );
expect(find.text('Test'), findsOneWidget); expect(find.text('Desc'), findsOneWidget);});Test Organization and Anti-Patterns
Section titled βTest Organization and Anti-PatternsβOrganize by Behavior
Section titled βOrganize by Behaviorβtest/βββ unit/β βββ convertors/ # Standard unitβ βββ cache_handlers/ # Standard unitβ βββ services/ # BDD with TestContextβ βββ repositories/ # BDDβ βββ notifiers/ # Both stylesβββ widget/β βββ screens/ # BDD for user scenariosβ βββ components/ # Unit for rendering, BDD for interactionβ βββ forms/ # BDD for submission flowsβββ helpers/ βββ contexts/ # TestContext classes βββ mocks/Anti-Patterns to Avoid
Section titled βAnti-Patterns to Avoidβ- Coverage percentage targets β They create perverse incentives to write low-value tests
- Method-per-group for business logic β Group by scenario, not by method name
- Scattered mock setup β Centralize in TestContext classes
- Testing implementation details β Assert observable behavior, not internal state
- Arrange/Act/Assert comments in BDD tests β The Given/When/Then name provides that structure
You now have a testing approach that protects real business value. BDD tests document domain behavior, unit tests guard technical correctness, and TestContext classes keep everything maintainable.
Next Steps
Section titled βNext Stepsβ- See the Testing Patterns quick-reference for decision tables and templates
- Review ADR-004 for the historical context behind this approach