Loading...
Loading...
Flutter 프로젝트의 테스트 작성 패턴 — `flutter_test` 기반 단위 테스트와 위젯 테스트, `ChangeNotifier` ViewModel 검증, Fake Repository, `pumpWidget` + `ListenableBuilder`, 스트림 검증. "테스트 작성", "ViewModel 테스트", "widget test", "pumpWidget", "Fake Repository", "Mock", "단위 테스트" 같은 표현에 트리거합니다.
npx skill4agent add junsuk5/survival-flutter-skills flutter-testing| 관심사 | 라이브러리 |
|---|---|
| 단위/위젯 테스트 프레임워크 | |
| 의존성 대체 | Fake 직접 작성 (mock 라이브러리 지양) |
| 스트림 검증 | |
flutter_testmockitomocktailtest/
└─ presentation/
└─ ingredient/
└─ ingredient_screen_test.darttest/<layer>/<feature>/<file>_test.darttest/presentation/<feature>/<feature>_screen_test.dart// 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]);
});
}.firstexpectLater(stream, emitsInOrder([...]))ChangeNotifieraddListenernotifyListeners()viewModel.statevoid 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']);
});
}pumpEventQueue()awaitviewModel.onAction(const IngredientAction.loadRecipe(1));
await pumpEventQueue();
expect(viewModel.state.recipe, isNotNull);int notifyCount = 0;
viewModel.addListener(() => notifyCount++);
viewModel.onAction(const IngredientAction.onTapIngredient());
expect(notifyCount, 1);lib/domain/repository/test/fake/class FakeRecipeRepository implements RecipeRepository {
FakeRecipeRepository({List<Recipe>? recipes}) : _recipes = recipes ?? [];
final List<Recipe> _recipes;
Future<List<Recipe>> getRecipes() async => _recipes;
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();
Stream<Set<int>> bookmarkIdsStream() => _controller.stream;
Future<void> toggle(int id) async {
_ids.contains(id) ? _ids.remove(id) : _ids.add(id);
_controller.add({..._ids});
}
// 나머지 메서드는 동일 패턴
}bool shouldReturnError = false;if (shouldReturnError) return Result.error(...)BehaviorSubjectStreamController.broadcast()add()stateonActionpumpWidgetvoid 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>());
});
}MaterialAppDirectionalityMediaQueryNetworkImageprovideMockedNetworkImagesgetItdiSetup()integration_test/setUp(() {
getIt.reset();
getIt.registerFactory<IngredientViewModel>(() => FakeIngredientViewModel());
});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;
}
}await IngredientRobot(tester)
.pump(state)
.then((r) => r.tapProcedureTab())
.then((r) => r.expectRecipeName('Pizza'));fromJsonexpectintegration_test/testWidgetstest/fake/dispose()dispose()getItgetIt.reset()MaterialApp.routerawait Future.delayed(Duration(seconds: 1))pumpEventQueuetester.pump(...)expectLater@visibleForTesting