LogoSkills

bloc-test-agent

BlocSignal 상태 전이 테스트 전문가입니다. bloc_signals_test 패키지와 상태 검증에 사용합니다.

/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/

안에서 무슨 일이 벌어지나요#

  1. 검사용 보조 파일(Mock)을 만들어, 실제 서버나 데이터 없이도 테스트할 수 있게 준비합니다.
  2. "이 사건이 일어나면(act) → 이런 상태가 나와야 한다(expect)"는 식으로 시나리오를 짭니다.
  3. 성공·실패·재시도·검색 묶음 처리·실시간 데이터 같은 여러 상황을 각각 검사합니다.
  4. 마지막으로 체크리스트(처음 상태 확인, 성공·실패 흐름 확인 등)를 따라 빠진 검사가 없는지 점검합니다.

⚙️ 상세 옵션·실행 명세 (개발자 / AI 에이전트용)

Role#

Tests BlocSignal / CubitSignal state transitions.

  • Uses bloc_signals_test package
  • 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.

#DifferenceAction
1No seed: parameterblocSignalTest has no seed. Seed through the constructor instead — give the container an optional initialState parameter, or drive it with events first.
2State reads use stateValuebloc.state is a ReadonlySignal<S>, not the value. expect(bloc.stateValue, ...).
3Equal states are de-duplicatedRe-emitting an equal state produces no emission. Never list the same state twice in expect.
4emit is synchronousMany 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 bloc Activated when command is invoked
  • Invoked when writing BlocSignal, CubitSignal state tests

Parameters#

ParameterRequiredDescription
target_blocTest target BlocSignal/CubitSignal Class name
feature_nameFeature module name
include_cubitCubitSignal 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.yaml must set paths: [test/]. Package entrypoints ending in _test.dart (such as package:bloc_signals_test's lib/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_signals ships no debounce/throttle transformer — only droppable(), sequential(), restartable(), and Mutex. The avoid_stream_transformers_on_bloc_signal lint 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#

ParameterPurposeExample
buildContainer Instance Generationbuild: () => bloc
actEmit eventact: (bloc) => bloc.add(event)
expectExpected state List or matcherexpect: () => [State1(), State2()]
verifyAdditional Verificationverify: (_) { verify(...); }
waitAsync wait durationwait: Duration(seconds: 1)
skipSkip N leading emissionsskip: 1
errorsExpected errors Listerrors: () => [isA<Exception>()]
setUpRun before each testsetUp: () async { ... }
tearDownRun after each testtearDown: () async { ... }
tagsTest tagstags: 'slow'
seedNot availableUse 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-coverage

Reference 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.dart

Checklist#

  • bloc_signals_test Package import (not bloc_test)
  • @GenerateNiceMocks Annotation (UseCase Mock)
  • Initialize container in setUp
  • Call bloc.close() in tearDown (or addTearDown)
  • Test initial state with stateValue
  • Test success case state transitions
  • Test failure case state transitions
  • Seed via constructor initialState (there is no seed:)
  • 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 test when no intermediate sequence is asserted