JAVA

테스트 더블(Test Double)

더즈 2022. 4. 9. 17:12

테스트 더블

테스트 더블이란 실제 프로덕션 코드에서 쓰이지 않는 객체를 사용해 테스트를 진행하는 것을 의미한다. 애플리케이션을 개발할 때 테스트 코드도 프로덕션 코드 못지않게 중요하다. 그 중요한 테스트 코드를 왜 실제 객체가 아닌 다른 객체로 테스트를 하는 것일까? 다른 이유도 있겠지만 다음의 두 가지 이유가 중요하다고 생각한다.

 

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 프레임워크를 활용해 사용할 수 있다.