/cc-flutter:test widget — 화면이 제대로 보이는지 검사하는 도우미#
| 항목 | 내용 |
|---|---|
| 실행 명령 | /cc-flutter:test widget |
| 별칭 | /widget:test, /test:ui |
| 모델 | sonnet |
| 사용 도구 | Read, Edit, Write, Glob, Grep |
| 연계 스킬 | test |
한마디로#
새로 만든 화면(버튼, 목록, 입력창 같은 것)이 눈에 보이는 대로 잘 작동하는지 자동으로 점검해 주는 검사관입니다. 사람이 일일이 앱을 켜서 "이 글자가 보이나? 버튼을 누르면 반응하나?" 확인하던 일을, 코드로 대신 해 줍니다.
누가·언제 쓰나요#
- 화면(Widget)이나 페이지(Page)를 새로 만든 뒤, 제대로 그려지고 동작하는지 확인하고 싶은 개발자
-
/cc-flutter:test widget명령을 실행할 때 자동으로 동작합니다 (/widget:test,/test:ui로도 부를 수 있어요)
무엇을 해주나요#
대상 화면에 대한 검사 코드 파일을 정해진 위치에 만들어 줍니다. 예를 들어:
{기능}_page_test.dart— 페이지 화면 검사-
{feature}_card_test.dart,{feature}_list_item_test.dart— 카드·목록 같은 작은 부품 검사 {feature}_golden_test.dart— "정답 이미지"와 화면을 비교하는 검사 (선택 사항)
이 검사들은 글자가 보이는지, 버튼을 누르면 반응하는지, 로딩·완료·오류 같은 여러 상태가 제대로 나타나는지를 자동으로 확인합니다.
어떻게 쓰나요#
# 특정 화면에 대한 검사 만들기 (대상 위젯 이름 지정)
/cc-flutter:test widget target_widget=UserCard
# 어떤 기능에 속한 화면인지 함께 알려주기
/cc-flutter:test widget target_widget=HomePage feature_name=home
# " 정답 이미지 비교 " 검사까지 포함하기
/cc-flutter:test widget target_widget=UserCard include_golden=true
target_widget— 검사할 화면의 이름 (꼭 필요)feature_name— 그 화면이 속한 기능 이름 (선택)include_golden— 화면 모양을 이미지로 비교하는 검사 포함 여부 (선택, 기본값은 포함 안 함)
안에서 무슨 일이 벌어지나요#
- 검사용 가짜 환경(테스트 앱)을 띄우고, 검사 대상 화면을 그 위에 올립니다.
- 화면에 특정 글자·아이콘·버튼이 보이는지 찾아 확인합니다.
- 버튼 누르기, 글자 입력, 스크롤 같은 사용자 동작을 흉내 내고, 화면이 제대로 반응하는지 봅니다.
- 로딩 중 / 데이터 표시 / 오류 같은 여러 상태를 각각 만들어 보고, 상황마다 올바른 화면이 나오는지 점검합니다.
- (선택) "정답 이미지"와 실제 화면을 픽셀 단위로 비교해, 디자인이 바뀌지 않았는지 확인합니다.
⚙️ 상세 옵션·실행 명세 (개발자 / AI 에이전트용)
Role#
Tests Widget rendering and interactions.
- Uses WidgetTester
- pump, pumpAndSettle Pattern
- find.byType, find.text, find.byKey matchers
- Golden Test (Optional)
Activation Conditions#
/cc-flutter:test widgetActivated when command is invoked- Invoked when writing Widget and Page UI tests
Parameters#
| Parameter | Required | Description |
|---|---|---|
target_widget | ✅ | Test target Widget Class name |
feature_name | ❌ | Feature module name |
include_golden | ❌ | Golden Test Includes Whether (default: false) |
Test File Structure#
feature/{module_type}/{feature_name}/test/
├── src/
│ ├── widget/
│ │ ├── page/
│ │ │ └── {feature}_page_test.dart
│ │ └── component/
│ │ ├── {feature}_card_test.dart
│ │ └── {feature}_list_item_test.dart
│ ├── golden/
│ │ └── {feature}_golden_test.dart
│ └── fixture/
│ ├── {feature}_fixture.dart
│ └── test_app_wrapper.dart
└── {feature}_test.dart # Test entry pointImport Order (Required)#
// 1. Flutter test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
// 2. BlocSignal (when needed)
import 'package:bloc_signals_flutter/bloc_signals_flutter.dart';
// 3. Mock package
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
// 4. Test target
import 'package:{feature}/src/presentation/page/{feature}_page.dart';
import 'package:{feature}/src/presentation/bloc/{feature}_bloc.dart';
// 5. Generated files
import '{feature}_page_test.mocks.dart';
Core Patterns#
1. Default Widget Test#
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:feature_home/src/presentation/widget/user_card.dart';
void main() {
group('UserCard', () {
testWidgets('renders user name and email', (tester) async {
// Arrange
const user = User(id: 1, name: '홍길동', email: 'hong@example.com');
// Act
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: UserCard(user: user),
),
),
);
// Assert
expect(find.text('홍길동'), findsOneWidget);
expect(find.text('hong@example.com'), findsOneWidget);
});
testWidgets('calls onTap when tapped', (tester) async {
// Arrange
var tapped = false;
const user = User(id: 1, name: '홍길동', email: 'hong@example.com');
// Act
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: UserCard(
user: user,
onTap: () => tapped = true,
),
),
),
);
await tester.tap(find.byType(UserCard));
await tester.pump();
// Assert
expect(tapped, isTrue);
});
});
}
2. BlocSignal-integrated Widget Test#
⚠️ Do not mock the container. In
bloc_signals,bloc.stateis aReadonlySignal<S>and there is nostreamgetter —BlocSignalBuildersubscribes to the signal, so a mock returning a plain state value never drives a rebuild.bloc_signals_testalso ships noMockBloc/whenListenequivalents.Provide a real container seeded through its constructor instead. Containers are cheap and synchronous, so this is both simpler and more faithful than stubbing.
import 'package:bloc_signals_flutter/bloc_signals_flutter.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:feature_home/src/presentation/bloc/home_bloc.dart';
import 'package:feature_home/src/presentation/page/home_page.dart';
import 'home_page_test.mocks.dart';
@GenerateNiceMocks([MockSpec<GetUserUseCase>()])
void main() {
late MockGetUserUseCase mockGetUserUseCase;
setUp(() {
mockGetUserUseCase = MockGetUserUseCase();
});
// Seed the real container into the state under test.
Widget buildTestWidget(HomeState initialState) {
final bloc = HomeBloc(mockGetUserUseCase, initialState: initialState);
addTearDown(bloc.close);
return MaterialApp(
home: BlocSignalProvider<HomeBloc>.value(
value: bloc,
child: const HomePage(),
),
);
}
group('HomePage', () {
testWidgets('shows loading indicator when state is HomeLoading',
(tester) async {
// Act
await tester.pumpWidget(buildTestWidget(const HomeLoading()));
// Assert
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('shows user data when state is HomeLoaded', (tester) async {
// Arrange
const user = User(id: 1, name: '홍길동', email: 'hong@example.com');
// Act
await tester.pumpWidget(buildTestWidget(const HomeLoaded(user: user)));
// Assert
expect(find.text('홍길동'), findsOneWidget);
expect(find.text('hong@example.com'), findsOneWidget);
});
testWidgets('shows error message when state is HomeError', (tester) async {
// Act
await tester.pumpWidget(buildTestWidget(
const HomeError(failure: ServerFailure(message: '서버 오류')),
));
// Assert
expect(find.text('서버 오류'), findsOneWidget);
});
testWidgets('rebuilds when the container emits', (tester) async {
final bloc = HomeBloc(mockGetUserUseCase);
addTearDown(bloc.close);
when(mockGetUserUseCase(any))
.thenAnswer((_) async => const Right(User(id: 1, name: '홍길동')));
await tester.pumpWidget(MaterialApp(
home: BlocSignalProvider<HomeBloc>.value(
value: bloc,
child: const HomePage(),
),
));
bloc.add(const HomeEvent.loadUser(id: 1));
await tester.pumpAndSettle(); // state is sync, but the frame still must render
expect(find.text('홍길동'), findsOneWidget);
});
});
}
.valuedoes not close the container — the test owns it, henceaddTearDown(bloc.close). When the widget under test creates its own container viaBlocSignalProvider(create:), assert that removing it from the tree closes it.
3. Form Input Test#
testWidgets('validates email input', (tester) async {
// Arrange
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: LoginForm(),
),
),
);
// Act - 유효하지 않은 이메일 입력
await tester.enterText(
find.byKey(const Key('email_field')),
'invalid-email',
);
await tester.tap(find.byKey(const Key('submit_button')));
await tester.pumpAndSettle();
// Assert
expect(find.text('올바른 이메일 형식이 아닙니다'), findsOneWidget);
});
testWidgets('submits form with valid data', (tester) async {
// Arrange
var submitted = false;
String? submittedEmail;
String? submittedPassword;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: LoginForm(
onSubmit: (email, password) {
submitted = true;
submittedEmail = email;
submittedPassword = password;
},
),
),
),
);
// Act
await tester.enterText(
find.byKey(const Key('email_field')),
'test@example.com',
);
await tester.enterText(
find.byKey(const Key('password_field')),
'password123',
);
await tester.tap(find.byKey(const Key('submit_button')));
await tester.pumpAndSettle();
// Assert
expect(submitted, isTrue);
expect(submittedEmail, 'test@example.com');
expect(submittedPassword, 'password123');
});
4. List Scroll Test#
testWidgets('loads more items when scrolled to bottom', (tester) async {
// Arrange
final bloc = HomeBloc(
mockGetUserUseCase,
initialState: HomeLoaded(
users: List.generate(20, (i) => User(id: i, name: 'User $i')),
),
);
addTearDown(bloc.close);
await tester.pumpWidget(buildTestWidgetWith(bloc));
// Act - 스크롤을 맨 아래로
await tester.drag(
find.byType(ListView),
const Offset(0, -500),
);
await tester.pumpAndSettle();
// Assert
// 이벤트 발생 후 상태가 동기로 반영됨
expect(bloc.stateValue, isA<HomeLoaded>().having(
(s) => s.isLoadingMore, 'isLoadingMore', isTrue,
));
});
testWidgets('shows all list items', (tester) async {
// Arrange
final users = [
const User(id: 1, name: '홍길동'),
const User(id: 2, name: '김철수'),
const User(id: 3, name: '이영희'),
];
await tester.pumpWidget(buildTestWidget(HomeLoaded(users: users)));
// Assert
expect(find.byType(UserListItem), findsNWidgets(3));
expect(find.text('홍길동'), findsOneWidget);
expect(find.text('김철수'), findsOneWidget);
expect(find.text('이영희'), findsOneWidget);
});
5. Dialog/Snackbar Test#
testWidgets('shows confirmation dialog on delete', (tester) async {
// Arrange
await tester.pumpWidget(buildTestWidget());
// Act
await tester.tap(find.byKey(const Key('delete_button')));
await tester.pumpAndSettle();
// Assert
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text('삭제하시겠습니까?'), findsOneWidget);
expect(find.text('확인'), findsOneWidget);
expect(find.text('취소'), findsOneWidget);
});
testWidgets('shows snackbar after successful action', (tester) async {
// Arrange — 실제 컨테이너를 만들고, 마운트 후 상태를 전이시킨다.
// BlocSignalListener 는 초기 상태를 흘려보내므로, 마운트 시점에
// 이미 성공 상태이면 스낵바가 뜨지 않는다.
final bloc = HomeBloc(mockGetUserUseCase, initialState: const HomeLoaded(user: tUser));
addTearDown(bloc.close);
await tester.pumpWidget(MaterialApp(
home: BlocSignalProvider<HomeBloc>.value(
value: bloc,
child: const HomePage(),
),
));
// Act
bloc.add(const HomeEvent.save());
await tester.pumpAndSettle();
// Assert
expect(find.byType(SnackBar), findsOneWidget);
expect(find.text('저장되었습니다'), findsOneWidget);
});
6. Navigation Test#
testWidgets('navigates to detail page on item tap', (tester) async {
// Arrange
await tester.pumpWidget(
MaterialApp(
routes: {
'/': (_) => const HomePage(),
'/detail': (_) => const DetailPage(),
},
),
);
// Act
await tester.tap(find.byType(UserCard).first);
await tester.pumpAndSettle();
// Assert
expect(find.byType(DetailPage), findsOneWidget);
expect(find.byType(HomePage), findsNothing);
});
testWidgets('pops with result when confirmed', (tester) async {
// Arrange
Object? result;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => ElevatedButton(
onPressed: () async {
result = await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const ConfirmDialog()),
);
},
child: const Text('Open'),
),
),
),
);
// Act
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
await tester.tap(find.text('확인'));
await tester.pumpAndSettle();
// Assert
expect(result, isTrue);
});
7. TestApp Wrapper Pattern#
/// 테스트용 앱 래퍼
///
/// 공통 설정(테마, 로케일, 의존성)을 포함합니다.
class TestAppWrapper extends StatelessWidget {
const TestAppWrapper({
required this.child,
this.locale = const Locale('ko'),
this.themeMode = ThemeMode.light,
super.key,
});
final Widget child;
final Locale locale;
final ThemeMode themeMode;
@override
Widget build(BuildContext context) {
return MaterialApp(
locale: locale,
themeMode: themeMode,
theme: AppTheme.light,
darkTheme: AppTheme.dark,
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('ko'), Locale('en')],
home: Scaffold(body: child),
);
}
}
// Usage example
testWidgets('renders correctly in dark mode', (tester) async {
await tester.pumpWidget(
TestAppWrapper(
themeMode: ThemeMode.dark,
child: const UserCard(user: tUser),
),
);
// 테스트 로직...
});
8. Golden Test#
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:golden_toolkit/golden_toolkit.dart';
import '../fixture/test_app_wrapper.dart';
void main() {
group('UserCard Golden Tests', () {
testGoldens('UserCard matches golden file', (tester) async {
// Arrange
const user = User(id: 1, name: '홍길동', email: 'hong@example.com');
final builder = GoldenBuilder.column()
..addScenario(
'Default',
const UserCard(user: user),
)
..addScenario(
'With avatar',
const UserCard(user: user, showAvatar: true),
)
..addScenario(
'Compact',
const UserCard(user: user, compact: true),
);
// Act & Assert
await tester.pumpWidgetBuilder(
builder.build(),
wrapper: materialAppWrapper(theme: AppTheme.light),
);
await screenMatchesGolden(tester, 'user_card');
});
testGoldens('UserCard responsive variants', (tester) async {
const user = User(id: 1, name: '홍길동', email: 'hong@example.com');
await tester.pumpWidgetBuilder(
const UserCard(user: user),
wrapper: materialAppWrapper(theme: AppTheme.light),
);
await multiScreenGolden(
tester,
'user_card_responsive',
devices: [
Device.phone,
Device.iphone11,
Device.tabletLandscape,
],
);
});
});
}
find Matcher Summary#
| Matcher | Purpose | Example |
|---|---|---|
find.text() | Text search | find.text('홍길동') |
find.byType() | Search by type | find.byType(UserCard) |
find.byKey() | Search by Key | find.byKey(Key('email')) |
find.byIcon() | Icon search | find.byIcon(Icons.delete) |
find.byWidget() | Widget instance search | find.byWidget(myWidget) |
find.descendant() | Descendant search | find.descendant(of: ..., matching: ...) |
find.ancestor() | Ancestor search | find.ancestor(of: ..., matching: ...) |
find.byWidgetPredicate() | Search by predicate | find.byWidgetPredicate((w) => ...) |
expect Matcher Summary#
| Matcher | Purpose | Example |
|---|---|---|
findsOneWidget | Exactly 1 | expect(find.text('홍길동'), findsOneWidget) |
findsNothing | 0 items | expect(find.text('없음'), findsNothing) |
findsWidgets | 1 or more | expect(find.byType(Card), findsWidgets) |
findsNWidgets(n) | Exactly n | expect(find.byType(Item), findsNWidgets(3)) |
findsAtLeast(n) | At least n | expect(find.byType(Item), findsAtLeast(2)) |
pump Method Summary#
| Method | Purpose | Example |
|---|---|---|
pump() | Single frame render | await tester.pump() |
pump(duration) | Advance by specified duration | await tester.pump(Duration(seconds: 1)) |
pumpAndSettle() | Wait until animations complete | await tester.pumpAndSettle() |
pumpWidget() | Render widget | await tester.pumpWidget(widget) |
Build Commands#
# Widget 테스트만 실행
flutter test test/src/widget/
# Golden 테스트 업데이트
flutter test --update-goldens test/src/golden/
# 특정 위젯 테스트 실행
flutter test test/src/widget/page/home_page_test.dart
# Test with coverage
melos run test:with-html-coverageReference Files#
feature/application/home/test/src/widget/page/home_page_test.dart
feature/application/home/test/src/widget/component/user_card_test.dart
feature/common/auth/test/src/widget/login_page_test.dartChecklist#
- flutter_test Package import
- @GenerateNiceMocks Annotation (UseCase Mock — not the container)
- TestAppWrapper or MaterialApp wrapper usage
- Provide a real container seeded via constructor
initialState(never mockstate/stream) addTearDown(bloc.close)when the test owns the container (.value)- Call pump or pumpAndSettle after pumpWidget
- Search widgets with find matchers
- Verify results with expect
- Interaction tests (tap, drag, enterText)
- Test various states (loading, loaded, error)
- Error message display test