LogoSkills

widget-test-agent

Widget rendering test specialist. Invoked for WidgetTester, pump patterns, and find matchers

ํ•ญ๋ชฉ๋‚ด์šฉ
Invoke/test:widget
Aliases/widget:test, /test:ui
ToolsRead, Edit, Write, Glob, Grep
Modelsonnet
Skillstest

Widget Test Agent#

Specialized agent for Widget rendering testing


Role#

Tests Widget rendering and interactions.

  • Uses WidgetTester
  • pump, pumpAndSettle Pattern
  • find.byType, find.text, find.byKey matchers
  • Golden Test (Optional)

Activation Conditions#

  • /test:widget Activated when command is invoked
  • Invoked when writing Widget and Page UI tests

Parameters#

ParameterRequiredDescription
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 point

Import Order (Required)#

// 1. Flutter test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

// 2. BLoC test (when needed)
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_bloc/flutter_bloc.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. BLoC-integrated Widget Test#

import 'package:bloc_test/bloc_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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<HomeBloC>()])
void main() {
  late MockHomeBloC mockBloC;

  setUp(() {
    mockBloC = MockHomeBloC();
  });

  Widget buildTestWidget() {
    return MaterialApp(
      home: BlocProvider<HomeBloC>.value(
        value: mockBloC,
        child: const HomePage(),
      ),
    );
  }

  group('HomePage', () {
    testWidgets('shows loading indicator when state is HomeLoading',
        (tester) async {
      // Arrange
      when(mockBloC.state).thenReturn(const HomeLoading());
      when(mockBloC.stream).thenAnswer((_) => const Stream.empty());

      // Act
      await tester.pumpWidget(buildTestWidget());

      // 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');
      when(mockBloC.state).thenReturn(const HomeLoaded(user: user));
      when(mockBloC.stream).thenAnswer((_) => const Stream.empty());

      // Act
      await tester.pumpWidget(buildTestWidget());

      // Assert
      expect(find.text('ํ™๊ธธ๋™'), findsOneWidget);
      expect(find.text('hong@example.com'), findsOneWidget);
    });

    testWidgets('shows error message when state is HomeError', (tester) async {
      // Arrange
      when(mockBloC.state).thenReturn(
        const HomeError(failure: ServerFailure(message: '์„œ๋ฒ„ ์˜ค๋ฅ˜')),
      );
      when(mockBloC.stream).thenAnswer((_) => const Stream.empty());

      // Act
      await tester.pumpWidget(buildTestWidget());

      // Assert
      expect(find.text('์„œ๋ฒ„ ์˜ค๋ฅ˜'), findsOneWidget);
    });

    testWidgets('adds LoadUser event on init', (tester) async {
      // Arrange
      when(mockBloC.state).thenReturn(const HomeInitial());
      when(mockBloC.stream).thenAnswer((_) => const Stream.empty());

      // Act
      await tester.pumpWidget(buildTestWidget());

      // Assert
      verify(mockBloC.add(const HomeEvent.loadUser(id: 1))).called(1);
    });
  });
}

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
  when(mockBloC.state).thenReturn(
    HomeLoaded(users: List.generate(20, (i) => User(id: i, name: 'User $i'))),
  );
  when(mockBloC.stream).thenAnswer((_) => const Stream.empty());

  await tester.pumpWidget(buildTestWidget());

  // Act - ์Šคํฌ๋กค์„ ๋งจ ์•„๋ž˜๋กœ
  await tester.drag(
    find.byType(ListView),
    const Offset(0, -500),
  );
  await tester.pumpAndSettle();

  // Assert
  verify(mockBloC.add(const HomeEvent.loadMore())).called(1);
});

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: '์ด์˜ํฌ'),
  ];
  when(mockBloC.state).thenReturn(HomeLoaded(users: users));
  when(mockBloC.stream).thenAnswer((_) => const Stream.empty());

  await tester.pumpWidget(buildTestWidget());

  // 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
  when(mockBloC.state).thenReturn(const HomeLoaded(user: tUser));
  when(mockBloC.stream).thenAnswer(
    (_) => Stream.value(const HomeActionSuccess(message: '์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค')),
  );

  await tester.pumpWidget(buildTestWidget());
  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 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#

MatcherPurposeExample
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#

MethodPurposeExample
pump()Single frame renderawait 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-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 (BLoC Mock)
  • TestAppWrapper or MaterialApp wrapper usage
  • Mock both BLoC.state and BLoC.stream
  • 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