단위 테스트 작성하기
단위 테스트(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으로 대체하는 방법을 배웁니다.