테스트 더블(Test double) 에 대해

원태연·2023년 3월 27일
0
post-thumbnail

어려운 테스트

아래와 같은 코드가 있다.

ChessGame.java

class ChessService {
	private final ChessGameDao chessGameDao;
	
	public void createNewGame() {
    	ChessGame newGame = ChessBoardFactory.getInitialBoard();
    	chessGameDao.save(newGame)
    }
    
    public void statusGame(ChessGame chessGame) {
    	chessGame.status();
    	chessGameDao.update(chessGame);
    }
	
}

class ChessGame {
	private final ChessBoard chessBoard;
	
}

class ChessBoard {
	private final Map<Position, Piece> pieceByPosition;
}

그리고 테스트

@Test
@DisplayName("게임의 상태를 출력할 수 있다.")
void createNewGameTest() {
	//given
	ChessGameDao chessGameDao = new ChessGameDao();
    ChessService chessService = new ChessService(chessGameDao);
    
    Map<Position, Piece> positionByPiece =  Map.of(
    	Position.of(A, TWO), new Pawn(WHITE), 
	    ...,
    );
    
    ChessBoard chessBoard = new ChessBoard(positionByPiece);
    ChessGame chessGame = new ChessGame(chessBoard);
    
    //when
    chessService.status(chessGame);
    
    //then
    chessGameDao.findAll();
    ...
}

눈으로 봐도 불편한 테스트임을 알 수 있지만
위 테스트 코드는 다양한 문제점들이 발견되는데, 다음과 같다.

  • ChessService를 테스트 하기 위해 의존 구성 요소를 생성해야 한다.
  • 해당 메서드를 테스트 하기 위해 상용되는 로직이 모두 구현(존재)해야 한다.
  • 실제 데이터에 영향을 줄 수 있다. (예측 불가능한 상황 발생 가능)
  • 테스트 하려는 대상의 성능을 독립적으로 확인할 수 없다. (네트워크, 커넥션 등의 존재)

애초에 테스트는 격리되어 있어야 한다.

Independent(독립적)
테스트는 깔끔함과 단정함을 유지해야 한다. 즉, 확실히 한 대강에 집중한 상태여야 하며, 환경과 다른 개발자들(명심하라. 다른 개발자들이 동시에 같은 테스트를 실행해 볼 수도 있다)에게서 독립적인 상태를 유지해야 한다.

또한 독립적이라는 것은 어떤 테스트도 다른 테스트에 의존하지 않는다는 것을 의미한다. 어느 순서로든, 어떤 개별 테스트라도 실행해 볼 수 있어야 한다. 처음 것을 실행할 때 그 밖의 다른 테스트에 의존해야 하는 상황을 원하지는 않을 것이다.

모든 테스트는 섬이어야 한다.


출처 : 실용주의 프로그래머를 위한 단위 테스트 with JUnit

복잡한 프로그램일수록 도메인이 완전히 격리되어 있는 경우는 드물다.
하지만 테스트 케이스는 독립적으로 존재하게 할 수 있다.
동작하는 프로덕션에서 의존하는 실제가 아닌 다른 것으로 대체 하는 것이다.

테스트 더블

실제를 의존하는건, 실제 케이스에서나 기대 해볼법 하다.
테스트 케이스를 위해선 실제와 동일한 대체제가 필요하다.
테스트 대상 코드와 상호작용 하기 위한 대체제의 객체를 테스트 더블이라고 한다. 그리고 스턴트 더블

테스트 더블을 활용한다면 다음과 같은 장점들을 기대할 수 있다.

  • 테스트 대상 코드의 격리
  • 테스트 속도 개선
  • 특수한 상황 케이스 테스트 가능
  • 테스트 대상의 영향 최소화
  • 객체의 감춰진 정보 확인

종류

1. Dummy

  • 특정 객체가 필요하지만 기능은 필요하지 않은 경우에 사용한다.
  • Dummy 객체의 메서드가 호출되었을 때 정상 동작을 기대하진 않는다.

    dummy: 모조품, 마네킹

ex)

public class ChessGameDao {
    public void save(CustomLogger chessGameLogger) {
    	chessGameLogger.pringLog();
        ...
    };
}
public class DummyChessGameLogger implements CustomLogger {
    @Override
    public void printLog() {
    	return;
    }
}

그리고 테스트

@Test
void chessGameDaoTest() {
	CustomLogger dummyChessGameLogger = new DummyChessGameLogger();
    ChessGameDao chessGameDao = new ChessGameDao();
    
    chessGameDao.save(dummyChessGameLogger);
}

위 케이스에선 Dummy 방식을 활용해도 좋다. Logger의 행동은 테스트 케이스에 아무런 영향을 미치지 않으니깐..

2. Fake

  • Dummy와 달린 기능이 존재하지만, 실제 프로덕트 기능과는 다르게 동작하는 객체를 말한다.
    ex)
public class ChessGameDao {
    public void save(ChessGame chessGame) {
		Connection connection = ConnectionProvider.getConnection();
        ...
    };

	public ChessGame findByIndex(int id) {
		Connection connection = ConnectionProvider.getConnection();
        ...
    };
}
public class FakeChessGameDao extends ChessGameDao {
	private List<ChessGame> chessGames = new ArrayList<>();

	@Override
    public void save(ChessGame chessGame) {
        chessGames.add(chessGame);
    };

	@Override
    public ChessGame findByIndex(int id) {
	    return chessGames.get(id);       	
    };
}

그리고 테스트

@Test
void chessGameDaoTest() {
    ChessGameDao fakeChessGameDao = new FakeChessGameDao();
    ChessService chessService = new ChessService(fakeChessGameDao);
    
    int createChessGameIndex = chessService.createNewGame();
    
    chessGameDao.findByIndex(createChessGameIndex);
}

