통합 테스트와 E2E 테스트
단위 테스트는 각 부품을 검증합니다. 통합 테스트(Integration Test)는 부품들이 함께 동작하는지 검증합니다. E2E(End-to-End) 테스트는 사용자 관점에서 전체 흐름을 검증합니다.
file_organizer를 예로 들면, 단위 테스트는 parseCsvLine()이 올바르게 동작하는지 확인합니다. 통합 테스트는 OrganizeService가 실제 DefaultFileService와 함께 파일을 올바르게 이동하는지 확인합니다. E2E 테스트는 dart run bin/file_organizer.dart organize -s /tmp를 실행하고 결과를 확인합니다.
통합 테스트 — 실제 파일 시스템 사용
통합 테스트는 Mock을 사용하지 않고 실제 구현체를 사용합니다. 임시 디렉토리로 파일 시스템을 격리합니다.
// 새 파일: test/integration/organize_integration_test.dart
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
import 'package:file_organizer/src/services/default_file_service.dart';
import 'package:file_organizer/src/services/organize_service.dart';
void main() {
late Directory sourceDir;
late Directory destDir;
late OrganizeService service;
setUpAll(() {
service = OrganizeService(const DefaultFileService());
});
setUp(() async {
sourceDir = await Directory.systemTemp.createTemp('fo_source_');
destDir = await Directory.systemTemp.createTemp('fo_dest_');
});
tearDown(() async {
if (sourceDir.existsSync()) await sourceDir.delete(recursive: true);
if (destDir.existsSync()) await destDir.delete(recursive: true);
});
Future<File> createFile(String name, {String content = ''}) async {
final file = File(p.join(sourceDir.path, name));
await file.writeAsString(content);
return file;
}
group('OrganizeService 통합 테스트', () {
test('이미지 파일 분류', () async {
await createFile('photo.jpg');
await createFile('screenshot.png');
final result = await service.organize(sourceDir, destDir.path);
expect(result.successCount, equals(2));
final imageDir = Directory(p.join(destDir.path, '이미지'));
expect(imageDir.existsSync(), isTrue);
final files = imageDir.listSync().whereType<File>().toList();
expect(files, hasLength(2));
expect(
files.map((f) => p.basename(f.path)),
containsAll(['photo.jpg', 'screenshot.png']),
);
});
test('다양한 파일 카테고리 분류', () async {
await createFile('doc.pdf');
await createFile('video.mp4');
await createFile('archive.zip');
await createFile('code.dart');
final result = await service.organize(sourceDir, destDir.path);
expect(result.successCount, equals(4));
final categories = destDir.listSync().whereType<Directory>().toList();
final categoryNames = categories.map((d) => p.basename(d.path)).toSet();
expect(categoryNames, containsAll(['문서', '동영상', '압축', '코드']));
});
test('이름 충돌 처리', () async {
// 같은 이름의 파일이 대상에 이미 존재
await createFile('photo.jpg', content: 'original');
final existingDir = Directory(p.join(destDir.path, '이미지'));
await existingDir.create();
await File(p.join(existingDir.path, 'photo.jpg')).writeAsString('existing');
final result = await service.organize(sourceDir, destDir.path);
expect(result.successCount, equals(1));
// 이름 충돌로 다른 이름으로 저장됐는지 확인
final movedFile = File(result.moves.first.destPath);
expect(movedFile.existsSync(), isTrue);
expect(p.basename(movedFile.path), isNot(equals('photo.jpg')));
});
test('dry-run 모드에서 파일 미이동', () async {
await createFile('photo.jpg');
await createFile('doc.pdf');
final result = await service.organize(
sourceDir,
destDir.path,
dryRun: true,
);
expect(result.moves, hasLength(2));
expect(result.dryRun, isTrue);
// 원본 파일이 그대로 있어야 함
expect(File(p.join(sourceDir.path, 'photo.jpg')).existsSync(), isTrue);
expect(File(p.join(sourceDir.path, 'doc.pdf')).existsSync(), isTrue);
// 대상 디렉토리는 비어있어야 함
expect(destDir.listSync(), isEmpty);
});
test('재귀 탐색 — 하위 디렉토리 파일 포함', () async {
await createFile('root.jpg');
// 하위 디렉토리 생성 및 파일 추가
final subDir = await Directory(p.join(sourceDir.path, 'sub')).create();
await File(p.join(subDir.path, 'nested.png')).writeAsString('');
// 비재귀: root.jpg만
final nonRecursive = await service.organize(
sourceDir,
p.join(destDir.path, 'nonrecursive'),
);
expect(nonRecursive.successCount, equals(1));
// 재귀: root.jpg + nested.png
final recursive = await service.organize(
sourceDir,
p.join(destDir.path, 'recursive'),
recursive: true,
);
expect(recursive.successCount, equals(2));
});
});
}
여러 모듈 연동 테스트 — CSV 규칙 파일 + Rename
// 새 파일: test/integration/rename_integration_test.dart
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
import 'package:file_organizer/src/utils/csv_utils.dart';
import 'package:file_organizer/src/utils/file_utils.dart';
import 'package:file_organizer/src/utils/rename_utils.dart';
void main() {
late Directory workDir;
setUp(() async {
workDir = await Directory.systemTemp.createTemp('fo_rename_');
});
tearDown(() async {
if (workDir.existsSync()) await workDir.delete(recursive: true);
});
test('CSV 규칙 파일 → 파일 이름 변경 전체 흐름', () async {
// 1. 테스트 파일 생성
final files = ['IMG_001.jpg', 'IMG_002.jpg', 'VIDEO_001.mp4'];
for (final name in files) {
await File(p.join(workDir.path, name)).writeAsString('');
}
// 2. CSV 규칙 파일 생성
final rulesCsv = '''pattern,replacement,description
^IMG_,photo_,iPhone 사진 변환
^VIDEO_,clip_,동영상 변환''';
final csvFile = File(p.join(workDir.path, 'rules.csv'));
await csvFile.writeAsString(rulesCsv);
// 3. CSV 파싱 → 규칙 목록 생성
final csvContent = await csvFile.readAsString();
final rules = parseRulesFromCsv(csvContent)
.map((r) => (pattern: r.pattern, replacement: r.replacement))
.toList();
// 4. 파일 수집 및 이름 변경
final fileInfos = await collectFiles(workDir);
final renamedFiles = <String>[];
for (final info in fileInfos) {
if (info.name == 'rules.csv') continue; // CSV 파일 제외
final newName = applyRenameRules(info.name, rules);
if (newName != info.name) {
final newPath = p.join(workDir.path, newName);
await File(info.path).rename(newPath);
renamedFiles.add(newName);
}
}
// 5. 결과 검증
expect(renamedFiles, hasLength(3));
expect(
renamedFiles,
containsAll(['photo_001.jpg', 'photo_002.jpg', 'clip_001.mp4']),
);
// 원본 이름으로는 파일이 없어야 함
for (final original in files) {
expect(File(p.join(workDir.path, original)).existsSync(), isFalse);
}
});
}
E2E 테스트 — 프로세스 실행
E2E 테스트는 실제 실행 파일을 프로세스로 실행하고 결과를 확인합니다.
// 새 파일: test/e2e/cli_e2e_test.dart
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
void main() {
late Directory workDir;
setUp(() async {
workDir = await Directory.systemTemp.createTemp('fo_e2e_');
});
tearDown(() async {
if (workDir.existsSync()) await workDir.delete(recursive: true);
});
/// `dart run`으로 file_organizer를 실행합니다.
Future<ProcessResult> runCli(List<String> args) async {
return Process.run(
'dart',
['run', 'bin/file_organizer.dart', ...args],
workingDirectory: p.current,
);
}
group('CLI E2E 테스트', () {
test('--help 출력', () async {
final result = await runCli(['--help']);
expect(result.exitCode, equals(0));
expect(result.stdout as String, contains('file_organizer'));
expect(result.stdout as String, contains('organize'));
expect(result.stdout as String, contains('stats'));
expect(result.stdout as String, contains('rename'));
});
test('알 수 없는 커맨드는 exitCode 64', () async {
final result = await runCli(['unknown_command']);
expect(result.exitCode, equals(64));
expect(result.stderr as String, isNotEmpty);
});
test('stats 커맨드 실행', () async {
// 테스트 파일 생성
await File(p.join(workDir.path, 'a.txt')).writeAsString('hello');
await File(p.join(workDir.path, 'b.jpg')).writeAsBytes([0, 1]);
final result = await runCli(['stats', '-p', workDir.path]);
expect(result.exitCode, equals(0));
final stdout = result.stdout as String;
// 통계 테이블 헤더 확인
expect(stdout, contains('카테고리'));
expect(stdout, contains('파일 수'));
});
test('organize dry-run 커맨드', () async {
await File(p.join(workDir.path, 'photo.jpg')).writeAsBytes([]);
await File(p.join(workDir.path, 'doc.pdf')).writeAsBytes([]);
final result = await runCli([
'organize',
'-s', workDir.path,
'--dry-run',
'--yes',
]);
expect(result.exitCode, equals(0));
// 파일은 그대로 있어야 함
expect(
File(p.join(workDir.path, 'photo.jpg')).existsSync(),
isTrue,
);
});
test('stats --format json 출력', () async {
await File(p.join(workDir.path, 'code.dart')).writeAsString('void main(){}');
final result = await runCli([
'stats',
'-p', workDir.path,
'--format', 'json',
]);
expect(result.exitCode, equals(0));
final stdout = result.stdout as String;
// JSON 형식 확인
expect(stdout, contains('{'));
expect(stdout, contains('"count"'));
});
});
}
테스트 실행 — 태그로 구분
E2E 테스트는 단위 테스트보다 느립니다. @Tags 어노테이션으로 분리해서 실행할 수 있습니다.
// e2e 테스트 파일 맨 위에 추가
@Tags(['e2e'])
library;
import 'package:test/test.dart';
// ...
# 단위 테스트만 실행 (e2e 제외)dart test --exclude-tags e2e# e2e 테스트만 실행dart test --tags e2e# 전체 실행dart test
테스트 구조 정리
완성된 테스트 디렉토리 구조입니다.
test/
├── e2e/
│ └── cli_e2e_test.dart ← E2E (프로세스 실행)
├── helpers/
│ └── test_helpers.dart ← 공통 헬퍼
├── integration/
│ ├── organize_integration_test.dart
│ └── rename_integration_test.dart
├── mocks/
│ ├── mocks.dart ← @GenerateMocks 정의
│ └── mocks.mocks.dart ← 자동 생성
├── models/
│ └── file_info_test.dart
├── services/
│ ├── file_service_test.dart
│ └── organize_service_test.dart
└── utils/
├── csv_utils_test.dart
├── file_utils_async_test.dart
└── rename_utils_test.dart
정리
이번 챕터에서는 테스트 피라미드의 세 층을 모두 다뤘습니다.
- 통합 테스트는 실제 파일 시스템을 사용하되 임시 디렉토리로 격리합니다.
- 여러 모듈이 연동되는 흐름을 단일 테스트로 검증합니다.
- E2E 테스트는
Process.run()으로 CLI 프로세스를 직접 실행합니다. @Tags로 테스트를 분류해 필요한 테스트만 선택해서 실행합니다.
다음 챕터에서는 코드 커버리지를 측정하고 개선합니다.