LogoSkills

widget-test-agent

Widget 렌더링 테스트 전문가입니다. WidgetTester, pump 패턴, find matcher 작업 시 호출됩니다.

/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 — 화면 모양을 이미지로 비교하는 검사 포함 여부 (선택, 기본값은 포함 안 함)

안에서 무슨 일이 벌어지나요#

  1. 검사용 가짜 환경(테스트 앱)을 띄우고, 검사 대상 화면을 그 위에 올립니다.
  2. 화면에 특정 글자·아이콘·버튼이 보이는지 찾아 확인합니다.
  3. 버튼 누르기, 글자 입력, 스크롤 같은 사용자 동작을 흉내 내고, 화면이 제대로 반응하는지 봅니다.
  4. 로딩 중 / 데이터 표시 / 오류 같은 여러 상태를 각각 만들어 보고, 상황마다 올바른 화면이 나오는지 점검합니다.
  5. (선택) "정답 이미지"와 실제 화면을 픽셀 단위로 비교해, 디자인이 바뀌지 않았는지 확인합니다.

⚙️ 상세 옵션·실행 명세 (개발자 / 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 widget Activated when command is invoked
  • Invoked when writing Widget and Page UI tests

Parameters#

ParameterRequiredDescription
target_widgetTest target Widget Class name
feature_nameFeature module name
include_goldenGolden 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 point

Import 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.state is a ReadonlySignal<S> and there is no stream getter — BlocSignalBuilder subscribes to the signal, so a mock returning a plain state value never drives a rebuild. bloc_signals_test also ships no MockBloc/whenListen equivalents.

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

.value does not close the container — the test owns it, hence addTearDown(bloc.close). When the widget under test creates its own container via BlocSignalProvider(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#

MatcherPurposeExample
find.text()Text searchfind.text('홍길동')
find.byType()Search by typefind.byType(UserCard)
find.byKey()Search by Keyfind.byKey(Key('email'))
find.byIcon()Icon searchfind.byIcon(Icons.delete)
find.byWidget()Widget instance searchfind.byWidget(myWidget)
find.descendant()Descendant searchfind.descendant(of: ..., matching: ...)
find.ancestor()Ancestor searchfind.ancestor(of: ..., matching: ...)
find.byWidgetPredicate()Search by predicatefind.byWidgetPredicate((w) => ...)

expect Matcher Summary#

MatcherPurposeExample
findsOneWidgetExactly 1expect(find.text('홍길동'), findsOneWidget)
findsNothing0 itemsexpect(find.text('없음'), findsNothing)
findsWidgets1 or moreexpect(find.byType(Card), findsWidgets)
findsNWidgets(n)Exactly nexpect(find.byType(Item), findsNWidgets(3))
findsAtLeast(n)At least nexpect(find.byType(Item), findsAtLeast(2))

pump Method Summary#

MethodPurposeExample
pump()Single frame renderawait tester.pump()
pump(duration)Advance by specified durationawait tester.pump(Duration(seconds: 1))
pumpAndSettle()Wait until animations completeawait tester.pumpAndSettle()
pumpWidget()Render widgetawait 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-coverage

Reference 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.dart

Checklist#

  • 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 mock state/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