테스트의 기초 — dart test
코드가 예상대로 동작한다는 확신은 어디서 오는가. "직접 실행해봤으니까"라는 대답은 불안합니다. 코드가 바뀔 때마다 다시 실행해야 하고, 엣지 케이스를 놓칠 수 있습니다. 테스트가 그 확신을 만들어 줍니다.
PART 05에서는 PART 04에서 만든 file_organizer를 테스트 대상으로 씁니다. 새로운 코드를 배울 필요 없이, 익숙한 코드에 테스트를 더합니다.
test 패키지
Dart 공식 테스트 프레임워크는 test 패키지입니다. file_organizer의 pubspec.yaml에 이미 추가되어 있습니다.
dev_dependencies: test: ^1.24.0
dev_dependencies에 포함된 패키지는 개발과 테스트에서만 사용되고, 배포 시 포함되지 않습니다.
첫 번째 테스트
test/ 디렉토리에 _test.dart 접미어로 파일을 만들면 dart test가 자동으로 찾아 실행합니다.
// 새 파일: test/models/file_info_test.dart
import 'package:test/test.dart';
import 'package:file_organizer/src/models/file_info.dart';
void main() {
group('FileInfo.humanReadableSize', () {
test('바이트 단위 반환', () {
// arrange
final info = FileInfo(
path: '/tmp/a.txt',
name: 'a.txt',
extension: '.txt',
sizeBytes: 512,
modifiedAt: DateTime.now(),
);
// act + assert
expect(info.humanReadableSize, equals('512 B'));
});
test('킬로바이트 단위 반환', () {
final info = FileInfo(
path: '/tmp/a.txt',
name: 'a.txt',
extension: '.txt',
sizeBytes: 2048,
modifiedAt: DateTime.now(),
);
expect(info.humanReadableSize, equals('2.0 KB'));
});
test('메가바이트 단위 반환', () {
final info = FileInfo(
path: '/tmp/a.txt',
name: 'a.txt',
extension: '.txt',
sizeBytes: 1024 * 1024 * 3,
modifiedAt: DateTime.now(),
);
expect(info.humanReadableSize, equals('3.0 MB'));
});
});
}
dart test
00:00 +3: All tests passed!
test()와 expect()
test(description, body)는 테스트 케이스를 정의합니다.
expect(actual, matcher)는 실제 값이 매처를 만족하는지 검증합니다. 실패하면 테스트가 중단되고 차이점을 출력합니다.
// 기본 매처
expect(value, equals(42));
expect(value, isNotNull);
expect(value, isNull);
expect(value, isTrue);
expect(value, isFalse);
expect(value, isA<String>());
// 컬렉션 매처
expect(list, isEmpty);
expect(list, isNotEmpty);
expect(list, hasLength(3));
expect(list, contains('item'));
expect(list, containsAll(['a', 'b']));
expect(list, orderedEquals(['a', 'b', 'c']));
// 문자열 매처
expect(str, startsWith('hello'));
expect(str, endsWith('world'));
expect(str, contains('dart'));
expect(str, matches(RegExp(r'^\d+$')));
// 수치 매처
expect(value, greaterThan(10));
expect(value, lessThanOrEqualTo(100));
expect(value, closeTo(3.14, 0.01)); // 오차 범위 내
group() — 테스트 묶기
관련 테스트를 group()으로 묶으면 구조가 명확해집니다.
// 새 파일: test/utils/csv_utils_test.dart
import 'package:test/test.dart';
import 'package:file_organizer/src/utils/csv_utils.dart';
void main() {
group('parseCsvLine', () {
test('단순 CSV 파싱', () {
final fields = parseCsvLine('a,b,c');
expect(fields, equals(['a', 'b', 'c']));
});
test('빈 필드 포함', () {
final fields = parseCsvLine('a,,c');
expect(fields, equals(['a', '', 'c']));
});
test('큰따옴표 필드', () {
final fields = parseCsvLine('"hello, world",b');
expect(fields, equals(['hello, world', 'b']));
});
test('따옴표 이스케이프 ("" → ")', () {
final fields = parseCsvLine('"say ""hello""",b');
expect(fields, equals(['say "hello"', 'b']));
});
});
group('parseCsv', () {
test('헤더 포함 CSV 파싱', () {
const csv = 'name,age\nAlice,30\nBob,25';
final rows = parseCsv(csv);
expect(rows, hasLength(2));
expect(rows[0], equals({'name': 'Alice', 'age': '30'}));
expect(rows[1], equals({'name': 'Bob', 'age': '25'}));
});
test('빈 문자열 처리', () {
final rows = parseCsv('');
expect(rows, isEmpty);
});
});
}
group() 안에 group()을 중첩할 수도 있습니다. 테스트 출력에는 계층 구조가 반영됩니다.
setUp()과 tearDown()
각 테스트 전후에 공통 설정/정리 코드를 실행합니다.
// 새 파일: test/utils/file_utils_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('file_organizer_test_');
});
tearDown(() async {
// 각 테스트 후: 임시 디렉토리 삭제
if (tempDir.existsSync()) {
await tempDir.delete(recursive: true);
}
});
group('collectFiles', () {
test('빈 디렉토리는 빈 목록 반환', () async {
final files = await collectFiles(tempDir);
expect(files, isEmpty);
});
test('파일 수집', () async {
// 테스트 파일 생성
await File(p.join(tempDir.path, 'a.txt')).writeAsString('hello');
await File(p.join(tempDir.path, 'b.dart')).writeAsString('void main(){}');
await File(p.join(tempDir.path, 'c.jpg')).writeAsBytes([0, 1, 2]);
final files = await collectFiles(tempDir);
expect(files, hasLength(3));
});
test('확장자 필터링', () async {
await File(p.join(tempDir.path, 'a.txt')).writeAsString('hello');
await File(p.join(tempDir.path, 'b.dart')).writeAsString('dart');
await File(p.join(tempDir.path, 'c.txt')).writeAsString('world');
final files = await collectFiles(
tempDir,
extensions: {'.txt'},
);
expect(files, hasLength(2));
expect(files.every((f) => f.extension == '.txt'), isTrue);
});
});
group('extensionToCategory', () {
test('이미지 확장자', () {
expect(extensionToCategory('.jpg'), equals('이미지'));
expect(extensionToCategory('.png'), equals('이미지'));
});
test('알 수 없는 확장자는 기타', () {
expect(extensionToCategory('.xyz'), equals('기타'));
});
});
}
setUpAll()과 tearDownAll()은 그룹 전체에서 한 번만 실행됩니다. 데이터베이스 연결처럼 비용이 큰 리소스를 공유할 때 사용합니다.
비동기 테스트
비동기 함수를 테스트할 때는 test() 콜백을 async로 만들면 됩니다.
test('비동기 파일 읽기', () async {
final file = File(p.join(tempDir.path, 'test.txt'));
await file.writeAsString('hello');
final content = await file.readAsString();
expect(content, equals('hello'));
});
예외가 발생해야 하는 경우는 throwsA를 사용합니다.
test('존재하지 않는 파일 읽기는 예외 발생', () async {
final file = File('/nonexistent/path/file.txt');
await expectLater(
() => file.readAsString(),
throwsA(isA<FileSystemException>()),
);
});
expectLater는 비동기 검증에 사용합니다. Future를 인자로 받아 완료를 기다립니다.
skip과 solo
특정 테스트를 건너뛰거나 집중해서 실행할 수 있습니다.
test('아직 구현 전', () {
// ...
}, skip: '미구현');
test('이것만 실행', () {
// ...
}, solo: true); // 이 테스트만 실행, 나머지는 건너뜀
solo는 빠른 디버깅에 유용하지만, 커밋 전에 반드시 제거해야 합니다.
테스트 실행 옵션
# 전체 테스트 실행dart test# 특정 파일만dart test test/utils/csv_utils_test.dart# 특정 이름 패턴dart test --name "파싱"# 상세 출력dart test --reporter expanded# 병렬 실행 (기본값)dart test --concurrency=4
정리
이번 챕터에서는 test 패키지의 기본 사용법을 익혔습니다.
test(),group(),expect()로 테스트를 구조화합니다.setUp()/tearDown()으로 테스트 전후 상태를 관리합니다.- 비동기 함수는
async콜백과expectLater로 테스트합니다. - 임시 디렉토리를 활용해 파일 시스템 테스트를 격리합니다.
다음 챕터에서는 file_organizer의 핵심 로직을 대상으로 단위 테스트를 본격적으로 작성합니다.