LogoSkills

/cc-flutter:test — 테스트 코드 작성 도우미

요청한 종류의 테스트 코드를 만듭니다 — UseCase 단위·BLoC·Widget·BDD(`.feature`→Patrol E2E)·Patrol E2E 중 선택하며, 커버리지 80%+ (핵심 UseCase 100%) 기준을 함께 안내합니다.

/cc-flutter:test — 테스트 코드 작성 도우미#

항목내용
실행 명령/cc-flutter:test
분류개발
난이도●●○ 보통
MCP 서버serena, context7

한마디로#

새로 만든 기능이 실제로 잘 돌아가는지 확인하는 "검사 코드(테스트)"를 대신 작성해 주는 도구입니다. 제품을 출고하기 전, 품질 검사 라인에서 자동으로 불량을 걸러내는 검수 장치를 깔아 주는 것과 같아요.

누가·언제 쓰나요#

  • 새 기능을 만든 뒤, 그 기능이 의도대로 동작하는지 자동 검사 코드를 붙이고 싶을 때
  • 검사 범위(커버리지)를 넓혀서 빠진 부분 없이 꼼꼼히 확인하고 싶을 때
  • 코드를 먼저 검사 기준부터 정하고 만드는 개발 방식(TDD/BDD)으로 일할 때
  • 사용자가 실제로 화면을 누르고 넘어가는 흐름까지 통째로 점검하는 E2E 검사(Patrol)를 만들 때

무엇을 해주나요#

요청한 검사 종류에 맞춰, 바로 쓸 수 있는 검사 코드와 설정 파일을 만들어 줍니다.

  • UseCase 단위 테스트 — 기능 한 조각이 맞게 동작하는지 검사
  • BLoC 테스트 — 화면의 상태(로딩 / 완료 / 에러)가 제대로 바뀌는지 검사
  • Widget 테스트 — 화면이 상황에 맞게 올바로 그려지는지 검사
  • BDD 시나리오 — "이런 상황에서 이렇게 동작해야 한다"는 시나리오(.feature)로 Patrol E2E 검사를 생성 (위젯 검사는 만들지 않습니다 — 위젯 검사가 필요하면 별도의 일반 Widget 테스트로 작성하세요)
  • Patrol E2E 테스트 — 실제 앱을 켜고 사람처럼 눌러 보는 통합 검사

검사 목표는 최소 80% 이상을 권장하며, 핵심 로직(UseCase)은 100% 검사하도록 안내합니다.

어떻게 쓰나요#

# UseCase 단위 테스트 만들기 (auth 기능)
/cc-flutter:test unit GetUserUseCase --feature auth

# 화면 상태(BLoC) 테스트 만들기 (home 기능)
/cc-flutter:test bloc HomeBloc --feature home

# 화면(Widget) 테스트 만들기 (auth 기능)
/cc-flutter:test widget LoginPage --feature auth

# BDD 시나리오 만들기 (Patrol E2E 생성 — 위젯 검사는 생성되지 않음)
/cc-flutter:test bdd community

# Patrol E2E 테스트 만들기 (auth 기능)
/cc-flutter:test patrol auth_flow --feature auth
  • 맨 앞에 검사 종류(unit / widget / bloc / bdd / patrol)와 검사 대상을 적습니다.
  • --feature 로 어떤 기능 모듈인지 지정하고, --coverage 90 처럼 검사 목표 비율을 정할 수 있습니다(기본 80).

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

  1. 요청 해석 — 검사 종류와 대상, 옵션을 읽어 어떤 테스트를 만들지 정합니다.
  2. 기존 패턴 참고 — 이미 있는 테스트 코드와 공식 문서(flutter_test, bloc_signals_test, patrol 등)를 살펴 같은 방식으로 맞춥니다.
  3. 검사 코드 작성 — 정해진 폴더 구조에 맞춰, "준비 → 실행 → 확인" 순서의 테스트 코드를 생성합니다. 진짜 의존성 대신 가짜(Mock)를 끼워 안전하게 검사합니다.
  4. BDD는 Patrol E2E 전용.feature 시나리오 하나로 Patrol E2E 검사 파일만 만들어집니다(위젯 검사는 생성되지 않습니다).
  5. 실행 안내melos run test, flutter test, patrol test 같은 실제 실행 명령을 함께 알려 줍니다.

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

Triggers#

  • When writing new test files
  • When improving test coverage
  • During TDD/BDD development
  • When writing Patrol E2E tests

Context Trigger Pattern#

/cc-flutter:test {type} {target} [--options]

Parameters#

ParameterRequiredDescriptionExample
typeTest Typeunit, widget, bloc, bdd, patrol
targetTest targetGetUserUseCase, HomeBloc, LoginPage
--featureFeature moduleauth, home
--coverageCoverage target80, 90 (default: 80)

Test Structure#

