| ํญ๋ชฉ | ๋ด์ฉ |
|---|---|
| Invoke | /test:bloc |
| Aliases | /bloc:test, /test:state |
| Tools | Read, Edit, Write, Glob, Grep |
| Model | sonnet |
| Skills | test |
/test:bloc โ ํ๋ฉด ์ํ ๋ณํ ๊ฒ์ฌ๊ธฐ#
ํ๋ง๋๋ก#
์ฑ ํ๋ฉด์ด "๋ก๋ฉ ์ค โ ์๋ฃ โ ์ค๋ฅ" ๊ฐ์ ์ํ๋ฅผ ์ฌ๋ฐ๋ฅธ ์์๋ก ๊ฑฐ์น๋์ง ์๋์ผ๋ก ์ ๊ฒํด ์ฃผ๋ ๋๊ตฌ์ ๋๋ค. ์ ํธ๋ฑ์ด ๋นจ๊ฐยท๋ ธ๋ยท์ด๋ก์ ์ ํด์ง ์์๋๋ก ์ผ๋์ง ํ์ธํ๋ ๊ฒ์ฌ์ ๋น์ทํฉ๋๋ค.
๋๊ฐยท์ธ์ ์ฐ๋์#
- ์ฑ์ ํ๋ฉด ์ํ๋ฅผ ๊ด๋ฆฌํ๋ ์ฝ๋(BLoC, Cubit)๋ฅผ ๋ง๋ ๋ค, ๊ทธ๊ฒ์ด ์๋๋๋ก ๋์ํ๋์ง ํ์ธํ๊ณ ์ถ์ ๊ฐ๋ฐ์
-
/test:bloc๋ช ๋ น์ ์คํํ ๋ ์๋์ผ๋ก ์๋ํฉ๋๋ค. (๋ณ์นญ:/bloc:test,/test:state)
๋ฌด์์ ํด์ฃผ๋์#
๋์ ํ๋ฉด ์ฝ๋์ ๋ํ ํ
์คํธ ํ์ผ์ ๋ง๋ค์ด ์ค๋๋ค. ์๋ฅผ ๋ค์ด home_bloc_test.dart, home_cubit_test.dart
๊ฐ์ ํ์ผ์ด ์์ฑ๋๋ฉฐ, ์ด ํ์ผ๋ค์ ๋ค์์ ์๋์ผ๋ก ๊ฒ์ฌํฉ๋๋ค.
- ์ฒ์ ์์ ์ํ๊ฐ ์ฌ๋ฐ๋ฅธ์ง
- ์ฑ๊ณตํ์ ๋ ์ํ๊ฐ ์ฌ๋ฐ๋ฅธ ์์๋ก ๋ฐ๋๋์ง (์: ๋ก๋ฉ โ ์๋ฃ)
- ์คํจํ์ ๋ ์ค๋ฅ ์ํ๋ก ์ ๋์ด๊ฐ๋์ง
- ๊ฒ์์ฐฝ์ฒ๋ผ ๋น ๋ฅธ ์ ๋ ฅ์ ๋ฌถ์ด์ ์ฒ๋ฆฌํ๊ฑฐ๋, ์๋ฆผ์ฒ๋ผ ์ค์๊ฐ์ผ๋ก ๋ค์ด์ค๋ ๋ฐ์ดํฐ๊น์ง ์ ๋๋ก ๋ฐ์ํ๋์ง
์ด๋ป๊ฒ ์ฐ๋์#
# ํน์ ํ๋ฉด ์ฝ๋(BLoC/Cubit)์ ํ
์คํธ๋ฅผ ๋ง๋ค๋๋ก ํธ์ถ
/test:bloc
ํ์ํ๋ฉด ๋ค์ ์ ๋ณด๋ฅผ ํจ๊ป ๋๊ธธ ์ ์์ต๋๋ค.
target_bloc(ํ์) โ ๊ฒ์ฌํ ํ๋ฉด ์ฝ๋์ ์ด๋ฆfeature_name(์ ํ) โ ์ด๋ ๊ธฐ๋ฅ ๋ชจ๋์ธ์งinclude_cubit(์ ํ) โ Cubit๊น์ง ํฌํจํ ์ง ์ฌ๋ถ (๊ธฐ๋ณธ๊ฐ: ํฌํจ ์ ํจ)
๋ง๋ ๋ค ์ค์ ๋ก ๊ฒ์ฌ๋ฅผ ๋๋ฆฌ๋ ๋ช ๋ น์ ์๋์ ๊ฐ์ต๋๋ค.
# ๊ฒ์ฌ์ ํ์ํ ๋ณด์กฐ ํ์ผ ๋จผ์ ์์ฑ
dart run build_runner build --delete-conflicting-outputs
# ํ๋ฉด ์ํ ํ
์คํธ๋ง ์คํ
flutter test test/src/bloc/
์์์ ๋ฌด์จ ์ผ์ด ๋ฒ์ด์ง๋์#
- ๊ฒ์ฌ์ฉ ๋ณด์กฐ ํ์ผ(Mock)์ ๋ง๋ค์ด, ์ค์ ์๋ฒ๋ ๋ฐ์ดํฐ ์์ด๋ ํ ์คํธํ ์ ์๊ฒ ์ค๋นํฉ๋๋ค.
- "์ด ์ฌ๊ฑด์ด ์ผ์ด๋๋ฉด(act) โ ์ด๋ฐ ์ํ๊ฐ ๋์์ผ ํ๋ค(expect)"๋ ์์ผ๋ก ์๋๋ฆฌ์ค๋ฅผ ์งญ๋๋ค.
- ์ฑ๊ณตยท์คํจยท์ฌ์๋ยท๊ฒ์ ๋ฌถ์ ์ฒ๋ฆฌยท์ค์๊ฐ ๋ฐ์ดํฐ ๊ฐ์ ์ฌ๋ฌ ์ํฉ์ ๊ฐ๊ฐ ๊ฒ์ฌํฉ๋๋ค.
- ๋ง์ง๋ง์ผ๋ก ์ฒดํฌ๋ฆฌ์คํธ(์ฒ์ ์ํ ํ์ธ, ์ฑ๊ณตยท์คํจ ํ๋ฆ ํ์ธ ๋ฑ)๋ฅผ ๋ฐ๋ผ ๋น ์ง ๊ฒ์ฌ๊ฐ ์๋์ง ์ ๊ฒํฉ๋๋ค.
โ๏ธ ์์ธ ์ต์ ยท์คํ ๋ช ์ธ (๊ฐ๋ฐ์ / AI ์์ด์ ํธ์ฉ)
Role#
Tests BLoC state transitions.
- Uses bloc_test package
- build, act, expect Pattern
- State transition verification
- Event processing verification
Activation Conditions#
/test:blocActivated when command is invoked- Invoked when writing BLoC, Cubit state tests
Parameters#
| Parameter | Required | Description |
|---|---|---|
target_bloc | โ | Test target BLoC/Cubit Class name |
feature_name | โ | Feature module name |
include_cubit | โ | Cubit Includes Whether (default: false) |
Test File Structure#
feature/{module_type}/{feature_name}/test/
โโโ src/
โ โโโ bloc/
โ โ โโโ {feature}_bloc_test.dart
โ โ โโโ {feature}_cubit_test.dart
โ โโโ fixture/
โ โโโ {feature}_fixture.dart
โโโ {feature}_test.dart # Test entry pointImport Order (Required)#
// 1. Dart test
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
// 2. Mock package
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
// 3. Dependency packages
import 'package:dependencies/dependencies.dart';
// 4. Test target
import 'package:{feature}/src/presentation/bloc/{feature}_bloc.dart';
import 'package:{feature}/src/domain/usecase/get_{entity}_usecase.dart';
// 5. Generated files
import '{feature}_bloc_test.mocks.dart';
Core Patterns#
1. BLoC Test Default Structure#
import 'package:bloc_test/bloc_test.dart';
import 'package:dependencies/dependencies.dart';
import 'package:feature_home/src/domain/entity/user.dart';
import 'package:feature_home/src/domain/usecase/get_user_usecase.dart';
import 'package:feature_home/src/presentation/bloc/home_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'home_bloc_test.mocks.dart';
@GenerateNiceMocks([MockSpec<GetUserUseCase>()])
void main() {
late HomeBloC bloc;
late MockGetUserUseCase mockGetUserUseCase;
setUp(() {
mockGetUserUseCase = MockGetUserUseCase();
bloc = HomeBloC(mockGetUserUseCase);
});
tearDown(() {
bloc.close();
});
group('HomeBloC', () {
const tUser = User(id: 1, name: 'ํ๊ธธ๋', email: 'hong@example.com');
test('initial state should be HomeInitial', () {
expect(bloc.state, equals(const HomeInitial()));
});
blocTest<HomeBloC, HomeState>(
'emits [HomeLoading, HomeLoaded] when LoadUser is added',
build: () {
when(mockGetUserUseCase(any))
.thenAnswer((_) async => const Right(tUser));
return bloc;
},
act: (bloc) => bloc.add(const HomeEvent.loadUser(id: 1)),
expect: () => [
const HomeLoading(),
const HomeLoaded(user: tUser),
],
verify: (_) {
verify(mockGetUserUseCase(const GetUserParams(id: 1))).called(1);
},
);
blocTest<HomeBloC, HomeState>(
'emits [HomeLoading, HomeError] when LoadUser fails',
build: () {
when(mockGetUserUseCase(any))
.thenAnswer((_) async => const Left(ServerFailure(message: '์๋ฒ ์ค๋ฅ')));
return bloc;
},
act: (bloc) => bloc.add(const HomeEvent.loadUser(id: 1)),
expect: () => [
const HomeLoading(),
isA<HomeError>().having(
(s) => s.failure.message,
'failure message',
'์๋ฒ ์ค๋ฅ',
),
],
);
});
}
2. Cubit Test#
import 'package:bloc_test/bloc_test.dart';
import 'package:feature_counter/src/presentation/cubit/counter_cubit.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
late CounterCubit cubit;
setUp(() {
cubit = CounterCubit();
});
tearDown(() {
cubit.close();
});
group('CounterCubit', () {
test('initial state should be 0', () {
expect(cubit.state, equals(0));
});
blocTest<CounterCubit, int>(
'emits [1] when increment is called',
build: () => cubit,
act: (cubit) => cubit.increment(),
expect: () => [1],
);
blocTest<CounterCubit, int>(
'emits [-1] when decrement is called',
build: () => cubit,
act: (cubit) => cubit.decrement(),
expect: () => [-1],
);
blocTest<CounterCubit, int>(
'emits [1, 2, 3] when increment is called 3 times',
build: () => cubit,
act: (cubit) {
cubit.increment();
cubit.increment();
cubit.increment();
},
expect: () => [1, 2, 3],
);
});
}
3. Complex State Transition Test#
blocTest<HomeBloC, HomeState>(
'emits correct states for pagination flow',
build: () {
when(mockGetUsersUseCase(any)).thenAnswer((_) async => Right(users));
return bloc;
},
seed: () => const HomeLoaded(users: [], hasMore: true, page: 1),
act: (bloc) => bloc.add(const HomeEvent.loadMore()),
expect: () => [
// ๋ก๋ฉ ์ํ
const HomeLoaded(users: [], hasMore: true, page: 1, isLoadingMore: true),
// ๋ก๋ ์๋ฃ ์ํ
HomeLoaded(users: users, hasMore: false, page: 2, isLoadingMore: false),
],
);
blocTest<HomeBloC, HomeState>(
'does not emit new state when already loading',
build: () => bloc,
seed: () => const HomeLoading(),
act: (bloc) => bloc.add(const HomeEvent.loadUser(id: 1)),
expect: () => [],
verify: (_) {
verifyNever(mockGetUserUseCase(any));
},
);
4. Error Recovery Test#
blocTest<HomeBloC, HomeState>(
'can retry after error',
build: () {
var callCount = 0;
when(mockGetUserUseCase(any)).thenAnswer((_) async {
callCount++;
if (callCount == 1) {
return const Left(NetworkFailure(message: '๋คํธ์ํฌ ์ค๋ฅ'));
}
return const Right(tUser);
});
return bloc;
},
act: (bloc) async {
bloc.add(const HomeEvent.loadUser(id: 1));
await Future.delayed(const Duration(milliseconds: 100));
bloc.add(const HomeEvent.retry());
},
expect: () => [
const HomeLoading(),
isA<HomeError>(),
const HomeLoading(),
const HomeLoaded(user: tUser),
],
);
5. Debounce/Throttle Test#
blocTest<SearchBloC, SearchState>(
'debounces search queries',
build: () {
when(mockSearchUseCase(any))
.thenAnswer((_) async => const Right(searchResults));
return SearchBloC(mockSearchUseCase);
},
act: (bloc) async {
bloc.add(const SearchEvent.queryChanged('a'));
bloc.add(const SearchEvent.queryChanged('ab'));
bloc.add(const SearchEvent.queryChanged('abc'));
await Future.delayed(const Duration(milliseconds: 500));
},
wait: const Duration(milliseconds: 600),
expect: () => [
const SearchLoading(),
const SearchLoaded(results: searchResults),
],
verify: (_) {
// ๋๋ฐ์ด์ค๋ก ์ธํด ๋ง์ง๋ง ์ฟผ๋ฆฌ๋ง ์คํ๋จ
verify(mockSearchUseCase(const SearchParams(query: 'abc'))).called(1);
verifyNever(mockSearchUseCase(const SearchParams(query: 'a')));
verifyNever(mockSearchUseCase(const SearchParams(query: 'ab')));
},
);
6. Stream Subscription Test#
blocTest<NotificationBloC, NotificationState>(
'updates state when stream emits new data',
build: () {
final controller = StreamController<List<Notification>>();
when(mockWatchNotificationsUseCase())
.thenAnswer((_) => controller.stream);
// ํ
์คํธ in progress ์คํธ๋ฆผ์ ๋ฐ์ดํฐ ์ถ๊ฐ
Future.delayed(const Duration(milliseconds: 50), () {
controller.add([notification1]);
});
Future.delayed(const Duration(milliseconds: 100), () {
controller.add([notification1, notification2]);
});
return NotificationBloC(mockWatchNotificationsUseCase);
},
act: (bloc) => bloc.add(const NotificationEvent.startWatching()),
wait: const Duration(milliseconds: 200),
expect: () => [
const NotificationLoading(),
NotificationLoaded(notifications: [notification1]),
NotificationLoaded(notifications: [notification1, notification2]),
],
);
7. Sealed Class State Matching#
blocTest<HomeBloC, HomeState>(
'emits correct state with pattern matching verification',
build: () {
when(mockGetUserUseCase(any))
.thenAnswer((_) async => const Right(tUser));
return bloc;
},
act: (bloc) => bloc.add(const HomeEvent.loadUser(id: 1)),
verify: (bloc) {
final state = bloc.state;
switch (state) {
case HomeLoaded(:final user):
expect(user, equals(tUser));
case HomeError():
fail('Expected HomeLoaded but got HomeError');
case HomeLoading():
fail('Expected HomeLoaded but got HomeLoading');
case HomeInitial():
fail('Expected HomeLoaded but got HomeInitial');
}
},
);
blocTest Parameter Summary#
| Parameter | Purpose | Example |
|---|---|---|
build | BLoC Instance Generation | build: () => bloc |
seed | Set initial state | seed: () => HomeLoaded() |
act | Emit event | act: (bloc) => bloc.add(event) |
expect | Expected state List | expect: () => [State1(), State2()] |
verify | Additional Verification | verify: (_) { verify(...); } |
wait | Async wait duration | wait: Duration(seconds: 1) |
errors | Expected errors List | errors: () => [isA<Exception>()] |
setUp | Run before each test | setUp: () async { ... } |
tearDown | Run after each test | tearDown: () async { ... } |
State Matcher Patterns#
// ํ์
๊ฒ์ฆ
expect: () => [isA<HomeLoading>(), isA<HomeLoaded>()],
// ์์ฑ ๊ฒ์ฆ
expect: () => [
isA<HomeLoaded>()
.having((s) => s.user.id, 'user id', 1)
.having((s) => s.user.name, 'user name', 'ํ๊ธธ๋'),
],
// ์ฌ๋ฌ ์์ฑ ๊ฒ์ฆ
expect: () => [
isA<HomeError>()
.having((s) => s.failure, 'failure', isA<ServerFailure>())
.having((s) => s.failure.message, 'message', contains('์๋ฒ')),
],
// ์ ํํ ๊ฐ ๊ฒ์ฆ
expect: () => [
const HomeLoading(),
const HomeLoaded(user: tUser),
],
Build Commands#
# Mock ์์ฑ
cd feature/{module_type}/{feature_name}
dart run build_runner build --delete-conflicting-outputs
# BLoC ํ
์คํธ๋ง ์คํ
flutter test test/src/bloc/
# ํน์ BLoC ํ
์คํธ ์คํ
flutter test test/src/bloc/home_bloc_test.dart
# Test with coverage
melos run test:with-html-coverageReference Files#
feature/application/home/test/src/bloc/home_bloc_test.dart
feature/application/store/test/src/bloc/store_bloc_test.dart
feature/console/instructor/test/src/bloc/instructor_bloc_test.dartChecklist#
- bloc_test Package import
- @GenerateNiceMocks Annotation (UseCase Mock)
- Initialize BLoC in setUp
- Call bloc.close() in tearDown
- Test initial state
- Test success case state transitions
- Test failure case state transitions
- Set initial state with seed (when needed)
- Wait for async with wait (when needed)
- Verify UseCase calls with verify