테스트 더블
테스트 더블이란 실제 프로덕션 코드에서 쓰이지 않는 객체를 사용해 테스트를 진행하는 것을 의미한다. 애플리케이션을 개발할 때 테스트 코드도 프로덕션 코드 못지않게 중요하다. 그 중요한 테스트 코드를 왜 실제 객체가 아닌 다른 객체로 테스트를 하는 것일까? 다른 이유도 있겠지만 다음의 두 가지 이유가 중요하다고 생각한다.
1. 테스트 하려는 객체와 연관된 객체가 사용하기 어려운 경우
2. 테스트 하려는 객체와 연관된 객체의 상태에 상관없이 테스트하고자 하는 객체만 테스트하고 싶은 경우
어찌보면 비슷한 말이다. 즉 연관된 객체와 테스트하고자 하는 객체를 분리하고 싶은 것이다. 연관된 객체에 문제가 생기더라도 테스트하고자 하는 객체의 테스트는 통과할 수 있어야 옳게 된 단위 테스트이다. (물론 연관된 객체와 아예 분리하는 것은 어렵긴 하다.)
예시: 데이터베이스 사용
우아한테크코스 체스 미션에서 JDBC를 이용해 DB를 사용했었다. 진행 중인 체스 게임을 저장해서 서버를 다시 시작하더라도 불러올 필요가 있었기 때문이다. DB 접근 구조는 다음과 같다.
- ChessGameService : 컨트롤러에서 넘겨준 정보에 따라 체스 게임을 생성하거나 예전에 생성했던 게임을 불러온다. ChessGameRepository를 사용해 게임을 저장, 관리한다.
- ChessGameRepository : DAO 객체를 사용해 체스 게임을 저장하거나 불러온다.
- ChessGameDAO, PieceDAO : 게임과 체스 기물의 위치 정보를 DB 테이블에 저장하거나 조회한다.
ChessGameService
public class ChessGameService {
private static final String NOT_EXIST_GAME = "해당하는 이름의 게임이 존재하지 않습니다.";
private final GameRepository gameRepository;
public ChessGameService(GameRepository gameRepository) {
this.gameRepository = gameRepository;
}
public ChessGame createGame(String name) {
ChessGame chessGame = new ChessGame(name, new Ready(BoardInitializer.generate()));
gameRepository.save(chessGame);
return chessGame;
}
public ChessGame findGame(String name) {
return gameRepository.findByName(name)
.orElseThrow(() -> new IllegalArgumentException(NOT_EXIST_GAME));
}
public ChessGame updateGame(Command command, String name) {
ChessGame updatedGame = findGame(name).execute(command);
gameRepository.updateGame(updatedGame, command);
return updatedGame;
}
public void deleteGame(String name) {
gameRepository.remove(name);
}
public List<String> findAllGames() {
return gameRepository.findAllNames();
}
}
위 코드와 같이 ChessGameService는 Repository를 사용해 체스 게임을 저장, 조회, 수정, 삭제를 한다. Repository가 단순 CRUD를 담당한다면 ChessGameService는 CRUD를 하는 대상인 ChessGame을 생성하고 조회를 할 때도 결과가 없으면 예외를 터뜨리는 등 부가적인 로직이 더 추가되어 있다.
DAO, ChessGameRepository의 테스트 코드가 모두 통과하고 이제 ChessGameService의 테스트 코드를 작성한다고 가정해 보자.
class ChessGameServiceTest {
private final ChessGameService gameService = new ChessGameService(new ChessGameRepository());
@Test
@DisplayName("게임을 기록한다.")
void saveGame() {
// ...
}
@Test
@DisplayName("게임 이름으로 게임을 불러온다.")
void findGame() {
// ...
}
@Test
@DisplayName("게임 상태 업데이트")
void updateGame() {
// ...
}
}
여기서 의문이 발생한다.
1. DB가 연결되어 있지 않으면 ChessGameServiceTest도 실패한다.
2. 이미 DB에 CRUD가 잘 되는지에 관해서는 DAO, Repository의 테스트에서 검증이 끝났는데 Service도 DB와 강하게 연관되어 테스트를 할 필요가 있을까?
이러한 상황에서 사용할 수 있는 것이 테스트 더블(Test Double)이다.
Fake 객체 사용
우선 ChessGameRepository가 구현하는 인터페이스가 필요하다. ChessGameService는 DB를 직접 사용하는 Repository를 의존하는 것이 아닌 이 인터페이스를 의존하게 된다. 이렇게 되면 실제로 DB를 사용하지 않고 CRUD를 흉내 내는 다른 구현 객체를 만들어서 ChessGameService가 사용하게 할 수도 있다.
public interface GameRepository {
void save(ChessGame game);
Optional<ChessGame> findByName(String name);
void updateGame(ChessGame game, Command command);
void remove(String name);
List<String> findAllNames();
}
아래는 DB를 사용하는 것이 아닌 Map을 사용하여 체스 게임을 저장하는 가짜 객체이다.
public class MemoryChessGameRepository implements GameRepository {
private final Map<String, ChessGame> database = new HashMap<>();
@Override
public void save(ChessGame game) {
database.put(game.getName(), game);
}
@Override
public Optional<ChessGame> findByName(String name) {
return Optional.ofNullable(database.get(name));
}
@Override
public void updateGame(ChessGame game, Command command) {
database.put(game.getName(), game);
}
@Override
public void remove(String name) {
database.remove(name);
}
@Override
public List<String> findAllNames() {
return new ArrayList<>(database.keySet());
}
}
그래고 테스트 코드에서 해당 객체를 사용해 ChessGameService의 테스트를 진행한다.
class ChessGameServiceTest {
private final ChessGameService gameService =
new ChessGameService(new MemoryChessGameRepository());
// ...
}
테스트 더블을 통해 DB의 상태와 상관 없이 ChessGameService의 테스트는 언제나 통과한다.
그리고 실제 프로덕션 코드에서는 DB를 연결하여 GameRepository를 구현한 객체를 사용하면 된다.
public WebController() {
this.gameService = new ChessGameService(new ChessGameRepository());
}
테스트 더블의 종류
1. Dummy
가장 기본적인 테스트 더블로 단순히 인스턴스 객체가 필요할 때 사용한다. 해당 객체가 어떤 기능을 할 필요는 없다.
2. Stub
dummy 객체인데 실제로 기능까지 추가하여 동작하는 것처럼 만들어 놓은 객체이다. 실제로 사용되는 객체의 기능을 최소한으로 구현한 상태.
3. Fake
위 데이터베이스 예시에서 설명했던 MemoryChessGameRepository가 바로 Fake 객체이다. 단순한 Stub 객체로 구현할 수 없는 기능을 구현해야 할 때 사용한다. 실제 사용되는 객체의 기능을 모방하여 구현했지만 실제 프로덕션에는 적합하지 않은 객체
4. Spy
Stub의 역할을 하면서 호출된 내용에 대해 약갼의 정보를 기록할 때 사용된다. 예를 들어 몇 번 호출되는지 알고 싶어서 프로덕션 코드에는 없는 int count 필드를 가짐으로써 호출될 때마다 count를 증가시키는 로직이 추가된 것이다.
5. Mock
호출에 대한 기대를 명세하고 그 내용에 따라 동작하도록 만들어진 객체이다. Mock은 Mockito 프레임워크를 활용해 사용할 수 있다.
'JAVA' 카테고리의 다른 글
JVM과 클래스 로더 (0) | 2022.04.12 |
---|---|
상태 패턴 (in 체스 미션) (0) | 2022.04.04 |
IllegalArgumentException vs IllegalStateException (0) | 2022.03.31 |
[JAVA] Enum으로 if문 제거하기 (0) | 2022.03.16 |
옵저버 패턴(Observer Pattern) 살펴보기 (0) | 2022.03.05 |