flutter-testing
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseFlutter 테스트
Flutter测试
스택
技术栈
| 관심사 | 라이브러리 |
|---|---|
| 단위/위젯 테스트 프레임워크 | |
| 의존성 대체 | Fake 직접 작성 (mock 라이브러리 지양) |
| 스트림 검증 | |
프로젝트에는 외의 테스트 의존성이 없다. / 을 도입하기 전에 Fake 로 먼저 작성해 본다 — 대부분 Fake 만으로 충분하고 유지보수가 쉽다.
flutter_testmockitomocktail| 关注点 | 库 |
|---|---|
| 单元/Widget测试框架 | |
| 依赖替换 | 自行编写Fake(不推荐使用mock库) |
| 流验证 | |
项目中除外无其他测试依赖。在引入/之前,建议先尝试编写Fake——大多数情况下仅用Fake就足够,且易于维护。
flutter_testmockitomocktail디렉터리
目录结构
테스트는 프로덕션 코드와 거울 구조로 둔다.
test/
└─ presentation/
└─ ingredient/
└─ ingredient_screen_test.dart- Unit test:
test/<layer>/<feature>/<file>_test.dart - Widget test:
test/presentation/<feature>/<feature>_screen_test.dart
测试目录与生产代码保持镜像结构。
test/
└─ presentation/
└─ ingredient/
└─ ingredient_screen_test.dart- 单元测试:
test/<layer>/<feature>/<file>_test.dart - Widget测试:
test/presentation/<feature>/<feature>_screen_test.dart
단위 테스트 — UseCase
单元测试 — UseCase
가장 쉬운 출발점. Repository 를 Fake 로 바꿔 끼우고 입·출력만 확인한다.
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([...]))最容易入手的测试类型。将Repository替换为Fake,仅验证输入输出即可。
dart
// test/domain/use_case/get_saved_recipes_use_case_test.dart
void main() {
test('仅将书签ID集合对应的Recipe通过流输出', () 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([...]))단위 테스트 — ChangeNotifier ViewModel
单元测试 — ChangeNotifier ViewModel
ChangeNotifieraddListenernotifyListeners()viewModel.statedart
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']);
});
}스트림 업데이트를 기다려야 할 때: 를 해서 마이크로태스크를 비운 뒤 상태를 단언한다.
pumpEventQueue()awaitdart
viewModel.onAction(const IngredientAction.loadRecipe(1));
await pumpEventQueue();
expect(viewModel.state.recipe, isNotNull);리스너가 여러 번 불렸는지 세고 싶으면:
dart
int notifyCount = 0;
viewModel.addListener(() => notifyCount++);
viewModel.onAction(const IngredientAction.onTapIngredient());
expect(notifyCount, 1);ChangeNotifieraddListenernotifyListeners()viewModel.statedart
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']);
});
}需要等待流更新时:清空微任务后,再断言状态。
await pumpEventQueue()dart
viewModel.onAction(const IngredientAction.loadRecipe(1));
await pumpEventQueue();
expect(viewModel.state.recipe, isNotNull);想要统计监听器被调用次数时:
dart
int notifyCount = 0;
viewModel.addListener(() => notifyCount++);
viewModel.onAction(const IngredientAction.onTapIngredient());
expect(notifyCount, 1);Fake 작성법
Fake编写方法
구현할 인터페이스는 에 이미 있다. 테스트 전용 디렉터리 에 간단한 인메모리 구현을 둔다.
lib/domain/repository/test/fake/dart
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;
}dart
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(...) - 대신
BehaviorSubject+ 수동StreamController.broadcast()로 충분하다 (rxdart 의존 없이).add()
待实现的接口已存在于中。在测试专用目录下编写简单的内存实现。
lib/domain/repository/test/fake/dart
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;
}dart
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(...) - 使用+ 手动
StreamController.broadcast()即可满足需求(无需依赖rxdart),无需使用add()。BehaviorSubject
위젯 테스트 — Screen 분리 덕분에 쉽다
Widget测试 — 得益于Screen分离,测试更简单
Screen 은 와 만 받으므로 ViewModel 없이 직접 할 수 있다.
stateonActionpumpWidgetdart
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>());
});
}요령:
- 최상위를 으로 감싸
MaterialApp, 테마,Directionality기본값을 제공한다.MediaQuery - 다이얼로그/스낵바/네비게이션은 Root 책임이라 Screen 테스트에서는 검증할 필요가 없다.
- 이미지·네트워크가 들어간 위젯은 가 테스트 환경에서 실패할 수 있다. 필요하면
NetworkImage헬퍼를 만들거나, 이미지 부분을 프리뷰 플래그로 분기한다.provideMockedNetworkImages
Screen仅接收和,因此无需ViewModel即可直接通过测试。
stateonActionpumpWidgetdart
void main() {
testWidgets('存在Recipe时,显示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>());
});
}技巧:
- 用包裹最外层,提供
MaterialApp、主题、Directionality默认值。MediaQuery - 对话框/ Snackbar/导航属于Root的职责,无需在Screen测试中验证。
- 包含图片/网络的Widget在测试环境中可能失败。必要时可创建工具函数,或通过预览标记对图片部分进行分支处理。
provideMockedNetworkImages
Root 위젯 테스트는 언제?
何时进行Root Widget测试?
Root 는 을 통해 실제 ViewModel 을 가져오므로 에 Fake 를 등록해 주는 유틸이 필요하다. Screen 테스트 + ViewModel 단위 테스트만으로도 대부분의 회귀가 잡힌다. Root 위젯은 통합 테스트()로 다룰 때가 자연스럽다.
getItdiSetup()integration_test/Root 를 꼭 테스트해야 한다면:
dart
setUp(() {
getIt.reset();
getIt.registerFactory<IngredientViewModel>(() => FakeIngredientViewModel());
});Root通过获取实际ViewModel,因此需要一个在中注册Fake的工具函数。仅通过Screen测试+ViewModel单元测试即可覆盖大部分回归场景。Root Widget更适合通过集成测试()处理。
getItdiSetup()integration_test/若必须测试Root Widget:
dart
setUp(() {
getIt.reset();
getIt.registerFactory<IngredientViewModel>(() => FakeIngredientViewModel());
});Robot 패턴 — 복잡한 화면 테스트
Robot模式 — 复杂页面测试
한 화면에 3개 이상의 위젯 테스트가 누적되면 Robot 패턴으로 setup 을 공유한다.
dart
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;
}
}테스트에서는 체인으로 사용:
dart
await IngredientRobot(tester)
.pump(state)
.then((r) => r.tapProcedureTab())
.then((r) => r.expectRecipeName('Pizza'));当一个页面的Widget测试累计超过3个时,可使用Robot模式共享初始化逻辑。
dart
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;
}
}测试中可链式调用:
dart
await IngredientRobot(tester)
.pump(state)
.then((r) => r.tapProcedureTab())
.then((r) => r.expectRecipeName('Pizza'));무엇을 테스트할 것인가
应该测试什么?
- UseCase: 입·출력이 명확하고 사이드이펙트가 드물어 가장 가성비가 높다. 우선 작성.
- ViewModel: 사용자 Action → 상태 변환 규칙. Fake Repository 와 결합해 단위 테스트.
- Screen 위젯 테스트: 렌더링/입력 전달. 복잡한 조건부 UI(비어있음, 로딩, 에러) 를 커버.
- Data 레이어: 매핑() 이 까다로운 곳은 goldens 대신 단순
fromJson로 키 필드 검증.expect - 통합 테스트: 실제 end-to-end 시나리오 (로그인 → 홈 → 상세) 는 에.
integration_test/
- UseCase:输入输出明确,副作用少,性价比最高,优先编写。
- ViewModel:用户Action → 状态转换规则。结合Fake Repository进行单元测试。
- Screen Widget测试:渲染/输入传递。覆盖复杂条件UI(空状态、加载中、错误)。
- Data层:映射()复杂的部分,无需使用golden测试,仅通过简单
fromJson验证关键字段即可。expect - 集成测试:实际端到端场景(登录 → 首页 → 详情)放在中。
integration_test/
체크리스트
检查清单
- 새 UseCase 마다 단위 테스트 1개 이상 (성공 + 실패 경로 각 1개)
- ViewModel 의 각 Action 케이스별 최소 1개 단언
- Screen 에 조건부 분기가 있다면 각 분기마다
testWidgets - Fake 는 아래 공유
test/fake/ - 에서 스트림 취소 로직이 있다면 ViewModel 테스트에서
dispose()를 명시적으로 호출해 검증dispose()
- 每个新UseCase至少编写1个单元测试(成功+失败路径各1个)
- ViewModel的每个Action场景至少有1个断言
- Screen存在条件分支时,为每个分支编写
testWidgets - Fake放在下共享
test/fake/ - 若中有流取消逻辑,需在ViewModel测试中显式调用
dispose()进行验证dispose()
안티 패턴
反模式
- ❌ 실제 전역 상태를 공유한 채 테스트 실행 → 테스트 순서 의존성이 생긴다. 필요하면
getIt을 setUp 에.getIt.reset() - ❌ Screen 테스트에서 내비게이션/스낵바를 검증하려고 전체 를 띄우기 → Root 통합 테스트로 분리.
MaterialApp.router - ❌ 같은 sleep 기반 동기화 →
await Future.delayed(Duration(seconds: 1)),pumpEventQueue,tester.pump(...)를 써라.expectLater - ❌ Mock 라이브러리로 모든 의존성을 자동 생성 → 작고 명시적인 Fake 가 읽기 쉽고 오해가 적다.
- ❌ Private 메서드 테스트를 위해 남용 → public 인터페이스(Action, State) 로만 테스트한다.
@visibleForTesting
- ❌ 共享实际全局状态执行测试 → 会导致测试顺序依赖。必要时在setUp中调用
getIt。getIt.reset() - ❌ 为了验证导航/Snackbar,在Screen测试中启动完整→ 移至Root集成测试。
MaterialApp.router - ❌ 使用这类基于睡眠的同步方式 → 使用
await Future.delayed(Duration(seconds: 1)),pumpEventQueue,tester.pump(...)。expectLater - ❌ 用Mock库自动生成所有依赖 → 小巧且明确的Fake更易读,不易产生误解。
- ❌ 为测试私有方法而滥用→ 仅通过公共接口(Action, State)进行测试。
@visibleForTesting