LogoSkills

test

Unit/Widget/BDD/Patrol test writing guide

ํ•ญ๋ชฉ๋‚ด์šฉ
Invoke/test
Aliases/test:unit, /test:widget, /test:bloc, /test:patrol
Categorypetmedi-development
Complexitymoderate
MCP Serversserena, context7

/test#

Context Framework Note: Activates when writing test code.

Triggers#

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

Context Trigger Pattern#

/test {type} {target} [--options]

Parameters#

ParameterRequiredDescriptionExample
type โœ… Test Type unit, widget, bloc, bdd, patrol
target โœ… Test target GetUserUseCase, HomeBloc, LoginPage
--feature โŒ Feature module auth, home
--coverage โŒ Coverage target 80, 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}.feature
    โ””โ”€โ”€ step/

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_test/bloc_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.state, const {Feature}State.initial());
    });

    blocTest<{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,
        ),
      ],
    );

    blocTest<{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:flutter_bloc/flutter_bloc.dart';
import 'package:mocktail/mocktail.dart';

class Mock{Feature}Bloc extends MockBloc<{Feature}Event, {Feature}State>
    implements {Feature}Bloc {}

void main() {
  late Mock{Feature}Bloc mockBloc;

  setUp(() {
    mockBloc = Mock{Feature}Bloc();
  });

  Widget buildTestWidget() {
    return MaterialApp(
      home: BlocProvider<{Feature}Bloc>.value(
        value: mockBloc,
        child: const {Feature}Page(),
      ),
    );
  }

  group('{Feature}Page', () {
    testWidgets('renders loading indicator when loading', (tester) async {
      // Arrange
      when(() => mockBloc.state).thenReturn(const {Feature}State.loading());

      // Act
      await tester.pumpWidget(buildTestWidget());

      // 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'),
      ];
      when(() => mockBloc.state).thenReturn(
        {Feature}State.loaded(items: items),
      );

      // Act
      await tester.pumpWidget(buildTestWidget());

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

    testWidgets('shows error message when error', (tester) async {
      // Arrange
      when(() => mockBloc.state).thenReturn(
        {Feature}State.error(ServerFailure('Network error')),
      );

      // Act
      await tester.pumpWidget(buildTestWidget());

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

.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 # ์—๋Ÿฌ ํ‘œ์‹œ

  @patrol-only
  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);
}

build.yaml (Dual test generation)#

targets:
  $default:
    builders:
      bdd_test_gen|dual_test_gen:
        enabled: true
        generate_for:
          - test/src/bdd/*.feature
        options:
          stepFolder: step

.feature โ†’ .widget_test.dart + .patrol_test.dart generated simultaneously

BDD Test (Legacy bdd_widget_test)#

Feature: {Feature} List View # {Feature} ๋ชฉ๋ก ์กฐํšŒ
  User can view {Feature} list.

  Background:
    Given the app is running # ์•ฑ์ด ์‹คํ–‰๋˜์–ด ์žˆ๋‹ค

  Scenario: List loads successfully # ๋ชฉ๋ก ์กฐํšŒ ์„ฑ๊ณต
    When user navigates to {feature} screen
    Then {entity} list is displayed

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

# BDD tests
melos run test:bdd
melos run test:bdd:select

# Code generation (BDD โ†’ _test.dart)
melos run build

# Patrol E2E
patrol test --target integration_test/scenarios/smoke_test.dart

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

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 should use TestDriver (recommended) or WidgetTester
  • K class for centralized widget Key management
  • @widget-only, @patrol-only tags for test target control

Coverage#

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

MCP Integration#

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

Examples#

Generate UseCase Test#

/test unit GetUserUseCase --feature auth

Generate BLoC Test#

/test bloc HomeBloc --feature home

Generate Widget Test#

/test widget LoginPage --feature auth

Generate BDD Scenario (Dual Test)#

/test bdd community

Patrol E2E#

/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