아래와 같은 코드가 있다.
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
복잡한 프로그램일수록 도메인이 완전히 격리되어 있는 경우는 드물다.
하지만 테스트 케이스는 독립적으로 존재하게 할 수 있다.
동작하는 프로덕션에서 의존하는 실제가 아닌 다른 것으로 대체 하는 것이다.
실제를 의존하는건, 실제 케이스에서나 기대 해볼법 하다.
테스트 케이스를 위해선 실제와 동일한 대체제가 필요하다.
테스트 대상 코드와 상호작용 하기 위한 대체제의 객체를 테스트 더블이라고 한다. 그리고 스턴트 더블
테스트 더블을 활용한다면 다음과 같은 장점들을 기대할 수 있다.
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
의 행동은 테스트 케이스에 아무런 영향을 미치지 않으니깐..
Dummy
와 달린 기능이 존재하지만, 실제 프로덕트 기능과는 다르게 동작하는 객체를 말한다.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
메서드의 실제 동작만 검증하고 있다.
Stub은 Dummy와 달리 Fake처럼 동작하지만, 미리 지정된 값을 반환한다.
위 Fake 예제에서의 FakeChessGameDao.findByIndex()
는 넘기는 파라미터에 따라 결과값 다르다.
Stub
은 Fake
와 달리 기대하는 값을 미리 설정해둔다.
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
의 결과가 반환되는 것을 기대할 수 있다.
테스트에서 특정 객체가 사용되었는지,그리고 그 객체의 예상된 메서드가 정상적으로 호출됐는지를 확인해야 하는 상황이 생기는 경우 사용 된다.
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번 호출되는 것을 확인할 수 있다.
앞서 언급했던 테스트 더블의 장점이었던 객체의 감춰진 정보 확인
도 가능하다.
호출에 대한 기대를 명세하고, 그대로 행동 하길 기대하도록 구현된 객체.
이러한 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"));
원하는 결과를 명세할 수 있다.
Mock
과 Stub
은 크게 달라보이지 않는다. 모두 호출되는 내부 구현에 대해 이해하고 있어야 하며, 기대값이 존재한다는 것도 동일하다.
큰 차이라고 하면, Mock
은 행위 검증을 수행하고, Stub
은 상태 검증을 수행한다고 한다.
상태 검증 예시
StateClass stateClass = new StateClass();
stateClass.doSomething();
assertThat(stateClass.getStatus()).isEqualTo(true);
행위 검증 예시
BehaviorClass behaviorClass = new BehaviorClass();
verify(behaviorClass).doBehavior();
행위 검증
의 경우, 특정 메서드의 호출 등을 검증하기 때문에 구현에 의존적이라는 단점이 존재한다.
상태 검증
의 경우, 상태를 노출하는 메서드(getter)가 많이 추가될 수 있다는 단점이 존재한다.