iBetter Books
수정

단위 테스트 작성하기

단위 테스트(Unit Test)는 코드의 가장 작은 단위, 즉 함수나 클래스의 메서드를 독립적으로 검증합니다. "이 함수에 이 입력을 주면 이 출력이 나온다"를 자동화합니다.

좋은 단위 테스트는 빠르고, 독립적이며, 반복 가능합니다. 파일 시스템, 네트워크, 데이터베이스에 의존하지 않습니다. 그런 의존성이 있다면 Mock으로 대체합니다(다음 챕터에서 다룹니다).

순수 함수 테스트 — csv_utils.dart

csv_utils.dart의 함수들은 입력이 같으면 항상 같은 출력을 반환하는 순수 함수입니다. 테스트하기 가장 쉬운 형태입니다.

// 새 파일: test/utils/csv_utils_test.dart
import 'package:test/test.dart';
import 'package:file_organizer/src/utils/csv_utils.dart';

void main() {
  group('parseCsvLine', () {
    group('기본 동작', () {
      test('단일 필드', () {
        expect(parseCsvLine('hello'), equals(['hello']));
      });

      test('다중 필드', () {
        expect(parseCsvLine('a,b,c'), equals(['a', 'b', 'c']));
      });

      test('빈 문자열', () {
        expect(parseCsvLine(''), equals(['']));
      });

      test('앞뒤 공백 유지', () {
        expect(parseCsvLine(' a , b '), equals([' a ', ' b ']));
      });
    });

    group('큰따옴표 처리', () {
      test('따옴표로 감싼 필드', () {
        expect(parseCsvLine('"hello"'), equals(['hello']));
      });

      test('따옴표 안의 쉼표', () {
        expect(parseCsvLine('"a,b",c'), equals(['a,b', 'c']));
      });

      test('이스케이프된 따옴표 ("" → ")', () {
        expect(parseCsvLine('"say ""hi"""'), equals(['say "hi"']));
      });

      test('빈 따옴표 필드', () {
        expect(parseCsvLine('"",b'), equals(['', 'b']));
      });
    });

    group('경계 케이스', () {
      test('끝에 쉼표', () {
        expect(parseCsvLine('a,b,'), equals(['a', 'b', '']));
      });

      test('쉼표만 있는 경우', () {
        expect(parseCsvLine(','), equals(['', '']));
      });
    });
  });

  group('toCsv', () {
    test('빈 목록', () {
      expect(toCsv([]), equals(''));
    });

    test('헤더와 데이터 행 생성', () {
      final data = [
        {'name': 'Alice', 'age': '30'},
        {'name': 'Bob', 'age': '25'},
      ];
      final csv = toCsv(data);
      final lines = csv.trim().split('\n');

      expect(lines[0], equals('name,age'));
      expect(lines[1], equals('Alice,30'));
      expect(lines[2], equals('Bob,25'));
    });

    test('쉼표 포함 값은 큰따옴표로 감쌈', () {
      final data = [
        {'desc': 'hello, world'},
      ];
      final csv = toCsv(data);
      expect(csv, contains('"hello, world"'));
    });

    test('큰따옴표 포함 값은 이스케이프', () {
      final data = [
        {'desc': 'say "hi"'},
      ];
      final csv = toCsv(data);
      expect(csv, contains('"say ""hi"""'));
    });
  });

  group('parseRulesFromCsv', () {
    test('정상 규칙 파싱', () {
      const csv = '''pattern,replacement,description
^IMG_(\\d+),photo_\$1,iPhone 사진
\\s+,_,공백 변환''';

      final rules = parseRulesFromCsv(csv);

      expect(rules, hasLength(2));
      expect(rules[0].pattern, equals(r'^IMG_(\d+)'));
      expect(rules[0].replacement, equals(r'photo_$1'));
      expect(rules[0].description, equals('iPhone 사진'));
      expect(rules[1].description, equals('공백 변환'));
    });

    test('빈 패턴 행은 무시', () {
      const csv = 'pattern,replacement\n,replacement_only\nvalid,ok';
      final rules = parseRulesFromCsv(csv);
      expect(rules, hasLength(1));
      expect(rules[0].pattern, equals('valid'));
    });

    test('설명 없는 규칙', () {
      const csv = 'pattern,replacement,description\nfoo,bar,';
      final rules = parseRulesFromCsv(csv);
      expect(rules[0].description, isNull);
    });
  });
}

클래스 메서드 테스트 — FileInfo

