iBetter Books
수정

통합 테스트와 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로 테스트를 분류해 필요한 테스트만 선택해서 실행합니다.

다음 챕터에서는 코드 커버리지를 측정하고 개선합니다.