LogoSkills

unit-test-agent

UseCase와 Repository 단위 테스트 전문가입니다. Mockito 패턴과 Either 결과 검증에 사용합니다.

/cc-flutter:test unit — 핵심 로직 자동 점검기#

항목내용
실행 명령/cc-flutter:test unit
별칭/unit:create, /test:usecase
모델sonnet
사용 도구 Read, Edit, Write, Glob, Grep
연계 스킬test

한마디로#

앱의 "두뇌"에 해당하는 핵심 로직(UseCase·Repository)이 제대로 동작하는지를 자동으로 확인하는 검사 코드를 대신 만들어 주는 도구입니다. 제품을 출고하기 전에 "버튼을 누르면 정말 켜지나?"를 자동 기계가 하나하나 눌러 보며 확인해 주는 것과 같아요.

누가·언제 쓰나요#

  • 새로 만든 핵심 기능(데이터를 가져오거나 저장하는 부분)이 의도대로 작동하는지 확인하고 싶을 때
  • 코드를 고친 뒤, 다른 곳이 망가지지 않았는지 안전하게 검증하고 싶을 때
  • /cc-flutter:test unit 명령(또는 별칭 /unit:create, /test:usecase)을 실행하면 자동으로 작동합니다.

무엇을 해주나요#

  • 검사 대상 코드에 맞는 단위 테스트 파일을 자동으로 작성합니다.
    • UseCase 테스트: get_{entity}_usecase_test.dart 같은 파일
    • Repository 테스트: {feature}_repository_test.dart 같은 파일
    • 테스트에서 반복해서 쓰는 가짜 데이터 모음({feature}_fixture.dart)
  • "성공했을 때"뿐 아니라 "실패했을 때도 올바르게 처리되는지" 까지 함께 확인하도록 만들어 줍니다.

어떻게 쓰나요#

# 핵심 로직 테스트 만들기 (기본)
/cc-flutter:test unit

# 별칭으로도 같은 동작
/unit:create
/test:usecase

검사 대상을 지정할 수 있습니다.

  • target_class (필수): 테스트할 클래스 이름
  • target_type (선택): usecase(기본값) 또는 repository 중 선택
  • feature_name (선택): 어떤 기능 모듈인지 이름 지정

테스트 코드를 다 만든 뒤에는 아래 명령으로 가짜 객체를 생성하고 실제로 테스트를 돌려 봅니다.

# 가짜 객체(Mock) 생성
dart run build_runner build --delete-conflicting-outputs

# 테스트 실행
melos run test:select

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

  1. 검사할 코드가 UseCase인지 Repository인지 파악하고, 정해진 폴더 구조에 맞춰 테스트 파일을 둘 위치를 잡습니다.
  2. 진짜 외부 의존성(서버, 데이터베이스) 대신 가짜 대역(Mock) 을 만들어, 외부 상황과 상관없이 로직만 깔끔하게 검사할 수 있게 준비합니다.
  3. "준비 → 실행 → 검증" 3단계 패턴으로 각 상황을 점검합니다 — 정상 동작, 서버 오류, 잘못된 입력, 캐시(임시 저장) 활용, 느린 응답·시간 초과 등.
  4. 만든 테스트가 빠짐없이 갖춰졌는지 체크리스트로 점검하고 마무리합니다.

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

Role#

Generates unit tests for UseCase and Repository.

  • Uses @GenerateNiceMocks annotation
  • Mockito Pattern (when, verify, verifyNoMoreInteractions)
  • Either result verification
  • setUp/tearDown patterns

Activation Conditions#

  • /cc-flutter:test unit Activated when command is invoked
  • Invoked when writing UseCase and Repository tests

Parameters#

ParameterRequiredDescription
target_classTest target Class name
target_typeusecase, repository (default: usecase)
feature_nameFeature module name

Test File Structure#

feature/{module_type}/{feature_name}/test/
├── src/
│   ├── unit/
│   │   ├── usecase/
│   │   │   ├── get_{entity}_usecase_test.dart
│   │   │   └── create_{entity}_usecase_test.dart
│   │   └── repository/
│   │       └── {feature}_repository_test.dart
│   └── fixture/
│       └── {feature}_fixture.dart
└── {feature}_test.dart               # Test entry point

Import Order (Required)#

// 1. Dart test
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/domain/usecase/get_{entity}_usecase.dart';
import 'package:{feature}/src/domain/repository/i_{feature}_repository.dart';

// 5. Generated files
import 'get_{entity}_usecase_test.mocks.dart';

Core Patterns#

1. UseCase Test#

import 'package:dependencies/dependencies.dart';
import 'package:feature_home/src/domain/entity/user.dart';
import 'package:feature_home/src/domain/repository/i_home_repository.dart';
import 'package:feature_home/src/domain/usecase/get_user_usecase.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';

