[Dart] 테스트 코드 작성 시 상수 경로 관련 트러블슈팅

임대한·2025년 3월 19일
0
post-thumbnail

소개


  • Dart 프로젝트의 테스트 코드를 작성하면서 마주친 문제와 해결 과정을 정리해보려고 합니다.
  • 특히 파일 경로가 상수로 하드코딩된 클래스의 테스트 방법에 대한 고민과 해결책을 공유하고자 합니다.
  • 비슷한 문제를 겪고 계신 분들께 도움이 되시길 바랍니다.

🚀 트러블슈팅


이번에 진행한 프로젝트는 Dart로 개발된 몬스터 전투 RPG 게임이었습니다. 게임은 캐릭터와 몬스터 데이터를 파일에서 불러와 전투를 진행하는 방식으로 구현되어 있습니다. GitHub Actions를 통한 지속적 통합(CI)를 한 번도 사용해 본 적이 없어서 이 프로젝트를 좋은 기회로 삼아 GitHub Actions를 사용려고 했습니다. 하지만 테스트 코드를 작성하는 과정에서 파일에서 게임 데이터를 로딩하는 GameDataLoader 클래스에서 문제가 발생했습니다.

1. 문제 상황 발생


GameDataLoader 클래스는 파일 경로가 Constants라는 클래스의 상수로 정의되어 있어, 테스트 시 다른 경로의 파일을 사용할 수 없었습니다. 테스트용 임시 파일을 생성했지만, 경로를 변경할 수 없어 테스트 실행 시 실제 게임 파일에 접근하는 문제가 발생했습니다.

class Constants {
  static const monstersFilePath = 'data/monsters.txt';
  static const resultFilePath = 'result.txt';
  // ...
}

class GameDataLoader {
  static Future<List<Monster>> loadMonsters(int characterDefensePower) async {
    try {
      final file = File(Constants.monstersFilePath); // 상수로 정의된 경로
      // 파일 내용 읽기 및 처리...
    }
    // ...
  }
}

파일 경로가 상수러 정의되어 있어서 변경할 수 없었습니다 :
test('should correctly parse monster data', () async {
  // 임시 디렉토리에 테스트 파일 생성
  final tempDir = await Directory.systemTemp.createTemp('test_');
  final testFile = File('${tempDir.path}/monsters.txt');
  
  // 문제: Constants.monstersFilePath는 상수이므로 변경 불가능!
  Constants.monstersFilePath = testFile.path; // 불가능함. 컴파일 에러 발생
  // ...
});

2. 원인 추론


  1. 상수의 불변성: Dart에서 static const로 선언된 값은 컴파일 타임에 결정되어 런타임에 변경할 수 없습니다.
  2. 의존성 주입 부재: GameDataLoader 클래스가 파일 경로를 외부에서 주입받지 않고 직접 상수를 참조하고 있어 테스트 시 경로를 변경할 수 없었습니다.
  3. 테스트 격리의 어려움: 실제 파일 경로를 사용하므로 테스트가 실제 게임 데이터에 의존하게 되어 격리된 테스트를 작성하기 어려웠습니다.

3. 해결 방안


첫 번째 시도: 테스트용 복제 클래스 생성

처음에는 테스트 파일(game_loader_test.dart)에서 원본 코드를 복제한 테스트용 클래스를 만들었습니다:

class TestableGameDataLoader {
  static Future<List<Monster>> loadMonsters(
    int characterDefensePower,
    String monstersFilePath,
  ) async {
    try {
      final file = File(monstersFilePath);
      // GameDataLoader와 동일한 로직...
    }
    // ...
  }
}

이 방법의 문제점:

  • 코드 중복 발생
  • 원본 코드가 변경될 때마다 테스트 코드도 수정해야 함
  • 실제 사용 코드와 테스트 코드 사이의 차이로 인한 버그 가능성

최종 해결책: 원본 클래스 리팩토링

최종 해결책을 찾는 과정에서, 단순히 기존 코드의 한계를 우회하는 것이 아니라 근본적인 문제를 해결해야 한다는 점을 깨달았습니다. 원본 코드가 강하게 결합되어 있어 테스트가 어렵다는 문제를 인식한 후, 어떻게 하면 테스트 가능성을 높일 수 있을지 고민했습니다.

