iBetter Books
수정

목(Mock) 객체 활용 — mockito

단위 테스트는 빠르고 독립적이어야 합니다. 그런데 실제 파일 시스템이나 네트워크에 의존하는 코드는 어떻게 테스트할까요. Mock(목) 객체가 해답입니다.

Mock은 실제 객체를 흉내 내는 가짜 객체입니다. "이 메서드를 호출하면 이 값을 반환해라"고 지시할 수 있고, "이 메서드가 실제로 호출됐는지"도 검증할 수 있습니다. 파일 시스템에 접근하지 않고도 파일 시스템을 다루는 코드를 테스트합니다.

의존성 주입 — Mock을 위한 준비

Mock을 사용하려면 코드가 의존성을 외부에서 받아야 합니다. 이를 의존성 주입(Dependency Injection)이라고 합니다.

file_organizer에 파일 수집 작업을 추상화하는 인터페이스를 도입합니다.

// 새 파일: lib/src/services/file_service.dart
import 'dart:io';

import '../models/file_info.dart';

/// 파일 작업을 추상화하는 서비스 인터페이스.
///
/// 테스트에서는 MockFileService로 대체합니다.
abstract interface class FileService {
  /// 디렉토리에서 파일 목록을 수집합니다.
  Future<List<FileInfo>> collectFiles(
    Directory dir, {
    bool recursive = false,
    Set<String>? extensions,
  });

  /// 파일을 안전하게 이동합니다.
  Future<String> moveFile(File source, String destDir);

  /// 파일의 내용을 문자열로 읽습니다.
  Future<String> readAsString(File file);

  /// 파일에 내용을 씁니다.
  Future<void> writeAsString(File file, String content);
}
// 새 파일: lib/src/services/default_file_service.dart
import 'dart:io';

import '../models/file_info.dart';
import '../utils/file_utils.dart';
import 'file_service.dart';

/// [FileService]의 실제 구현체.
class DefaultFileService implements FileService {
  const DefaultFileService();

  @override
  Future<List<FileInfo>> collectFiles(
    Directory dir, {
    bool recursive = false,
    Set<String>? extensions,
  }) {
    return file_utils.collectFiles(dir, recursive: recursive, extensions: extensions);
  }

  @override
  Future<String> moveFile(File source, String destDir) {
    return moveFileSafely(source, destDir);
  }

  @override
  Future<String> readAsString(File file) => file.readAsString();

  @override
  Future<void> writeAsString(File file, String content) =>
      file.writeAsString(content);
}

mockito 설정

pubspec.yamlmockito와 코드 생성 도구를 추가합니다.

# 수정: pubspec.yamlname: file_organizerdescription: A CLI tool for organizing and transforming files.version: 1.0.0publish_to: noneenvironment:  sdk: ">=3.0.0 <4.0.0"dependencies:  args: ^2.4.2  path: ^1.9.0dev_dependencies:  lints: ^3.0.0  test: ^1.24.0  mockito: ^5.4.4  build_runner: ^2.4.0
dart pub get

Mock 클래스 생성

mockito는 코드 생성 방식을 사용합니다. @GenerateMocks 어노테이션을 붙이면 build_runner가 Mock 클래스를 생성합니다.

// 새 파일: test/mocks/mocks.dart
import 'package:mockito/annotations.dart';
import 'package:file_organizer/src/services/file_service.dart';

@GenerateMocks([FileService])
void main() {}
dart run build_runner build

test/mocks/mocks.mocks.dart 파일이 자동 생성됩니다. 이 파일에 MockFileService 클래스가 포함됩니다.

when()과 verify()

when()으로 Mock의 동작을 지정하고, verify()로 메서드 호출을 검증합니다.

// 새 파일: test/services/file_service_test.dart
import 'dart:io';

import 'package:mockito/mockito.dart';
import 'package:test/test.dart';
import 'package:file_organizer/src/models/file_info.dart';

import '../mocks/mocks.mocks.dart';

