목(Mock) 객체 활용 — mockito
단위 테스트는 빠르고 독립적이어야 합니다. 그런데 실제 파일 시스템이나 네트워크에 의존하는 코드는 어떻게 테스트할까요. Mock(목) 객체가 해답입니다.
Mock은 실제 객체를 흉내 내는 가짜 객체입니다. "이 메서드를 호출하면 이 값을 반환해라"고 지시할 수 있고, "이 메서드가 실제로 호출됐는지"도 검증할 수 있습니다. 파일 시스템에 접근하지 않고도 파일 시스템을 다루는 코드를 테스트합니다.
의존성 주입 — Mock을 위한 준비
Mock을 사용하려면 코드가 의존성을 외부에서 받아야 합니다. 이를 의존성 주입(Dependency Injection)이라고 합니다.
file_organizer에 파일 수집 작업을 추상화하는 인터페이스를 도입합니다.
// 새 파일: lib/src/services/file_service.dart
import 'dart:io';
import '../models/file_info.dart';
/// 파일 작업을 추상화하는 서비스 인터페이스.
///
/// 테스트에서는 MockFileService로 대체합니다.
abstract interface class FileService {
/// 디렉토리에서 파일 목록을 수집합니다.
Future<List<FileInfo>> collectFiles(
Directory dir, {
bool recursive = false,
Set<String>? extensions,
});
/// 파일을 안전하게 이동합니다.
Future<String> moveFile(File source, String destDir);
/// 파일의 내용을 문자열로 읽습니다.
Future<String> readAsString(File file);
/// 파일에 내용을 씁니다.
Future<void> writeAsString(File file, String content);
}
// 새 파일: lib/src/services/default_file_service.dart
import 'dart:io';
import '../models/file_info.dart';
import '../utils/file_utils.dart';
import 'file_service.dart';
/// [FileService]의 실제 구현체.
class DefaultFileService implements FileService {
const DefaultFileService();
@override
Future<List<FileInfo>> collectFiles(
Directory dir, {
bool recursive = false,
Set<String>? extensions,
}) {
return file_utils.collectFiles(dir, recursive: recursive, extensions: extensions);
}
@override
Future<String> moveFile(File source, String destDir) {
return moveFileSafely(source, destDir);
}
@override
Future<String> readAsString(File file) => file.readAsString();
@override
Future<void> writeAsString(File file, String content) =>
file.writeAsString(content);
}
mockito 설정
pubspec.yaml에 mockito와 코드 생성 도구를 추가합니다.
# 수정: pubspec.yamlname: file_organizerdescription: A CLI tool for organizing and transforming files.version: 1.0.0publish_to: noneenvironment: sdk: ">=3.0.0 <4.0.0"dependencies: args: ^2.4.2 path: ^1.9.0dev_dependencies: lints: ^3.0.0 test: ^1.24.0 mockito: ^5.4.4 build_runner: ^2.4.0
dart pub get
Mock 클래스 생성
mockito는 코드 생성 방식을 사용합니다. @GenerateMocks 어노테이션을 붙이면 build_runner가 Mock 클래스를 생성합니다.
// 새 파일: test/mocks/mocks.dart
import 'package:mockito/annotations.dart';
import 'package:file_organizer/src/services/file_service.dart';
@GenerateMocks([FileService])
void main() {}
dart run build_runner build
test/mocks/mocks.mocks.dart 파일이 자동 생성됩니다. 이 파일에 MockFileService 클래스가 포함됩니다.
when()과 verify()
when()으로 Mock의 동작을 지정하고, verify()로 메서드 호출을 검증합니다.
// 새 파일: test/services/file_service_test.dart
import 'dart:io';
import 'package:mockito/mockito.dart';
import 'package:test/test.dart';
import 'package:file_organizer/src/models/file_info.dart';
import '../mocks/mocks.mocks.dart';
void main() {
late MockFileService mockService;
setUp(() {
mockService = MockFileService();
});
group('MockFileService 기본 사용', () {
test('collectFiles 스텁 설정', () async {
// arrange: collectFiles가 반환할 값 지정
final fakeFiles = [
FileInfo(
path: '/tmp/photo.jpg',
name: 'photo.jpg',
extension: '.jpg',
sizeBytes: 1024,
modifiedAt: DateTime(2024),
),
FileInfo(
path: '/tmp/doc.pdf',
name: 'doc.pdf',
extension: '.pdf',
sizeBytes: 2048,
modifiedAt: DateTime(2024),
),
];
when(
mockService.collectFiles(
any,
recursive: anyNamed('recursive'),
extensions: anyNamed('extensions'),
),
).thenAnswer((_) async => fakeFiles);
// act
final result = await mockService.collectFiles(Directory('/tmp'));
// assert
expect(result, hasLength(2));
expect(result.first.name, equals('photo.jpg'));
});
test('moveFile 호출 검증', () async {
final source = File('/tmp/photo.jpg');
when(mockService.moveFile(source, '/tmp/이미지'))
.thenAnswer((_) async => '/tmp/이미지/photo.jpg');
await mockService.moveFile(source, '/tmp/이미지');
// moveFile이 정확히 한 번 호출됐는지 확인
verify(mockService.moveFile(source, '/tmp/이미지')).called(1);
});
test('호출되지 않은 메서드 검증', () async {
// 아무 메서드도 호출하지 않음
verifyNever(mockService.moveFile(any, any));
});
});
group('예외 처리', () {
test('collectFiles 예외 발생 스텁', () async {
when(mockService.collectFiles(any))
.thenThrow(FileSystemException('권한 없음'));
await expectLater(
() => mockService.collectFiles(Directory('/protected')),
throwsA(isA<FileSystemException>()),
);
});
});
}
OrganizeService 작성과 테스트
OrganizeCommand의 핵심 로직을 OrganizeService로 분리합니다. 서비스 레이어는 커맨드(UI 레이어)와 파일 시스템(인프라 레이어)을 분리합니다.
// 새 파일: lib/src/services/organize_service.dart
import 'dart:io';
import '../models/file_info.dart';
import '../utils/file_utils.dart';
import 'file_service.dart';
/// 파일 정리 비즈니스 로직.
class OrganizeService {
const OrganizeService(this._fileService);
final FileService _fileService;
/// 디렉토리를 정리하고 이동된 파일 목록을 반환합니다.
Future<OrganizeResult> organize(
Directory sourceDir,
String destBasePath, {
bool dryRun = false,
bool recursive = false,
}) async {
final files = await _fileService.collectFiles(
sourceDir,
recursive: recursive,
);
final moves = <FileMoveResult>[];
for (final fileInfo in files) {
final category = extensionToCategory(fileInfo.extension);
final targetDir = '$destBasePath/$category';
if (!dryRun) {
try {
final movedPath = await _fileService.moveFile(
File(fileInfo.path),
targetDir,
);
moves.add(FileMoveResult(
source: fileInfo,
destPath: movedPath,
success: true,
));
} catch (e) {
moves.add(FileMoveResult(
source: fileInfo,
destPath: targetDir,
success: false,
error: e.toString(),
));
}
} else {
moves.add(FileMoveResult(
source: fileInfo,
destPath: '$targetDir/${fileInfo.name}',
success: true,
dryRun: true,
));
}
}
return OrganizeResult(moves: moves, dryRun: dryRun);
}
}
/// 파일 이동 결과.
class FileMoveResult {
const FileMoveResult({
required this.source,
required this.destPath,
required this.success,
this.error,
this.dryRun = false,
});
final FileInfo source;
final String destPath;
final bool success;
final String? error;
final bool dryRun;
}
/// 정리 작업 전체 결과.
class OrganizeResult {
const OrganizeResult({required this.moves, required this.dryRun});
final List<FileMoveResult> moves;
final bool dryRun;
int get successCount => moves.where((m) => m.success).length;
int get failureCount => moves.where((m) => !m.success).length;
}
// 새 파일: test/services/organize_service_test.dart
import 'dart:io';
import 'package:mockito/mockito.dart';
import 'package:test/test.dart';
import 'package:file_organizer/src/models/file_info.dart';
import 'package:file_organizer/src/services/organize_service.dart';
import '../mocks/mocks.mocks.dart';
void main() {
late MockFileService mockFileService;
late OrganizeService organizeService;
setUp(() {
mockFileService = MockFileService();
organizeService = OrganizeService(mockFileService);
});
FileInfo makeFileInfo(String name, String ext) => FileInfo(
path: '/tmp/$name',
name: name,
extension: ext,
sizeBytes: 100,
modifiedAt: DateTime(2024),
);
group('organize', () {
test('파일 없으면 빈 결과 반환', () async {
when(mockFileService.collectFiles(any)).thenAnswer((_) async => []);
final result = await organizeService.organize(
Directory('/tmp'),
'/dest',
);
expect(result.moves, isEmpty);
expect(result.successCount, equals(0));
});
test('이미지 파일을 이미지 카테고리로 이동', () async {
final file = makeFileInfo('photo.jpg', '.jpg');
when(mockFileService.collectFiles(any)).thenAnswer((_) async => [file]);
when(mockFileService.moveFile(any, any))
.thenAnswer((_) async => '/dest/이미지/photo.jpg');
final result = await organizeService.organize(
Directory('/tmp'),
'/dest',
);
expect(result.successCount, equals(1));
expect(result.moves.first.destPath, contains('이미지'));
// moveFile이 올바른 대상 디렉토리로 호출됐는지 확인
verify(mockFileService.moveFile(any, '/dest/이미지')).called(1);
});
test('dry-run 모드에서 moveFile 미호출', () async {
final file = makeFileInfo('doc.pdf', '.pdf');
when(mockFileService.collectFiles(any)).thenAnswer((_) async => [file]);
final result = await organizeService.organize(
Directory('/tmp'),
'/dest',
dryRun: true,
);
// dry-run이므로 실제 이동 없음
verifyNever(mockFileService.moveFile(any, any));
expect(result.moves.first.dryRun, isTrue);
});
test('이동 실패 시 failureCount 증가', () async {
final file = makeFileInfo('photo.jpg', '.jpg');
when(mockFileService.collectFiles(any)).thenAnswer((_) async => [file]);
when(mockFileService.moveFile(any, any))
.thenThrow(FileSystemException('권한 없음'));
final result = await organizeService.organize(
Directory('/tmp'),
'/dest',
);
expect(result.successCount, equals(0));
expect(result.failureCount, equals(1));
expect(result.moves.first.error, isNotNull);
});
test('여러 파일 혼합 처리', () async {
final files = [
makeFileInfo('photo.jpg', '.jpg'),
makeFileInfo('doc.pdf', '.pdf'),
makeFileInfo('video.mp4', '.mp4'),
];
when(mockFileService.collectFiles(any)).thenAnswer((_) async => files);
when(mockFileService.moveFile(any, any))
.thenAnswer((inv) async {
final destDir = inv.positionalArguments[1] as String;
return '$destDir/file';
});
final result = await organizeService.organize(
Directory('/tmp'),
'/dest',
);
expect(result.successCount, equals(3));
// 각 카테고리로 분류됐는지 확인
final destinations = result.moves.map((m) => m.destPath).toList();
expect(destinations.any((d) => d.contains('이미지')), isTrue);
expect(destinations.any((d) => d.contains('문서')), isTrue);
expect(destinations.any((d) => d.contains('동영상')), isTrue);
});
});
}
any와 매처
mockito에서 인자를 유연하게 매칭하는 방법입니다.
// any: 어떤 값이든 매칭
when(mock.method(any)).thenReturn('value');
// anyNamed: 명명된 매개변수에 any 사용
when(mock.method(path: anyNamed('path'))).thenReturn('value');
// argThat: 조건부 매칭
when(mock.readFile(argThat(endsWith('.txt')))).thenReturn('text content');
// typed: 타입 지정
when(mock.process(typed<File>(any))).thenReturn(true);
정리
이번 챕터에서는 mockito로 의존성을 Mock하는 방법을 배웠습니다.
- 인터페이스를 도입해 의존성 주입 구조를 만들었습니다.
@GenerateMocks와build_runner로 Mock 클래스를 자동 생성했습니다.when()으로 Mock 동작을 지정하고verify()로 호출을 검증했습니다.OrganizeService를 커맨드에서 분리해 비즈니스 로직을 독립적으로 테스트했습니다.
다음 챕터에서는 여러 모듈을 연동하는 통합 테스트를 작성합니다.