feature/{location}/{feature_name}/test/
└── src/
    ├── domain/
    │   └── usecase/           # UseCase 단위 테스트
    ├── data/
    │   └── repository/        # Repository 테스트 (mocked)
    └── presentation/
        ├── bloc/              # BLoC 테스트
        └── widget/            # Widget 테스트

⛔ feature 패키지에는 BDD 관련 파일이 없습니다. .feature·step·Patrol 시나리오는 전부 app/{app}/integration_test/에 있습니다:

app/{app}/integration_test/
├── features/{feature}.feature
├── step/
└── scenarios/
    ├── {feature}_{screen}_batch_test.dart   # 기본: 같은 Background 시나리오 3+ 를 한 세션에
    └── {feature}_{scenario}_test.dart       # @isolated 또는 2건 이하일 때만

UseCase Unit Test#

import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:dependencies/dependencies.dart';

class MockI{Feature}Repository extends Mock implements I{Feature}Repository {}

void main() {
  late Get{Entity}UseCase useCase;
  late MockI{Feature}Repository mockRepository;

  setUp(() {
    mockRepository = MockI{Feature}Repository();
    useCase = Get{Entity}UseCase(mockRepository);
  });

  group('Get{Entity}UseCase', () {
    final tEntity = {Entity}(id: 1, name: 'Test');
    final tParams = Get{Entity}Params(id: 1);

    test('should return entity when repository succeeds', () async {
      // Arrange
      when(() => mockRepository.get{Entity}(any()))
          .thenAnswer((_) async => Right(tEntity));

      // Act
      final result = await useCase(tParams);

      // Assert
      expect(result, Right(tEntity));
      verify(() => mockRepository.get{Entity}(1)).called(1);
      verifyNoMoreInteractions(mockRepository);
    });

    test('should return failure when repository fails', () async {
      // Arrange
      final tFailure = ServerFailure('Error');
      when(() => mockRepository.get{Entity}(any()))
          .thenAnswer((_) async => Left(tFailure));

      // Act
      final result = await useCase(tParams);

      // Assert
      expect(result, Left(tFailure));
    });
  });
}

BLoC Test#

import 'package:bloc_signals_test/bloc_signals_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';

class MockGet{Entity}UseCase extends Mock implements Get{Entity}UseCase {}

void main() {
  late {Feature}Bloc bloc;
  late MockGet{Entity}UseCase mockUseCase;

  setUp(() {
    mockUseCase = MockGet{Entity}UseCase();
    bloc = {Feature}Bloc();
  });

  tearDown(() {
    bloc.close();
  });

  group('{Feature}Bloc', () {
    test('initial state is Initial', () {
      expect(bloc.stateValue, const {Feature}State.initial());
    });

    blocSignalTest<{Feature}Bloc, {Feature}State>(
      'emits [Loading, Loaded] when load succeeds',
      build: () => bloc,
      act: (bloc) => bloc.load(),
      expect: () => [
        const {Feature}State.loading(),
        isA<{Feature}State>().having(
          (s) => s.maybeMap(loaded: (l) => l.items, orElse: () => null),
          'items',
          isNotEmpty,
        ),
      ],
    );

    blocSignalTest<{Feature}Bloc, {Feature}State>(
      'emits [Loading, Error] when load fails',
      build: () => bloc,
      act: (bloc) => bloc.load(),
      expect: () => [
        const {Feature}State.loading(),
        isA<{Feature}State>().having(
          (s) => s.maybeMap(error: (e) => e.failure, orElse: () => null),
          'failure',
          isNotNull,
        ),
      ],
    );
  });
}

Widget Test#

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:bloc_signals_flutter/bloc_signals_flutter.dart';
import 'package:mocktail/mocktail.dart';

// bloc_signals 에는 MockBloc/whenListen 이 없다. state 는 ReadonlySignal 이라
// 목으로 값을 돌려줘도 리빌드가 일어나지 않는다 → 실제 컨테이너를 시드해서 쓴다.
void main() {
  late MockGet{Entity}UseCase mockUseCase;

  setUp(() {
    mockUseCase = MockGet{Entity}UseCase();
  });

  Widget buildTestWidget({Feature}State initialState) {
    final bloc = {Feature}Bloc(mockUseCase, initialState: initialState);
    addTearDown(bloc.close);

    return MaterialApp(
      home: BlocSignalProvider<{Feature}Bloc>.value(
        value: bloc,
        child: const {Feature}Page(),
      ),
    );
  }

  group('{Feature}Page', () {
    testWidgets('renders loading indicator when loading', (tester) async {
      // Arrange
      // Act
      await tester.pumpWidget(buildTestWidget(const {Feature}State.loading()));

      // Assert
      expect(find.byType(CircularProgressIndicator), findsOneWidget);
    });

    testWidgets('renders list when loaded', (tester) async {
      // Arrange
      final items = [
        {Entity}(id: 1, name: 'Item 1'),
        {Entity}(id: 2, name: 'Item 2'),
      ];
      // Act
      await tester.pumpWidget(
        buildTestWidget({Feature}State.loaded(items: items)),
      );

      // Assert
      expect(find.text('Item 1'), findsOneWidget);
      expect(find.text('Item 2'), findsOneWidget);
    });

    testWidgets('shows error message when error', (tester) async {
      // Arrange
      // Act
      await tester.pumpWidget(
        buildTestWidget({Feature}State.error(ServerFailure('Network error'))),
      );

      // Assert
      expect(find.text('Network error'), findsOneWidget);
    });
  });
}

