/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)
- UseCase 테스트:
- "성공했을 때"뿐 아니라 "실패했을 때도 올바르게 처리되는지" 까지 함께 확인하도록 만들어 줍니다.
어떻게 쓰나요#
# 핵심 로직 테스트 만들기 (기본)
/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
안에서 무슨 일이 벌어지나요#
- 검사할 코드가 UseCase인지 Repository인지 파악하고, 정해진 폴더 구조에 맞춰 테스트 파일을 둘 위치를 잡습니다.
- 진짜 외부 의존성(서버, 데이터베이스) 대신 가짜 대역(Mock) 을 만들어, 외부 상황과 상관없이 로직만 깔끔하게 검사할 수 있게 준비합니다.
- "준비 → 실행 → 검증" 3단계 패턴으로 각 상황을 점검합니다 — 정상 동작, 서버 오류, 잘못된 입력, 캐시(임시 저장) 활용, 느린 응답·시간 초과 등.
- 만든 테스트가 빠짐없이 갖춰졌는지 체크리스트로 점검하고 마무리합니다.
⚙️ 상세 옵션·실행 명세 (개발자 / 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 unitActivated when command is invoked- Invoked when writing UseCase and Repository tests
Parameters#
| Parameter | Required | Description |
|---|---|---|
target_class | ✅ | Test target Class name |
target_type | ❌ | usecase, repository (default: usecase) |
feature_name | ❌ | Feature 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 pointImport 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#
| Method | Purpose | Example |
|---|---|---|
when(...).thenReturn() | Set synchronous return value | when(mock.getValue()).thenReturn(42) |
when(...).thenAnswer() | Set async return value | when(mock.getData()).thenAnswer((_) async => data) |
when(...).thenThrow() | Set exception throwing | when(mock.call()).thenThrow(Exception()) |
verify(...).called(n) | Verify call count | verify(mock.call()).called(1) |
verifyNever(...) | Verify not called | verifyNever(mock.call()) |
verifyNoMoreInteractions(...) | Verify no additional calls | verifyNoMoreInteractions(mock) |
verifyZeroInteractions(...) | Verify zero calls | verifyZeroInteractions(mock) |
reset(...) | Reset mock state | reset(mock) |
any | Match all values | when(mock.call(any)).thenReturn(true) |
argThat(...) | Conditional matching | argThat(isA<String>()) |
captureAny | Argument capture | verify(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-coverageReference 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