이 과정에서 먼저 Dart의 테스트 가능성을 높이는 방법을 조사하기 위해 공식 문서와 GitHub의 오픈소스 프로젝트들을 참고했습니다. 특히, 테스트가 용이한 코드들이 의존성 주입(DI)이 분리된 로직 구조를 활용하고 있다는 점을 발견했습니다. 이를 바탕으로, 현재 코드에서 직접 객체를 생성하는 방식이 아니라, 외부에서 필요한 값을 주입받도록 리팩토링하는 것이 최선이라는 결론에 도달했습니다. 결국 해결책은 기존 메서드에 선택적 매개변수를 추가하여 테스트 시에는 다른 파일 경로를 주입할 수 있도록 하는 변경이었습니다.

이러한 접근 방식을 적용하면 테스트 코드가 원본 코드와 자연스럽게 동기화되면서도, 실제 코드와 별도로 안전한 테스트 환경을 유지할 수 있습니다. 이를 기반으로 코드 변경이 있을 때마다 테스트를 따로 수정할 필요 없이, 자연스럽게 유지보수가 가능한 구조를 만들 수 있었습니다:

class GameDataLoader {
  static Future<List<Monster>> loadMonsters(
    int characterDefensePower, 
    {String? customFilePath}
  ) async {
    try {
      final filePath = customFilePath ?? Constants.monstersFilePath;
      final file = File(filePath);
      // 나머지 로직은 그대로 유지
      ...
    }
    // ...
  }
  
  static bool saveGameResult(
    Character character, 
    bool isWin, 
    {String? customFilePath}
  ) {
    try {
      final filePath = customFilePath ?? Constants.resultFilePath;
      // ...
    }
    // ...
  }
}

이렇게 수정한 후 테스트 코드:

test('should correctly parse valid monster data', () async {
  // Arrange
  final tempDir = await Directory.systemTemp.createTemp('monstrike_test_');
  final monsterFile = File('${tempDir.path}/monsters.txt');
  await monsterFile.writeAsString(
    'Dragon,30,20\nGoblin,10,15\nOrc,25,18',
  );
  
  // Act - 추가된 선택적 매개변수 사용
  final monsters = await GameDataLoader.loadMonsters(5, 
    customFilePath: monsterFile.path,
  );
  
  // Assert
  expect(monsters.length, equals(3));
  // ...
});

4. 결과 확인


리팩토링 후 테스트는 실제 게임 파일에 영향을 주지 않고 임시 디렉토리에서 모든 테스트를 실행할 수 있게 되었습니다.

group('GameDataLoader', () {
  late Directory tempDir;
  
  setUp(() async {
    // 테스트마다 고유한 임시 디렉토리 생성
    tempDir = await Directory.systemTemp.createTemp('monstrike_test_');
  });
  
  tearDown(() async {
    // 테스트 후 임시 디렉토리 정리
    if (await tempDir.exists()) {
      await tempDir.delete(recursive: true);
    }
  });
  
  // 여러 테스트 케이스...
  
  test('should save character data with win result', () async {
    // Arrange
    final character = Character(name: 'TestHero', ...);
    final resultPath = '${tempDir.path}/result.txt';
    
    // Act
    final success = GameDataLoader.saveGameResult(
      character, true, customFilePath: resultPath,
    );
    
    // Assert
    expect(success, isTrue);
    final file = File(resultPath);
    expect(await file.exists(), isTrue);
    
    final content = await file.readAsString();
    expect(content, contains('게임 결과: 승리'));
  });
});

이 접근 방식의 주요 이점:

  1. 제로 코드 중복: 테스트 코드가 실제 코드의 복제본을 유지할 필요가 없음
  2. 자동 동기화: 원본 코드 변경이 자동으로 테스트에 반영됨
  3. 하위 호환성: 기존 코드가 정상적으로 작동하면서도 테스트성 향상
  4. 안전한 테스트 환경: 실제 게임 파일을 수정하지 않음

이 경험을 통해 상수로 하드코딩된 값에 의존하는 코드도 의존성 주입 패턴을 사용하여 테스트 가능하게 만들 수 있다는 것을 배웠습니다. 기존 코드를 수정해야 하지만, 코드 품질과 유지보수성 측면에서 훨씬 더 나은 결과를 얻을 수 있었습니다.

profile
안드로이드, Flutter, 머신러닝

0개의 댓글

Powered by GraphCDN, the GraphQL CDN