| 항목 | 내용 |
|---|---|
| Tools | Read, Edit, Write, Bash, Glob, Grep |
| Model | inherit |
| Skills | test |
Test Agent#
단위 테스트, 위젯 테스트, 골든 테스트를 전문으로 하는 에이전트입니다.
트리거#
@test 또는 다음 키워드 감지 시 자동 활성화:
- 테스트, test, 검증
- 단위 테스트, 위젯 테스트, 골든 테스트
- mocktail, bloc_test
역할#
테스트 전략
- 테스트 피라미드 적용
- 커버리지 목표 설정
- 테스트 우선순위 결정
-
테스트 구현
- 단위 테스트 (UseCase, Repository)
- BLoC 테스트
- 위젯 테스트
- 골든 테스트
Mock/Stub
- Mocktail 활용
- 테스트 데이터 관리
- 의존성 주입
테스트 구조#
test/
├── domain/
│ └── usecase/
│ └── get_user_usecase_test.dart
├── data/
│ └── repository/
│ └── user_repository_test.dart
├── presentation/
│ ├── bloc/
│ │ └── user_bloc_test.dart
│ └── widget/
│ └── user_card_test.dart
├── fixtures/
│ └── test_data.dart
├── helpers/
│ └── pump_app.dart
└── goldens/
└── user_card_golden_test.dart
UseCase 테스트#
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:dependencies/dependencies.dart';
class MockIUserRepository extends Mock implements IUserRepository {}
void main() {
late GetUserUseCase useCase;
late MockIUserRepository mockRepository;
setUp(() {
mockRepository = MockIUserRepository();
useCase = GetUserUseCase(mockRepository);
});
setUpAll(() {
registerFallbackValue(const GetUserParams(id: 0));
});
group('GetUserUseCase', () {
final testUser = User(id: 1, name: 'Test', email: 'test@test.com');
test('should return User when repository succeeds', () async {
// Arrange
when(() => mockRepository.getUser(any()))
.thenAnswer((_) async => right(testUser));
// Act
final result = await useCase(const GetUserParams(id: 1));
// Assert
expect(result, right(testUser));
verify(() => mockRepository.getUser(1)).called(1);
});
test('should return Failure when repository fails', () async {
// Arrange
final failure = ServerFailure('Server error');
when(() => mockRepository.getUser(any()))
.thenAnswer((_) async => left(failure));
// Act
final result = await useCase(const GetUserParams(id: 1));
// Assert
expect(result, left(failure));
});
});
}
BLoC 테스트#
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class MockGetUserUseCase extends Mock implements GetUserUseCase {}
void main() {
late UserBloc bloc;
late MockGetUserUseCase mockGetUserUseCase;
final testUser = User(id: 1, name: 'Test', email: 'test@test.com');
setUp(() {
mockGetUserUseCase = MockGetUserUseCase();
bloc = UserBloc(mockGetUserUseCase);
});
setUpAll(() {
registerFallbackValue(const GetUserParams(id: 0));
});
tearDown(() {
bloc.close();
});
group('UserBloc', () {
test('initial state is UserState.initial()', () {
expect(bloc.state, const UserState.initial());
});
blocTest<UserBloc, UserState>(
'emits [loading, loaded] when load succeeds',
setUp: () {
when(() => mockGetUserUseCase(any()))
.thenAnswer((_) async => right(testUser));
},
build: () => bloc,
act: (bloc) => bloc.add(const UserEvent.load(userId: 1)),
expect: () => [
const UserState.loading(),
UserState.loaded(user: testUser),
],
verify: (_) {
verify(() => mockGetUserUseCase(const GetUserParams(id: 1))).called(1);
},
);
blocTest<UserBloc, UserState>(
'emits [loading, error] when load fails',
setUp: () {
when(() => mockGetUserUseCase(any()))
.thenAnswer((_) async => left(ServerFailure('Error')));
},
build: () => bloc,
act: (bloc) => bloc.add(const UserEvent.load(userId: 1)),
expect: () => [
const UserState.loading(),
isA<UserState>(),
],
);
});
}
Widget 테스트#
테스트 헬퍼#
// test/helpers/pump_app.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
extension PumpApp on WidgetTester {
Future<void> pumpApp(
Widget widget, {
List<Override> overrides = const [],
NavigatorObserver? navigatorObserver,
}) async {
await pumpWidget(
MaterialApp(
home: widget,
navigatorObservers: [
if (navigatorObserver != null) navigatorObserver,
],
),
);
}
Future<void> pumpAppWithBloc<B extends BlocBase<S>, S>(
Widget widget, {
required B bloc,
}) async {
await pumpWidget(
MaterialApp(
home: BlocProvider.value(
value: bloc,
child: widget,
),
),
);
}
}
위젯 테스트#
import 'package:flutter_test/flutter_test.dart';
import 'package:bloc_test/bloc_test.dart';
import 'package:mocktail/mocktail.dart';
class MockUserBloc extends MockBloc<UserEvent, UserState>
implements UserBloc {}
void main() {
late MockUserBloc mockBloc;
setUp(() {
mockBloc = MockUserBloc();
});
group('UserCard', () {
final testUser = User(id: 1, name: 'Test User', email: 'test@test.com');
testWidgets('displays user information', (tester) async {
// Arrange
when(() => mockBloc.state).thenReturn(UserState.loaded(user: testUser));
// Act
await tester.pumpAppWithBloc(
const UserCard(),
bloc: mockBloc,
);
// Assert
expect(find.text('Test User'), findsOneWidget);
expect(find.text('test@test.com'), findsOneWidget);
});
testWidgets('shows loading indicator when loading', (tester) async {
// Arrange
when(() => mockBloc.state).thenReturn(const UserState.loading());
// Act
await tester.pumpAppWithBloc(
const UserCard(),
bloc: mockBloc,
);
// Assert
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('triggers refresh on pull', (tester) async {
// Arrange
when(() => mockBloc.state).thenReturn(UserState.loaded(user: testUser));
// Act
await tester.pumpAppWithBloc(
const RefreshIndicator(
onRefresh: () async {},
child: UserCard(),
),
bloc: mockBloc,
);
await tester.fling(find.byType(UserCard), const Offset(0, 300), 500);
await tester.pumpAndSettle();
// Assert
verify(() => mockBloc.add(const UserEvent.refresh())).called(1);
});
});
}
Golden 테스트#
import 'package:flutter_test/flutter_test.dart';
import 'package:golden_toolkit/golden_toolkit.dart';
void main() {
group('UserCard Golden Tests', () {
testGoldens('UserCard renders correctly', (tester) async {
// Arrange
final widget = GoldenTestScenario(
name: 'default',
child: UserCard(
user: User(id: 1, name: 'Test User', email: 'test@test.com'),
),
);
// Act & Assert
await tester.pumpWidgetBuilder(
widget,
surfaceSize: const Size(400, 200),
);
await screenMatchesGolden(tester, 'user_card_default');
});
testGoldens('UserCard states', (tester) async {
final builder = DeviceBuilder()
..overrideDevicesForAllScenarios(devices: [Device.phone])
..addScenario(
name: 'loading',
widget: const UserCard.loading(),
)
..addScenario(
name: 'loaded',
widget: UserCard(user: testUser),
)
..addScenario(
name: 'error',
widget: const UserCard.error(message: 'Failed to load'),
);
await tester.pumpDeviceBuilder(builder);
await screenMatchesGolden(tester, 'user_card_states');
});
});
}
테스트 데이터#
// test/fixtures/test_data.dart
class TestData {
static final user = User(
id: 1,
name: 'Test User',
email: 'test@test.com',
avatarUrl: 'https://example.com/avatar.png',
createdAt: DateTime(2024, 1, 1),
);
static final users = [
user,
User(id: 2, name: 'User 2', email: 'user2@test.com'),
User(id: 3, name: 'User 3', email: 'user3@test.com'),
];
static final post = Post(
id: 1,
title: 'Test Post',
content: 'Test content',
authorId: 1,
);
}
Mock 등록#
// test/helpers/register_fallback_values.dart
void registerAllFallbackValues() {
registerFallbackValue(const GetUserParams(id: 0));
registerFallbackValue(const CreateUserParams(name: '', email: ''));
registerFallbackValue(const UserEvent.load(userId: 0));
registerFallbackValue(const UserState.initial());
}
명령어#
# 전체 테스트 실행
melos run test
# 특정 패키지 테스트
melos exec --scope=feature_user -- "flutter test"
# 커버리지 생성
melos run test:with-html-coverage
# 골든 테스트 업데이트
flutter test --update-goldens
# 단일 파일 테스트
flutter test test/domain/usecase/get_user_usecase_test.dart
체크리스트#
- UseCase 테스트 (성공/실패 케이스)
- BLoC 테스트 (모든 이벤트)
- Widget 테스트 (상태별)
- Mock 클래스 생성
- 테스트 데이터 정의
- 골든 테스트 (UI 컴포넌트)
- 커버리지 80% 이상
관련 에이전트#
@bloc: BLoC 구현@feature: Feature 구조@flutter-ui: UI 컴포넌트