Skip to content

Implement Value-Focused Testing

Problem

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.

  • A MOFA architecture project set up
  • flutter_test and mocktail dependencies added
  • Basic knowledge of Riverpod testing
dev_dependencies:
flutter_test:
sdk: flutter
mocktail: ^1.0.0
Important

Always use flutter_test β€” never use just the test package. This ensures proper Flutter environment setup.

MOFA uses two test languages, chosen by what the code does:

Code TypeTest StyleExamples
Business domainBDD Given/When/ThenServices, complex Notifiers, Repository integration, Widget user scenarios
Technical componentsStandard unit testConvertors, CacheHandlerSpecs, StreamConvertors, pure functions

Tests prove the system works. They are not a proxy metric for quality.

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.

test/helpers/contexts/event_orchestrator_service_test_context.dart
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.

For services, complex notifiers, repository integration, and widget user scenarios, use Given/When/Then naming. Group tests by behavior scenario, not by method.

test/unit/services/event_orchestrator_service_test.dart
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, // Assert comments β€” 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.

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(''));
});
});
}
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);
});
});

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();
});
}
});
}

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
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));
});
});
}
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();
});
});
}

Widget tests for user interaction use BDD. Widget tests for rendering-only (e.g., β€œdisplays correct text”) use standard unit.

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));
});
});
}
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/
β”œβ”€β”€ 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/
  1. Coverage percentage targets β€” They create perverse incentives to write low-value tests
  2. Method-per-group for business logic β€” Group by scenario, not by method name
  3. Scattered mock setup β€” Centralize in TestContext classes
  4. Testing implementation details β€” Assert observable behavior, not internal state
  5. Arrange/Act/Assert comments in BDD tests β€” The Given/When/Then name provides that structure
Success!

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.

  • See the Testing Patterns quick-reference for decision tables and templates
  • Review ADR-004 for the historical context behind this approach