BDD Test (TestDriver Pattern — only supported form, hand-written as Patrol E2E only)#

.feature File#

@smoke
@auth
Feature: Login Page # 로그인 페이지

  Background:
    Given I am on the login page # 로그인 페이지

  @validation
  Scenario: Email field validation # 이메일 유효성 검사
    When I enter { ' invalid-email ' } in the email field # 잘못된 이메일
    Then the email error should be displayed # 에러 표시

  Scenario: Native back button # 네이티브 백 버튼
    When I press the back button # 백 버튼
    Then the app should navigate back # 이전 화면 복귀

Step Function (TestDriver-based)#

import 'package:test_driver/test_driver.dart';

/// Usage: I enter {string} in the email field # 이메일 입력
Future<void> iEnterInTheEmailField(TestDriver driver, String param1) async {
  await driver.enterText(K.emailField, param1);
}

Hand-written, no build.yaml#

No code generator runs in this pipeline — not bdd_test_gen, not co_test_gen (dual_test_gen), not bdd_widget_test. There is no build.yaml builder block to configure for BDD. .feature is a spec document under app/{app}/integration_test/features/; the Patrol test under app/{app}/integration_test/scenarios/ is written by hand, copying the .feature Scenario name and step order directly. Scenarios sharing one Background (3 or more) go into a single screen batch file ({name}_{screen}_batch_test.dart) so the arrival cost is paid once; scenarios tagged @isolated stay one-per-file ({name}_{scenario}_test.dart). See cc-flutter/skills/patrol-bdd-conventions → "화면 단위 배치". Shared steps are imported directly:

import 'package:test_driver/shared_steps.dart';

Legacy bdd_widget_test is retired. BDD scenarios no longer generate widget tests at all — there is no widget-targeted variant of this pattern to fall back to. A fast, mock-backed, non-Gherkin check belongs to plain Widget testing instead (see skills/widget-testing).

Patrol E2E Test#

import 'package:patrol/patrol.dart';
import 'package:test_driver/test_driver.dart';

void main() {
  patrolTest(
    'Given logged in, When store, Then book list',
    config: const PatrolTesterConfig(settleTimeout: Duration(seconds: 15)),
    ($) async {
      final driver = PatrolTestDriver($);
      await driver.tap(K.navStore);
      await driver.expectVisible(K.bookList);
    },
  );
}

Test Commands#

# 전체 테스트
melos run test

# Feature별 테스트
melos run test --scope=feature_{feature_name}

# 커버리지 포함
melos run test:with-html-coverage

# ⛔ melos run test:bdd / test:bdd:select / melos run build (BDD → _test.dart) are retired —
# there is no BDD codegen pipeline. Patrol E2E is the only way to run a BDD scenario:
patrol test --target integration_test/scenarios/smoke_test.dart

# Tag filtering
flutter test --tags smoke
flutter test --exclude-tags patrol

Core Rules#

Test Structure#

  • Arrange -> Act -> Assert pattern
  • Each test verifies only one behavior
  • Maintain independence between tests

Mocking#

  • Use mocktail package
  • Replace Repository/UseCase with Mock
  • Configure registerFallbackValue when needed

BDD Tests#

  • Follow Gherkin syntax
  • Step functions must use TestDriver (⛔ WidgetTester-based step functions are retired for BDD)
  • K class for centralized widget Key management
  • @both/@widget-only/@patrol-only tags are retired — every scenario is Patrol E2E only, so there is no widget/Patrol target to select. Grading/scheduling tags (@smoke, @P0@P2, domain tags) are unaffected and still apply.

Coverage#

  • Target minimum 80% code coverage
  • UseCase 100% testing required
  • BLoC main flow testing required

MCP Integration#

PhaseMCP ServerPurpose
Pattern analysisContext7flutter_test, bloc_signals_test, patrol docs
Code searchSerenaExisting test pattern reference
E2E executionpatrol_mcpAI agent-driven E2E verification

Examples#

Generate UseCase Test#

/cc-flutter:test unit GetUserUseCase --feature auth

Generate BLoC Test#

/cc-flutter:test bloc HomeBloc --feature home

Generate Widget Test#

/cc-flutter:test widget LoginPage --feature auth

Generate BDD Scenario (Patrol E2E)#

/cc-flutter:test bdd community

Patrol E2E#

/cc-flutter:test patrol auth_flow --feature auth

References#

  • Detailed implementation: agents/test.md
  • BDD generation: commands/bdd/generate.md
  • BDD patterns: rules/bdd-test-patterns.md