// 새 파일: test/models/file_info_test.dart
import 'package:test/test.dart';
import 'package:file_organizer/src/models/file_info.dart';

void main() {
  // 테스트용 FileInfo 팩토리
  FileInfo makeFileInfo({
    String path = '/tmp/test.txt',
    String name = 'test.txt',
    String extension = '.txt',
    int sizeBytes = 0,
  }) {
    return FileInfo(
      path: path,
      name: name,
      extension: extension,
      sizeBytes: sizeBytes,
      modifiedAt: DateTime(2024, 1, 1),
    );
  }

  group('humanReadableSize', () {
    test('0 바이트', () {
      final info = makeFileInfo(sizeBytes: 0);
      expect(info.humanReadableSize, equals('0 B'));
    });

    test('1023 바이트는 B 단위', () {
      final info = makeFileInfo(sizeBytes: 1023);
      expect(info.humanReadableSize, equals('1023 B'));
    });

    test('1024 바이트는 1.0 KB', () {
      final info = makeFileInfo(sizeBytes: 1024);
      expect(info.humanReadableSize, equals('1.0 KB'));
    });

    test('1.5 KB', () {
      final info = makeFileInfo(sizeBytes: 1536);
      expect(info.humanReadableSize, equals('1.5 KB'));
    });

    test('정확히 1 MB', () {
      final info = makeFileInfo(sizeBytes: 1024 * 1024);
      expect(info.humanReadableSize, equals('1.0 MB'));
    });

    test('1 GB', () {
      final info = makeFileInfo(sizeBytes: 1024 * 1024 * 1024);
      expect(info.humanReadableSize, equals('1.0 GB'));
    });
  });

  group('toString', () {
    test('이름과 크기 포함', () {
      final info = makeFileInfo(name: 'hello.txt', sizeBytes: 512);
      expect(info.toString(), equals('FileInfo(hello.txt, 512 B)'));
    });
  });
}

정규식 기반 이름 변환 테스트 — rename 로직

rename 커맨드의 핵심 로직을 분리해서 테스트합니다. 커맨드에서 로직을 함수로 추출하면 테스트하기 쉬워집니다.

// 새 파일: lib/src/utils/rename_utils.dart

/// 파일 이름에 규칙 목록을 순서대로 적용합니다.
String applyRenameRules(
  String fileName,
  List<({String pattern, String replacement})> rules,
) {
  var result = fileName;
  for (final rule in rules) {
    final regex = RegExp(rule.pattern);
    result = result.replaceAll(regex, rule.replacement);
  }
  return result;
}
// 새 파일: test/utils/rename_utils_test.dart
import 'package:test/test.dart';
import 'package:file_organizer/src/utils/rename_utils.dart';

void main() {
  group('applyRenameRules', () {
    test('규칙 없으면 원본 반환', () {
      expect(applyRenameRules('photo.jpg', []), equals('photo.jpg'));
    });

    test('단일 규칙 적용', () {
      final rules = [(pattern: r'\s+', replacement: '_')];
      expect(
        applyRenameRules('my photo.jpg', rules),
        equals('my_photo.jpg'),
      );
    });

    test('여러 규칙 순서대로 적용', () {
      final rules = [
        (pattern: r'^IMG_', replacement: 'photo_'),
        (pattern: r'\.jpeg$', replacement: '.jpg'),
      ];
      expect(
        applyRenameRules('IMG_001.jpeg', rules),
        equals('photo_001.jpg'),
      );
    });

    test('일치하지 않으면 원본 유지', () {
      final rules = [(pattern: r'^VIDEO_', replacement: 'clip_')];
      expect(
        applyRenameRules('IMG_001.jpg', rules),
        equals('IMG_001.jpg'),
      );
    });

    test('캡처 그룹 활용', () {
      final rules = [
        (pattern: r'^(\d{4})(\d{2})(\d{2})_', replacement: r'$1-$2-$3_'),
      ];
      expect(
        applyRenameRules('20241231_photo.jpg', rules),
        equals('2024-12-31_photo.jpg'),
      );
    });

    test('전체 매치 replaceAll', () {
      final rules = [(pattern: ' ', replacement: '_')];
      expect(
        applyRenameRules('my old file.txt', rules),
        equals('my_old_file.txt'),
      );
    });
  });
}

비동기 테스트 — ensureDirectory

// 새 파일: test/utils/file_utils_async_test.dart
import 'dart:io';

import 'package:path/path.dart' as p;
import 'package:test/test.dart';
import 'package:file_organizer/src/utils/file_utils.dart';

