stack
| concerns | libraries |
|---|---|
| unit/widget testing framework | flutter_test (pub.dev default) |
| dependency substitution | writing Fakes directly (avoiding mock libraries) |
| stream verification | Stream.first, expectLater(stream, emits...) |
The project has no test dependencies other than flutter_test . mockito/mocktail Before introducing Fake , try writing it with
Directory
Tests mirror the production code structure.
test/
└─ presentation/
└─ ingredient/
└─ ingredient_screen_test.dart
- Unit test:
test/<layer>/<feature>/<file>_test.dart - Widget test:
test/presentation/<feature>/<feature>_screen_test.dart
Unit tests — UseCase
The easiest starting point. Swap in a Fake Repository and check only inputs and outputs.
// test/domain/use_case/get_saved_recipes_use_case_test.dart
void main() {
test('북마크된 id 집합에 해당하는 레시피만 스트림으로 흘러나온다', () async {
final useCase = GetSavedRecipesUseCase(
recipeRepository: FakeRecipeRepository(
recipes: [Recipe(id: 1, /* ... */), Recipe(id: 2, /* ... */)],
),
bookmarkRepository: FakeBookmarkRepository(initialIds: {2}),
);
final result = await useCase.execute().first;
expect(result.map((e) => e.id), [2]);
});
}
If you only need the first event of a stream, .first is convenient. To verify the order of multiple events, expectLater(stream, emitsInOrder([...])).
Unit tests — ChangeNotifier ViewModel
ChangeNotifier 는 addListener to observe changes, or notifyListeners() and then immediately viewModel.state to verify.
void main() {
late FakeIngredientRepository ingredientRepo;
late FakeProcedureRepository procedureRepo;
late FakeGetDishesByCategoryUseCase getDishes;
late FakeClipboardService clipboard;
late IngredientViewModel viewModel;
setUp(() {
ingredientRepo = FakeIngredientRepository();
procedureRepo = FakeProcedureRepository();
getDishes = FakeGetDishesByCategoryUseCase();
clipboard = FakeClipboardService();
viewModel = IngredientViewModel(
ingredientRepository: ingredientRepo,
procedureRepository: procedureRepo,
getDishesByCategoryUseCase: getDishes,
clipboardService: clipboard,
);
});
test('OnTapProcedure 를 받으면 선택 탭 인덱스가 1로 바뀐다', () {
viewModel.onAction(const IngredientAction.onTapProcedure());
expect(viewModel.state.selectedTabIndex, 1);
});
test('OnTapShareMenu 는 클립보드에 링크를 복사한다', () {
viewModel.onAction(const IngredientAction.onTapShareMenu('https://example.com'));
expect(clipboard.copiedTexts, ['https://example.com']);
});
}
When you need to wait for a stream update: pumpEventQueue() 를 await to flush microtasks and then assert the state.
viewModel.onAction(const IngredientAction.loadRecipe(1));
await pumpEventQueue();
expect(viewModel.state.recipe, isNotNull);
If you want to count how many times a listener was called:
int notifyCount = 0;
viewModel.addListener(() => notifyCount++);
viewModel.onAction(const IngredientAction.onTapIngredient());
expect(notifyCount, 1);
How to write Fakes
The interface to implement is already in lib/domain/repository/ . Place a simple in-memory implementation in the test-only directory test/fake/ .
class FakeRecipeRepository implements RecipeRepository {
FakeRecipeRepository({List<Recipe>? recipes}) : _recipes = recipes ?? [];
final List<Recipe> _recipes;
@override
Future<List<Recipe>> getRecipes() async => _recipes;
@override
Future<Recipe?> getRecipe(int id) async =>
_recipes.where((r) => r.id == id).firstOrNull;
}
class FakeBookmarkRepository implements BookmarkRepository {
FakeBookmarkRepository({Set<int>? initialIds}) : _ids = {...?initialIds} {
_controller.add(_ids);
}
final Set<int> _ids;
final _controller = StreamController<Set<int>>.broadcast();
@override
Stream<Set<int>> bookmarkIdsStream() => _controller.stream;
@override
Future<void> toggle(int id) async {
_ids.contains(id) ? _ids.remove(id) : _ids.add(id);
_controller.add({..._ids});
}
// 나머지 메서드는 동일 패턴
}
Principles:
- Return the "simplest correct answer" unrelated to real business logic.
- Provide a variable to simulate failure scenarios:
bool shouldReturnError = false;→if (shouldReturnError) return Result.error(...). BehaviorSubjectinstead ofStreamController.broadcast()+ manualadd()is sufficient (without depending on rxdart).
Widget tests — easy thanks to Screen separation
Screen 은 state 와 onAction only, so without a ViewModel you can directly pumpWidget .
void main() {
testWidgets('레시피가 있으면 IngredientRecipeCard 가 표시된다', (tester) async {
final state = IngredientState(
recipe: Recipe(id: 1, name: 'Pizza', /* ... */),
ingredients: const [],
procedures: const [],
);
await tester.pumpWidget(
MaterialApp(
home: IngredientScreen(
state: state,
onAction: (_) {},
onTapMenu: (_) {},
),
),
);
expect(find.text('Pizza'), findsOneWidget);
});
testWidgets('Procedure 탭을 누르면 onAction 으로 OnTapProcedure 가 전달된다', (tester) async {
IngredientAction? captured;
await tester.pumpWidget(
MaterialApp(
home: IngredientScreen(
state: IngredientState(recipe: /* ... */),
onAction: (a) => captured = a,
onTapMenu: (_) {},
),
),
);
await tester.tap(find.text('Procedure'));
expect(captured, isA<OnTapProcedure>());
});
}
Tips:
- Wrap the top level in
MaterialAppto provideDirectionality, theme, andMediaQuerydefaults. - Dialogs/snackbars/navigation are the Root's responsibility, so there is no need to verify them in Screen tests.
- Widgets that involve images/networks may
NetworkImagemay fail in the test environment. If needed,provideMockedNetworkImagescreate a helper, or branch the image part with a preview flag.
When to test the Root widget?
Root 는 getIt Because it obtains the real ViewModel through diSetup() , you need a utility that registers the Fake inintegration_test/. Screen tests + ViewModel unit tests alone catch most regressions. Root widgets are naturally handled with integration tests (
) which is natural.
setUp(() {
getIt.reset();
getIt.registerFactory<IngredientViewModel>(() => FakeIngredientViewModel());
});
Robot pattern — testing complex screens
When tests for 3 or more widgets accumulate on one screen, share the setup using the Robot pattern.
class IngredientRobot {
IngredientRobot(this.tester);
final WidgetTester tester;
Future<IngredientRobot> pump(IngredientState state, {ValueChanged<IngredientAction>? onAction}) async {
await tester.pumpWidget(MaterialApp(
home: IngredientScreen(
state: state,
onAction: onAction ?? (_) {},
onTapMenu: (_) {},
),
));
return this;
}
Future<IngredientRobot> tapProcedureTab() async {
await tester.tap(find.text('Procedure'));
await tester.pump();
return this;
}
IngredientRobot expectRecipeName(String name) {
expect(find.text(name), findsOneWidget);
return this;
}
}
In tests, use as a chain:
await IngredientRobot(tester)
.pump(state)
.then((r) => r.tapProcedureTab())
.then((r) => r.expectRecipeName('Pizza'));
What to test
- UseCase: Input/output is clear and side effects are rare, making it the most cost-effective. Write these first.
- ViewModel: User Action → state transition rules. Unit test in combination with a Fake Repository.
- Screen widget test: Rendering/input delivery. Cover complex conditional UI (empty, loading, error).
- Data layer: Mapping (
fromJson) For tricky spots, instead of goldens, simply useexpectto verify key fields. - Integration test: Real end-to-end scenarios (login → home → detail) are
integration_test/에.
checklist
- At least 1 unit test per new UseCase (1 each for success + failure paths)
- At least 1 assertion per each Action case of the ViewModel
- If the Screen has conditional branches, for each branch
testWidgets - Fake 는
test/fake/Share below -
dispose()If there is stream cancellation logic in , in the ViewModel testdispose()explicitly call to verify
Anti-pattern
- ❌ Real
getItRunning tests while sharing global state → creates test order dependency. If needed,getIt.reset()을 setUp 에. - ❌ In a Screen test, to verify navigation/snackbar, the entire
MaterialApp.routerlaunching → separate into a Root integration test. - ❌
await Future.delayed(Duration(seconds: 1))Same sleep-based synchronization →pumpEventQueue,tester.pump(...),expectLateruse . - ❌ Auto-generating all dependencies with a Mock library → small, explicit Fakes are easier to read and less misunderstood.
- ❌ To test private methods,
@visibleForTestingabuse → test only through the public interface (Action, State).