void main() {
  late MockFileService mockService;

  setUp(() {
    mockService = MockFileService();
  });

  group('MockFileService 기본 사용', () {
    test('collectFiles 스텁 설정', () async {
      // arrange: collectFiles가 반환할 값 지정
      final fakeFiles = [
        FileInfo(
          path: '/tmp/photo.jpg',
          name: 'photo.jpg',
          extension: '.jpg',
          sizeBytes: 1024,
          modifiedAt: DateTime(2024),
        ),
        FileInfo(
          path: '/tmp/doc.pdf',
          name: 'doc.pdf',
          extension: '.pdf',
          sizeBytes: 2048,
          modifiedAt: DateTime(2024),
        ),
      ];

      when(
        mockService.collectFiles(
          any,
          recursive: anyNamed('recursive'),
          extensions: anyNamed('extensions'),
        ),
      ).thenAnswer((_) async => fakeFiles);

      // act
      final result = await mockService.collectFiles(Directory('/tmp'));

      // assert
      expect(result, hasLength(2));
      expect(result.first.name, equals('photo.jpg'));
    });

    test('moveFile 호출 검증', () async {
      final source = File('/tmp/photo.jpg');
      when(mockService.moveFile(source, '/tmp/이미지'))
          .thenAnswer((_) async => '/tmp/이미지/photo.jpg');

      await mockService.moveFile(source, '/tmp/이미지');

      // moveFile이 정확히 한 번 호출됐는지 확인
      verify(mockService.moveFile(source, '/tmp/이미지')).called(1);
    });

    test('호출되지 않은 메서드 검증', () async {
      // 아무 메서드도 호출하지 않음
      verifyNever(mockService.moveFile(any, any));
    });
  });

  group('예외 처리', () {
    test('collectFiles 예외 발생 스텁', () async {
      when(mockService.collectFiles(any))
          .thenThrow(FileSystemException('권한 없음'));

      await expectLater(
        () => mockService.collectFiles(Directory('/protected')),
        throwsA(isA<FileSystemException>()),
      );
    });
  });
}

OrganizeService 작성과 테스트

OrganizeCommand의 핵심 로직을 OrganizeService로 분리합니다. 서비스 레이어는 커맨드(UI 레이어)와 파일 시스템(인프라 레이어)을 분리합니다.

// 새 파일: lib/src/services/organize_service.dart
import 'dart:io';

import '../models/file_info.dart';
import '../utils/file_utils.dart';
import 'file_service.dart';

/// 파일 정리 비즈니스 로직.
class OrganizeService {
  const OrganizeService(this._fileService);

  final FileService _fileService;

  /// 디렉토리를 정리하고 이동된 파일 목록을 반환합니다.
  Future<OrganizeResult> organize(
    Directory sourceDir,
    String destBasePath, {
    bool dryRun = false,
    bool recursive = false,
  }) async {
    final files = await _fileService.collectFiles(
      sourceDir,
      recursive: recursive,
    );

    final moves = <FileMoveResult>[];

    for (final fileInfo in files) {
      final category = extensionToCategory(fileInfo.extension);
      final targetDir = '$destBasePath/$category';

      if (!dryRun) {
        try {
          final movedPath = await _fileService.moveFile(
            File(fileInfo.path),
            targetDir,
          );
          moves.add(FileMoveResult(
            source: fileInfo,
            destPath: movedPath,
            success: true,
          ));
        } catch (e) {
          moves.add(FileMoveResult(
            source: fileInfo,
            destPath: targetDir,
            success: false,
            error: e.toString(),
          ));
        }
      } else {
        moves.add(FileMoveResult(
          source: fileInfo,
          destPath: '$targetDir/${fileInfo.name}',
          success: true,
          dryRun: true,
        ));
      }
    }

    return OrganizeResult(moves: moves, dryRun: dryRun);
  }
}

/// 파일 이동 결과.
class FileMoveResult {
  const FileMoveResult({
    required this.source,
    required this.destPath,
    required this.success,
    this.error,
    this.dryRun = false,
  });

  final FileInfo source;
  final String destPath;
  final bool success;
  final String? error;
  final bool dryRun;
}

/// 정리 작업 전체 결과.
class OrganizeResult {
  const OrganizeResult({required this.moves, required this.dryRun});

  final List<FileMoveResult> moves;
  final bool dryRun;

  int get successCount => moves.where((m) => m.success).length;
  int get failureCount => moves.where((m) => !m.success).length;
}
// 새 파일: test/services/organize_service_test.dart
import 'dart:io';

import 'package:mockito/mockito.dart';
import 'package:test/test.dart';
import 'package:file_organizer/src/models/file_info.dart';
import 'package:file_organizer/src/services/organize_service.dart';

import '../mocks/mocks.mocks.dart';

