/cc-flutter:test bloc — 화면 상태 변화 검사기#
| 항목 | 내용 |
|---|---|
| 실행 명령 | /cc-flutter:test bloc |
| 별칭 | /bloc:test, /test:state |
| 모델 | sonnet |
| 사용 도구 | Read, Edit, Write, Glob, Grep |
| 연계 스킬 | test |
한마디로#
앱 화면이 "로딩 중 → 완료 → 오류" 같은 상태를 올바른 순서로 거치는지 자동으로 점검해 주는 도구입니다. 신호등이 빨강·노랑·초록을 정해진 순서대로 켜는지 확인하는 검사와 비슷합니다.
누가·언제 쓰나요#
- 앱의 화면 상태를 관리하는 코드(BlocSignal, CubitSignal)를 만든 뒤, 그것이 의도대로 동작하는지 확인하고 싶은 개발자
-
/cc-flutter:test bloc명령을 실행할 때 자동으로 작동합니다. (별칭:/bloc:test,/test:state)
무엇을 해주나요#
대상 화면 코드에 대한 테스트 파일을 만들어 줍니다. 예를 들어 home_bloc_test.dart, home_cubit_test.dart
같은 파일이 생성되며, 이 파일들은 다음을 자동으로 검사합니다.
- 처음 시작 상태가 올바른지
- 성공했을 때 상태가 올바른 순서로 바뀌는지 (예: 로딩 → 완료)
- 실패했을 때 오류 상태로 잘 넘어가는지
- 검색창처럼 빠른 입력을 묶어서 처리하거나, 알림처럼 실시간으로 들어오는 데이터까지 제대로 반영하는지
어떻게 쓰나요#
# 특정 화면 코드(BlocSignal/CubitSignal)의 테스트를 만들도록 호출
/cc-flutter:test bloc
필요하면 다음 정보를 함께 넘길 수 있습니다.
target_bloc(필수) — 검사할 화면 코드의 이름feature_name(선택) — 어느 기능 모듈인지include_cubit(선택) — CubitSignal까지 포함할지 여부 (기본값: 포함 안 함)
만든 뒤 실제로 검사를 돌리는 명령은 아래와 같습니다.
# 검사에 필요한 보조 파일 먼저 생성
dart run build_runner build --delete-conflicting-outputs
# 화면 상태 테스트만 실행
flutter test test/src/bloc/
안에서 무슨 일이 벌어지나요#
- 검사용 보조 파일(Mock)을 만들어, 실제 서버나 데이터 없이도 테스트할 수 있게 준비합니다.
- "이 사건이 일어나면(act) → 이런 상태가 나와야 한다(expect)"는 식으로 시나리오를 짭니다.
- 성공·실패·재시도·검색 묶음 처리·실시간 데이터 같은 여러 상황을 각각 검사합니다.
- 마지막으로 체크리스트(처음 상태 확인, 성공·실패 흐름 확인 등)를 따라 빠진 검사가 없는지 점검합니다.
⚙️ 상세 옵션·실행 명세 (개발자 / AI 에이전트용)
Role#
Tests BlocSignal / CubitSignal state transitions.
- Uses
bloc_signals_testpackage - build, act, expect Pattern
- State transition verification
- Event processing verification
⚠️ Migration Notes (bloc_test → bloc_signals_test)#
These four differences cause most failures when porting existing tests.
| # | Difference | Action |
|---|---|---|
| 1 | No seed: parameter | blocSignalTest has no seed. Seed through the constructor instead — give the container an optional initialState parameter, or drive it with events first. |
| 2 | State reads use stateValue | bloc.state is a ReadonlySignal<S>, not the value. expect(bloc.stateValue, ...). |
| 3 | Equal states are de-duplicated | Re-emitting an equal state produces no emission. Never list the same state twice in expect. |
| 4 | emit is synchronous | Many tests no longer need blocSignalTest at all — call and assert directly. |
setUp, tearDown, wait, skip, verify, errors, and tags all exist and behave as before.
Actual signature#
blocSignalTest<B extends BlocSignalBase<State>, State>(
String description, {
required B Function() build,
FutureOr<void> Function()? setUp,
FutureOr<void> Function(B bloc)? act,
Duration? wait,
int skip = 0,
Object? Function()? expect,
FutureOr<void> Function(B bloc)? verify,
Object? Function()? errors,
FutureOr<void> Function()? tearDown,
dynamic tags,
})
Activation Conditions#
/cc-flutter:test blocActivated when command is invoked- Invoked when writing
BlocSignal,CubitSignalstate tests
Parameters#
| Parameter | Required | Description |
|---|---|---|
target_bloc | ✅ | Test target BlocSignal/CubitSignal Class name |
feature_name | ❌ | Feature module name |
include_cubit | ❌ | CubitSignal 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 point
dart_test.yamlmust setpaths: [test/]. Package entrypoints ending in_test.dart(such aspackage:bloc_signals_test'slib/bloc_signals_test.dart) otherwise match the runner's globs and break test discovery.
Import Order (Required)#
// 1. Dart test
import 'package:bloc_signals_test/bloc_signals_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#
0. Synchronous Direct Assertion ✅ Prefer when there is no async gap#
emit lands in the same execution block, so a plain test is often enough — no declarative wrapper,
no stream wait.
test('increments synchronously', () {
final cubit = CounterCubit();
addTearDown(cubit.close);
cubit.increment();
expect(cubit.stateValue, 1); // already updated
});
Reach for blocSignalTest when you need to assert the sequence of intermediate states.
1. BlocSignal Test Default Structure#
import 'package:bloc_signals_test/bloc_signals_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.stateValue, equals(const HomeInitial()));
});
blocSignalTest<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);
},
);
blocSignalTest<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. CubitSignal Test#
import 'package:bloc_signals_test/bloc_signals_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.stateValue, equals(0));
});
blocSignalTest<CounterCubit, int>(
'emits [1] when increment is called',
build: () => cubit,
act: (cubit) => cubit.increment(),
expect: () => [1],
);
blocSignalTest<CounterCubit, int>(
'emits [-1] when decrement is called',
build: () => cubit,
act: (cubit) => cubit.decrement(),
expect: () => [-1],
);
blocSignalTest<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. Seeding Initial State (replaces seed:)#
blocSignalTest has no seed parameter. Expose an optional initialState on the container and
pass it from build:
class HomeBloc extends BlocSignal<HomeEvent, HomeState> {
HomeBloc(
this._getUsersUseCase, {
HomeState initialState = const HomeInitial(),
}) : super(initialState: initialState) {
on<HomeLoadMore>(_onLoadMore);
}
// ...
}
blocSignalTest<HomeBloc, HomeState>(
'emits correct states for pagination flow',
build: () {
when(mockGetUsersUseCase(any)).thenAnswer((_) async => Right(users));
return HomeBloc(
mockGetUsersUseCase,
initialState: 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),
],
);
4. Guard Test (no emission expected)#
blocSignalTest<HomeBloc, HomeState>(
'does not emit new state when already loading',
build: () => HomeBloc(
mockGetUserUseCase,
initialState: const HomeLoading(),
),
act: (bloc) => bloc.add(const HomeEvent.loadUser(id: 1)),
expect: () => <HomeState>[],
verify: (_) {
verifyNever(mockGetUserUseCase(any));
},
);
Auto de-duplication makes this stricter than before: even if the handler does call
emit(const HomeLoading()), no emission is recorded because the state is unchanged.
5. Error Recovery Test#
blocSignalTest<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<void>.delayed(const Duration(milliseconds: 100));
bloc.add(const HomeEvent.retry());
},
expect: () => [
const HomeLoading(),
isA<HomeError>(),
const HomeLoading(),
const HomeLoaded(user: tUser),
],
);
6. Debounce Test#
⚠️
bloc_signalsships nodebounce/throttletransformer — onlydroppable(),sequential(),restartable(), andMutex. Theavoid_stream_transformers_on_bloc_signallint also forbids calling.debounce()/.switchMap()on a container.Implement debounce as
restartable()+ a delay at the top of the handler: each new event supersedes the pending one before its delay elapses.
// Container under test
on<SearchQueryChanged>(
(event, emit) async {
await Future<void>.delayed(const Duration(milliseconds: 300));
if (isClosed) return; // superseded handlers must not emit
emit(const SearchLoading());
final result = await _searchUseCase(SearchParams(query: event.query));
if (isClosed) return;
emit(result.fold(SearchError.new, SearchLoaded.new));
},
transformer: restartable(),
);
blocSignalTest<SearchBloc, SearchState>(
'debounces search queries',
build: () {
when(mockSearchUseCase(any))
.thenAnswer((_) async => const Right(searchResults));
return SearchBloc(mockSearchUseCase);
},
act: (bloc) {
bloc.add(const SearchEvent.queryChanged('a'));
bloc.add(const SearchEvent.queryChanged('ab'));
bloc.add(const SearchEvent.queryChanged('abc'));
},
wait: const Duration(milliseconds: 600),
expect: () => [
const SearchLoading(),
const SearchLoaded(results: searchResults),
],
verify: (_) {
// restartable()로 앞선 핸들러가 취소되어 마지막 쿼리만 실행됨
verify(mockSearchUseCase(const SearchParams(query: 'abc'))).called(1);
verifyNever(mockSearchUseCase(const SearchParams(query: 'a')));
verifyNever(mockSearchUseCase(const SearchParams(query: 'ab')));
},
);
7. Stream Subscription Test#
blocSignalTest<NotificationBloc, NotificationState>(
'updates state when stream emits new data',
build: () {
final controller = StreamController<List<Notification>>();
when(mockWatchNotificationsUseCase())
.thenAnswer((_) => controller.stream);
// 테스트 진행 중 스트림에 데이터 추가
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]),
],
);
8. Sealed Class State Matching#
blocSignalTest<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.stateValue;
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');
}
},
);
9. Lifecycle Tests#
test('add after close leaves state unchanged', () async {
final bloc = HomeBloc(mockGetUserUseCase);
await bloc.close();
bloc.add(const HomeEvent.loadUser(id: 1));
expect(bloc.isClosed, isTrue);
expect(bloc.stateValue, const HomeInitial()); // state stays readable
});
BlocSignalObserver.observer is process-global — reset it in tearDown whenever a test installs a spy.
blocSignalTest Parameter Summary#
| Parameter | Purpose | Example |
|---|---|---|
build | Container Instance Generation | build: () => bloc |
act | Emit event | act: (bloc) => bloc.add(event) |
expect | Expected state List or matcher | expect: () => [State1(), State2()] |
verify | Additional Verification | verify: (_) { verify(...); } |
wait | Async wait duration | wait: Duration(seconds: 1) |
skip | Skip N leading emissions | skip: 1 |
errors | Expected errors List | errors: () => [isA<Exception>()] |
setUp | Run before each test | setUp: () async { ... } |
tearDown | Run after each test | tearDown: () async { ... } |
tags | Test tags | tags: 'slow' |
seed | ❌ Not available | Use the constructor's initialState — see Pattern 3 |
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
# BlocSignal 테스트만 실행
flutter test test/src/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_signals_testPackage import (notbloc_test)- @GenerateNiceMocks Annotation (UseCase Mock)
- Initialize container in setUp
- Call
bloc.close()in tearDown (oraddTearDown) - Test initial state with
stateValue - Test success case state transitions
- Test failure case state transitions
- Seed via constructor
initialState(there is noseed:) - Wait for async with
wait(when needed) - Verify UseCase calls with
verify - No duplicate consecutive states in
expect(auto de-dup) - Prefer a plain synchronous
testwhen no intermediate sequence is asserted