void main() {
  late Directory tempDir;

  setUp(() async {
    tempDir = await Directory.systemTemp.createTemp('fo_test_');
  });

  tearDown(() async {
    if (tempDir.existsSync()) {
      await tempDir.delete(recursive: true);
    }
  });

  group('ensureDirectory', () {
    test('존재하지 않는 디렉토리 생성', () async {
      final newPath = p.join(tempDir.path, 'new', 'nested', 'dir');
      expect(Directory(newPath).existsSync(), isFalse);

      await ensureDirectory(newPath);

      expect(Directory(newPath).existsSync(), isTrue);
    });

    test('이미 존재하는 디렉토리는 그대로', () async {
      // 두 번 호출해도 에러 없음
      await ensureDirectory(tempDir.path);
      await ensureDirectory(tempDir.path);
      expect(tempDir.existsSync(), isTrue);
    });

    test('생성된 디렉토리 반환', () async {
      final newPath = p.join(tempDir.path, 'created');
      final result = await ensureDirectory(newPath);
      expect(result.path, equals(newPath));
    });
  });

  group('moveFileSafely', () {
    test('파일 이동', () async {
      final source = File(p.join(tempDir.path, 'source.txt'));
      await source.writeAsString('content');

      final destDir = p.join(tempDir.path, 'dest');
      await moveFileSafely(source, destDir);

      expect(source.existsSync(), isFalse);
      expect(File(p.join(destDir, 'source.txt')).existsSync(), isTrue);
    });

    test('이름 충돌 시 번호 추가', () async {
      final source1 = File(p.join(tempDir.path, 'file.txt'));
      final source2 = File(p.join(tempDir.path, 'file.txt'));
      final destDir = p.join(tempDir.path, 'dest');

      await ensureDirectory(destDir);

      // 대상에 파일 미리 생성
      await File(p.join(destDir, 'file.txt')).writeAsString('existing');
      await source1.writeAsString('new');

      final movedPath = await moveFileSafely(source1, destDir);

      // 원본 이름이 아닌 다른 이름으로 저장
      expect(p.basename(movedPath), isNot(equals('file.txt')));
      expect(File(movedPath).existsSync(), isTrue);
    });
  });
}

테스트 헬퍼 — 반복 코드 줄이기

여러 테스트에서 반복되는 FileInfo 생성 코드를 헬퍼로 분리합니다.

// 새 파일: test/helpers/test_helpers.dart
import 'dart:io';

import 'package:path/path.dart' as p;
import 'package:file_organizer/src/models/file_info.dart';

/// 테스트용 FileInfo 생성 헬퍼.
FileInfo makeFileInfo({
  String path = '/tmp/test.txt',
  String name = 'test.txt',
  String extension = '.txt',
  int sizeBytes = 0,
  DateTime? modifiedAt,
}) {
  return FileInfo(
    path: path,
    name: name,
    extension: extension,
    sizeBytes: sizeBytes,
    modifiedAt: modifiedAt ?? DateTime(2024, 1, 1),
  );
}

/// 임시 디렉토리에 테스트 파일을 생성합니다.
Future<File> createTestFile(
  Directory dir,
  String name, {
  String content = 'test content',
}) async {
  final file = File(p.join(dir.path, name));
  await file.writeAsString(content);
  return file;
}

테스트 실행 및 결과 확인

dart test --reporter expanded
00:01 +0: loading test/utils/csv_utils_test.dart
00:01 +0: loading test/models/file_info_test.dart
...

test/utils/csv_utils_test.dart: parseCsvLine 기본 동작
  ✓ 단일 필드
  ✓ 다중 필드
  ✓ 빈 문자열
  ✓ 앞뒤 공백 유지

test/utils/csv_utils_test.dart: parseCsvLine 큰따옴표 처리
  ✓ 따옴표로 감싼 필드
  ✓ 따옴표 안의 쉼표
  ✓ 이스케이프된 따옴표 ("" → ")

...

00:02 +28: All tests passed!

정리

이번 챕터에서는 file_organizer의 핵심 로직에 단위 테스트를 작성했습니다.

  • 순수 함수는 입력/출력 쌍으로 직접 검증합니다.
  • 클래스 메서드 테스트는 팩토리 헬퍼로 반복 코드를 줄입니다.
  • 비동기 파일 시스템 테스트는 임시 디렉토리로 격리합니다.
  • 테스트 대상 로직을 함수로 추출하면 테스트하기 쉬워집니다.

다음 챕터에서는 외부 의존성을 Mock으로 대체하는 방법을 배웁니다.