void main() {
  late MockFileService mockFileService;
  late OrganizeService organizeService;

  setUp(() {
    mockFileService = MockFileService();
    organizeService = OrganizeService(mockFileService);
  });

  FileInfo makeFileInfo(String name, String ext) => FileInfo(
        path: '/tmp/$name',
        name: name,
        extension: ext,
        sizeBytes: 100,
        modifiedAt: DateTime(2024),
      );

  group('organize', () {
    test('파일 없으면 빈 결과 반환', () async {
      when(mockFileService.collectFiles(any)).thenAnswer((_) async => []);

      final result = await organizeService.organize(
        Directory('/tmp'),
        '/dest',
      );

      expect(result.moves, isEmpty);
      expect(result.successCount, equals(0));
    });

    test('이미지 파일을 이미지 카테고리로 이동', () async {
      final file = makeFileInfo('photo.jpg', '.jpg');
      when(mockFileService.collectFiles(any)).thenAnswer((_) async => [file]);
      when(mockFileService.moveFile(any, any))
          .thenAnswer((_) async => '/dest/이미지/photo.jpg');

      final result = await organizeService.organize(
        Directory('/tmp'),
        '/dest',
      );

      expect(result.successCount, equals(1));
      expect(result.moves.first.destPath, contains('이미지'));

      // moveFile이 올바른 대상 디렉토리로 호출됐는지 확인
      verify(mockFileService.moveFile(any, '/dest/이미지')).called(1);
    });

    test('dry-run 모드에서 moveFile 미호출', () async {
      final file = makeFileInfo('doc.pdf', '.pdf');
      when(mockFileService.collectFiles(any)).thenAnswer((_) async => [file]);

      final result = await organizeService.organize(
        Directory('/tmp'),
        '/dest',
        dryRun: true,
      );

      // dry-run이므로 실제 이동 없음
      verifyNever(mockFileService.moveFile(any, any));
      expect(result.moves.first.dryRun, isTrue);
    });

    test('이동 실패 시 failureCount 증가', () async {
      final file = makeFileInfo('photo.jpg', '.jpg');
      when(mockFileService.collectFiles(any)).thenAnswer((_) async => [file]);
      when(mockFileService.moveFile(any, any))
          .thenThrow(FileSystemException('권한 없음'));

      final result = await organizeService.organize(
        Directory('/tmp'),
        '/dest',
      );

      expect(result.successCount, equals(0));
      expect(result.failureCount, equals(1));
      expect(result.moves.first.error, isNotNull);
    });

    test('여러 파일 혼합 처리', () async {
      final files = [
        makeFileInfo('photo.jpg', '.jpg'),
        makeFileInfo('doc.pdf', '.pdf'),
        makeFileInfo('video.mp4', '.mp4'),
      ];
      when(mockFileService.collectFiles(any)).thenAnswer((_) async => files);
      when(mockFileService.moveFile(any, any))
          .thenAnswer((inv) async {
        final destDir = inv.positionalArguments[1] as String;
        return '$destDir/file';
      });

      final result = await organizeService.organize(
        Directory('/tmp'),
        '/dest',
      );

      expect(result.successCount, equals(3));
      // 각 카테고리로 분류됐는지 확인
      final destinations = result.moves.map((m) => m.destPath).toList();
      expect(destinations.any((d) => d.contains('이미지')), isTrue);
      expect(destinations.any((d) => d.contains('문서')), isTrue);
      expect(destinations.any((d) => d.contains('동영상')), isTrue);
    });
  });
}

any와 매처

mockito에서 인자를 유연하게 매칭하는 방법입니다.

// any: 어떤 값이든 매칭
when(mock.method(any)).thenReturn('value');

// anyNamed: 명명된 매개변수에 any 사용
when(mock.method(path: anyNamed('path'))).thenReturn('value');

// argThat: 조건부 매칭
when(mock.readFile(argThat(endsWith('.txt')))).thenReturn('text content');

// typed: 타입 지정
when(mock.process(typed<File>(any))).thenReturn(true);

정리

이번 챕터에서는 mockito로 의존성을 Mock하는 방법을 배웠습니다.

  • 인터페이스를 도입해 의존성 주입 구조를 만들었습니다.
  • @GenerateMocksbuild_runner로 Mock 클래스를 자동 생성했습니다.
  • when()으로 Mock 동작을 지정하고 verify()로 호출을 검증했습니다.
  • OrganizeService를 커맨드에서 분리해 비즈니스 로직을 독립적으로 테스트했습니다.

다음 챕터에서는 여러 모듈을 연동하는 통합 테스트를 작성합니다.