์๋ก ๋ง๋ ํ๋ฉด(๋ฒํผ, ๋ชฉ๋ก, ์
๋ ฅ์ฐฝ ๊ฐ์ ๊ฒ)์ด ๋์ ๋ณด์ด๋ ๋๋ก ์ ์๋ํ๋์ง ์๋์ผ๋ก ์ ๊ฒํด ์ฃผ๋ ๊ฒ์ฌ๊ด ์
๋๋ค.
์ฌ๋์ด ์ผ์ผ์ด ์ฑ์ ์ผ์ "์ด ๊ธ์๊ฐ ๋ณด์ด๋? ๋ฒํผ์ ๋๋ฅด๋ฉด ๋ฐ์ํ๋?" ํ์ธํ๋ ์ผ์, ์ฝ๋๋ก ๋์ ํด ์ค๋๋ค.
๋์ ํ๋ฉด์ ๋ํ ๊ฒ์ฌ ์ฝ๋ ํ์ผ์ ์ ํด์ง ์์น์ ๋ง๋ค์ด ์ค๋๋ค. ์๋ฅผ ๋ค์ด:
์ด ๊ฒ์ฌ๋ค์ ๊ธ์๊ฐ ๋ณด์ด๋์ง, ๋ฒํผ์ ๋๋ฅด๋ฉด ๋ฐ์ํ๋์ง, ๋ก๋ฉยท์๋ฃยท์ค๋ฅ ๊ฐ์ ์ฌ๋ฌ ์ํ๊ฐ ์ ๋๋ก ๋ํ๋๋์ง๋ฅผ ์๋์ผ๋ก ํ์ธํฉ๋๋ค.
โ๏ธ ์์ธ ์ต์
ยท์คํ ๋ช
์ธ (๊ฐ๋ฐ์ / AI ์์ด์ ํธ์ฉ) 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 invokedInvoked 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 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 # 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) ;
} ) ;
} ) ;
}
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 ) ;
} ) ;
} ) ;
}
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' ) ;
} ) ;
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 # 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 findsOneWidgetExactly 1 expect(find.text('ํ๊ธธ๋'), findsOneWidget)findsNothing0 items expect(find.text('์์'), findsNothing)findsWidgets1 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- 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 #