import 'get_user_usecase_test.mocks.dart';

@GenerateNiceMocks([MockSpec<IHomeRepository>()])
void main() {
  late GetUserUseCase useCase;
  late MockIHomeRepository mockRepository;

  setUp(() {
    mockRepository = MockIHomeRepository();
    useCase = GetUserUseCase(mockRepository);
  });

  tearDown(() {
    reset(mockRepository);
  });

  group('GetUserUseCase', () {
    const tUserId = 1;
    const tUser = User(id: tUserId, name: '홍길동', email: 'hong@example.com');
    final tParams = GetUserParams(id: tUserId);

    test('should return User when repository call is successful', () async {
      // Arrange
      when(mockRepository.getUser(tUserId))
          .thenAnswer((_) async => const Right(tUser));

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

      // Assert
      expect(result, const Right<Failure, User>(tUser));
      verify(mockRepository.getUser(tUserId)).called(1);
      verifyNoMoreInteractions(mockRepository);
    });

    test('should return Failure when repository call fails', () async {
      // Arrange
      const tFailure = ServerFailure(message: '서버 오류');
      when(mockRepository.getUser(tUserId))
          .thenAnswer((_) async => const Left(tFailure));

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

      // Assert
      expect(result, const Left<Failure, User>(tFailure));
      verify(mockRepository.getUser(tUserId)).called(1);
      verifyNoMoreInteractions(mockRepository);
    });

    test('should throw when params is invalid', () async {
      // Arrange
      final invalidParams = GetUserParams(id: -1);

      // Act & Assert
      expect(
        () => useCase(invalidParams),
        throwsA(isA<InvalidParamsException>()),
      );
      verifyZeroInteractions(mockRepository);
    });
  });
}

2. Repository Test#

import 'package:dependencies/dependencies.dart';
import 'package:feature_home/src/data/repository/home_repository.dart';
import 'package:feature_home/src/domain/entity/user.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';

import 'home_repository_test.mocks.dart';

@GenerateNiceMocks([
  MockSpec<HomeApiClient>(),
  MockSpec<HomeLocalDataSource>(),
])
void main() {
  late HomeRepository repository;
  late MockHomeApiClient mockApiClient;
  late MockHomeLocalDataSource mockLocalDataSource;

  setUp(() {
    mockApiClient = MockHomeApiClient();
    mockLocalDataSource = MockHomeLocalDataSource();
    repository = HomeRepository(
      apiClient: mockApiClient,
      localDataSource: mockLocalDataSource,
    );
  });

  tearDown(() {
    reset(mockApiClient);
    reset(mockLocalDataSource);
  });

  group('HomeRepository.getUser', () {
    const tUserId = 1;
    const tUserDto = UserDto(id: tUserId, name: '홍길동', email: 'hong@example.com');
    const tUser = User(id: tUserId, name: '홍길동', email: 'hong@example.com');

    test('should return User when API call is successful', () async {
      // Arrange
      when(mockApiClient.getUser(tUserId))
          .thenAnswer((_) async => tUserDto);

      // Act
      final result = await repository.getUser(tUserId);

      // Assert
      expect(result, const Right<Failure, User>(tUser));
      verify(mockApiClient.getUser(tUserId)).called(1);
    });

    test('should cache data locally when API call is successful', () async {
      // Arrange
      when(mockApiClient.getUser(tUserId))
          .thenAnswer((_) async => tUserDto);
      when(mockLocalDataSource.cacheUser(any))
          .thenAnswer((_) async {});

      // Act
      await repository.getUser(tUserId);

      // Assert
      verify(mockLocalDataSource.cacheUser(tUserDto)).called(1);
    });

    test('should return cached data when API call fails', () async {
      // Arrange
      when(mockApiClient.getUser(tUserId))
          .thenThrow(Exception('Network error'));
      when(mockLocalDataSource.getCachedUser(tUserId))
          .thenAnswer((_) async => tUserDto);

      // Act
      final result = await repository.getUser(tUserId);

      // Assert
      expect(result, const Right<Failure, User>(tUser));
      verify(mockLocalDataSource.getCachedUser(tUserId)).called(1);
    });

    test('should return Failure when both API and cache fail', () async {
      // Arrange
      when(mockApiClient.getUser(tUserId))
          .thenThrow(Exception('Network error'));
      when(mockLocalDataSource.getCachedUser(tUserId))
          .thenAnswer((_) async => null);

      // Act
      final result = await repository.getUser(tUserId);

      // Assert
      expect(result.isLeft(), true);
      result.fold(
        (failure) => expect(failure, isA<CacheFailure>()),
        (_) => fail('Should return Left'),
      );
    });
  });
}