DB를 사용해야 하는 ChessService를 실제 DB와 분리하였다. DB와 통신하던 DAO를 사용하지 않고, 인메모리에서 컬렉션을 활용하는 Fake DAO를 사용하게 되었다.
통신에 사용되는 비용이 줄어들어서 테스트 속도에서의 이점이 생겼고, 다같이 사용될 수 있는 DB와 분리되어 격리성이 높아졌다.
위 테스트에선 사용되는 ChessService 메서드의 실제 동작만 검증하고 있다.

3. Stub

Stub은 Dummy와 달리 Fake처럼 동작하지만, 미리 지정된 값을 반환한다.
위 Fake 예제에서의 FakeChessGameDao.findByIndex()는 넘기는 파라미터에 따라 결과값 다르다.

StubFake와 달리 기대하는 값을 미리 설정해둔다.

public class ChessGameDao {
    public void save(ChessGame chessGame) {
		Connection connection = ConnectionProvider.getConnection();
        ...
    };

	public ChessGame findById(int id) {
		Connection connection = ConnectionProvider.getConnection();
        ...
    };
}
public class StubChessGameDao extends ChessGameDao {

	@Override
    public void save(ChessGame chessGame) {
		return;
    };

	@Override
    public ChessGame findById(int ignore) {
    	ChessGame stubChessGame = new StubChessGame();
	    return stubChessGame;  	
    };
}

그리고 테스트

class ChessService {
	private final ChessGameDao chessGameDao;
    
    public ChessGame move(int id, String moveCommand) {
    	ChessGame chessGame =chessGameDao.findById(id);
        chessGame.move(moveCommand);
        
        return chessGame;
    }
}

@Test
void chessGameDaoTest() {
    ChessGameDao stubChessGameDao = new StubChessGameDao();
    ChessService chessService = new ChessService(stubChessGameDao);
    
    ChessGame chessGame = chessService.move(1, "move a2 a4");
    
    assertThat(chessGame).isSameAs(new StubChessGame());
    assertThat(chessGame.get("a4")).isNotNull;
}

ChessGameDao을 의존하는 ChessService의 동작에 대해 미리 지정해둔 Stub의 결과가 반환되는 것을 기대할 수 있다.

4. Spy

테스트에서 특정 객체가 사용되었는지,그리고 그 객체의 예상된 메서드가 정상적으로 호출됐는지를 확인해야 하는 상황이 생기는 경우 사용 된다.

Engine.java 를 의존하는 Car.java 객체가 존재한다.

public class Car {
    private Engine engine;

	public void start() {
        engine.run();
        engine.run();
        engine.run();
    }
}

Engine.java

public class Engine {
	private static final int DEFAULT_OIL_AMOUNT = 10;
	private int oilLeft = DEFAULT_OIL_AMOUNT;

	public void run() {
        oilLeft -= 1;
    }
    
    public int getMovement() {
        return DEFAULT_OIL_AMOUNT - oilLeft;
    }
}
public class EngineSpy extends Engine {
    private int calledCount = 0;
    
    @Override
    public void run() {
        calledCount += 1;
    }
    
    public int getMovement() {
        return this.calledCount;
    }
}

그리고 테스트

@Test
void carStartTest() {
    Engine engine = new EngineSpy();
    Car car = new Car(engine);
    
    car.start();

    assertThat(car.getMovement()).isEqaul(3);
}

위 테스트 코드에서 ChessService.move()의 구현체를 직접 확인 하진 않았지만 engine.start()가 3번 호출되는 것을 확인할 수 있다.
앞서 언급했던 테스트 더블의 장점이었던 객체의 감춰진 정보 확인도 가능하다.

5. Mock

호출에 대한 기대를 명세하고, 그대로 행동 하길 기대하도록 구현된 객체.
이러한 Mock 객체의 생성과 행동의 명세를 도와주는 다양한 Framework가 존재한다.
대표적으론, Mockito가 있다.

대충 Mockito를 사용한 코드..

@ExtendWith(MockitoExtension.class)
public class UserServiceTest {
    @Mock
    private UserRepository userRepository;
    
    @Test
    void test() {
        when(userRepository.findById(anyLong())).thenReturn(new User(1, "Test User"));
        
        User actual = userService.findById(1);
        assertThat(actual.getId()).isEqualTo(1);
        assertThat(actual.getName()).isEqualTo("Test User");
        ...
    }
}

when(userRepository.findById(anyLong())).thenReturn(new User(1, "Test User"));
다른 내용은 차치하고 해당 코드만 보자.
when(userRepository.findById(anyLong()))을 통해 어떤 메서드에 대한 Mocking을 기대하는지 명시하고,
.thenReturn(new User(1, "Test User")); 원하는 결과를 명세할 수 있다.

상태 검증과 행위 검증

MockStub은 크게 달라보이지 않는다. 모두 호출되는 내부 구현에 대해 이해하고 있어야 하며, 기대값이 존재한다는 것도 동일하다.
큰 차이라고 하면, Mock은 행위 검증을 수행하고, Stub은 상태 검증을 수행한다고 한다.


상태 검증 예시

StateClass stateClass = new StateClass();
stateClass.doSomething();

assertThat(stateClass.getStatus()).isEqualTo(true);

행위 검증 예시

BehaviorClass behaviorClass = new BehaviorClass();

verify(behaviorClass).doBehavior();

행위 검증의 경우, 특정 메서드의 호출 등을 검증하기 때문에 구현에 의존적이라는 단점이 존재한다.
상태 검증의 경우, 상태를 노출하는 메서드(getter)가 많이 추가될 수 있다는 단점이 존재한다.

용도에 맞게 사용하자

profile
앞으로 넘어지기

0개의 댓글