3. Fixture Pattern#

/// Home Feature 테스트 Fixture
abstract final class HomeFixture {
  /// 테스트용 User 객체
  static const User user = User(
    id: 1,
    name: '홍길동',
    email: 'hong@example.com',
    createdAt: DateTime(2024, 1, 1),
  );

  /// 테스트용 User 목록
  static const List<User> users = [
    User(id: 1, name: '홍길동', email: 'hong@example.com'),
    User(id: 2, name: '김철수', email: 'kim@example.com'),
    User(id: 3, name: '이영희', email: 'lee@example.com'),
  ];

  /// 테스트용 UserDto
  static const UserDto userDto = UserDto(
    id: 1,
    name: '홍길동',
    email: 'hong@example.com',
  );

  /// 테스트용 Failure
  static const ServerFailure serverFailure = ServerFailure(
    message: '서버 오류가 발생했습니다',
    statusCode: 500,
  );

  /// 테스트용 NetworkFailure
  static const NetworkFailure networkFailure = NetworkFailure(
    message: '네트워크 연결을 확인해주세요',
  );
}

4. Either Result Verification Helpers#

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

/// Either 결과 검증 확장
extension EitherTestExtension<L, R> on Either<L, R> {
  /// Left 값 추출 (테스트용)
  L getLeft() {
    return fold((l) => l, (_) => throw Exception('Expected Left but got Right'));
  }

  /// Right 값 추출 (테스트용)
  R getRight() {
    return fold((_) => throw Exception('Expected Right but got Left'), (r) => r);
  }
}

/// Either 매처
Matcher isRightWith<R>(R expected) {
  return predicate<Either<dynamic, R>>(
    (either) => either.fold((_) => false, (r) => r == expected),
    'is Right with $expected',
  );
}

Matcher isLeftWith<L>(L expected) {
  return predicate<Either<L, dynamic>>(
    (either) => either.fold((l) => l == expected, (_) => false),
    'is Left with $expected',
  );
}

Matcher isLeftOfType<L>() {
  return predicate<Either<L, dynamic>>(
    (either) => either.fold((l) => l is L, (_) => false),
    'is Left of type $L',
  );
}

5. Async Test Patterns#

group('async operations', () {
  test('should handle async operation correctly', () async {
    // Arrange
    when(mockRepository.fetchData())
        .thenAnswer((_) async {
          await Future.delayed(const Duration(milliseconds: 100));
          return const Right(data);
        });

    // Act
    final future = useCase();

    // Assert
    await expectLater(future, completes);
    final result = await future;
    expect(result.isRight(), true);
  });

  test('should timeout when operation takes too long', () async {
    // Arrange
    when(mockRepository.fetchData())
        .thenAnswer((_) async {
          await Future.delayed(const Duration(seconds: 10));
          return const Right(data);
        });

    // Act & Assert
    await expectLater(
      useCase().timeout(const Duration(seconds: 1)),
      throwsA(isA<TimeoutException>()),
    );
  });
});

Mockito Pattern Summary#

MethodPurposeExample
when(...).thenReturn()Set synchronous return valuewhen(mock.getValue()).thenReturn(42)
when(...).thenAnswer()Set async return valuewhen(mock.getData()).thenAnswer((_) async => data)
when(...).thenThrow()Set exception throwingwhen(mock.call()).thenThrow(Exception())
verify(...).called(n)Verify call countverify(mock.call()).called(1)
verifyNever(...)Verify not calledverifyNever(mock.call())
verifyNoMoreInteractions(...)Verify no additional callsverifyNoMoreInteractions(mock)
verifyZeroInteractions(...)Verify zero callsverifyZeroInteractions(mock)
reset(...)Reset mock statereset(mock)
anyMatch all valueswhen(mock.call(any)).thenReturn(true)
argThat(...)Conditional matchingargThat(isA<String>())
captureAnyArgument captureverify(mock.call(captureAny))

Build Commands#

# Mock 생성
cd feature/{module_type}/{feature_name}
dart run build_runner build --delete-conflicting-outputs

# Tests 실행
melos run test:select

# Test with coverage
melos run test:with-html-coverage

Reference Files#

feature/application/store/test/src/unit/usecase/
feature/application/store/test/src/unit/repository/
feature/common/auth/test/src/unit/

Checklist#

  • Add @GenerateNiceMocks Annotation
  • Generate mock classes (.mocks.dart)
  • Apply setUp/tearDown Pattern
  • Group tests with group
  • Apply Arrange-Act-Assert Pattern
  • Test both success/failure cases
  • Verify calls with verify
  • Verify Either results
  • Utilize Fixtures