[우테코] 컨트롤러에 대한 테스트

dooboocookie·2023년 2월 27일
2

컨트롤러의 의존성

  • 사다리 게임을 구현하며, Controller의 의존성을 외부에서 모두 주입하도록 했다.
  • BooleanGenerator, Result, Input 의 구현체를 Controller를 만들 때 주입했다.
  • 이는 아래와 같은 생각 때문이었다.

컨트롤러는 단순히 뷰와 도메인을 이어주는 역할을 할 뿐, 어떤 뷰로 입력을 받거나 출력해야되는지 알 필요가 없다.

컨트롤러에 대한 테스트

  • 위와 같이 설계를 하고 다른 도메인에 대한 테스트만 집중하던 도중 리뷰어에게 아래와 같은 피드백이 있었다.

컨트롤러의 의존성을 모두 외부에서 주입하고 있네요 👍
한발 더 나아가서, 컨트롤러에 대한 테스트를 작성해볼 수도 있을까요? 만약 어렵다면 어떤 이유 때문일까요?

  • 저 질문을 듣고 여러가지 생각이 들었다.
    • 컨트롤러에 대한 테스트가 필요할까?
    • 그 전에 컨트롤러에 대한 테스트가 가능할까?
  • 테스트가 필요하다.
    • 컨트롤러 안에서는 일련의 과정을 순차적으로 일어난다.
    • 각 도메인의 역할이 잘 동작하는 지에 대한 단위 테스트도 존재해야겠지만,
    • 당연히, 컨트롤러가 자체가 순차적으로 잘 진행 되는지를 확인해야된다.
  • 그렇다면 어떻게?
    • 그래서 처음 든 생각은 컨트롤러가 의존하는 것들에 대해서 현재는 InputView, ResultView, RandomBooleanGenerator가 구현체가 있으므로,
    • System.setIn(), System.setOut()등을 테스트 코드에서 활용하여 콘솔에 입력할 내용이나, 출력할 내용에 대해서 관리를 하고자 했다.
    • 랜덤값에 대해서는 목 객체를 생성해서 특정한 값을 계속 던져주는 값을 하자.

의문

System.setIn()과 같은 방식으로 콘솔에 입력할 값을 조작하여 테스트를 하는게 과연 Controller의 run() 기능에 대한 테스트일까?

  • 그렇지 않았다.
    • 저것은 뷰에서 콘솔에서 어떻게 입력되는 지에 대한 과정까지 포함을 해버리게 된다.
  • 이전 자동차 경주에서 짰던 컨트롤러 테스트
@DisplayName("자동차 경주 통합 정상 작동 테스트")
@Test
void playGameTest() {
	String carNames = "헤나, 썬샷, 루카"+System.lineSeparator()+"5";
	inputStream = new ByteArrayInputStream(carNames.getBytes(UTF_8));
	out = new ByteArrayOutputStream();
	outputStream = new PrintStream(out);

	System.setIn(inputStream);
	System.setOut(outputStream);

	numberGenerator = new RandomNumberGenerator();
	outputView = new OutputView();
	inputView = new InputView();
	racingCarController = new RacingCarController(inputView, outputView, numberGenerator);

	racingCarController.newCarNames();
	racingCarController.newGameRound();
	racingCarController.play();

	assertThat(out.toString()).contains("최종 우승했습니다.");
}
  • 입력 값, 출력 값이 어때야 하는 지에 대한 지정이 매우 어려웠다.
  • 또한 이는 위에서 말했듯이, inputView, outputView가 잘 동작하는지에 대한 테스트도 포함된게 된다.

개선 방향

  • Input, Result, BooleanGenerator에 대한 목 객체를 만들었다.

MockInputView.java

public class MockInputView implements Input {

    private final List<List<String>> inputPlayersNames;
    private final List<Integer> inputHeightOfLadder;
    private final List<List<String>> inputRewards;
    private final List<List<String>> inputTargetPlayers;
    private final List<String> inputContinue;

    private int orderOfInputPlayerNames;
    private int orderOfInputHeightOfLadder;
    private int orderOfInputRewards;
    private int orderOfInputTargetPlayers;
    private int orderOfInputContinue;

    public MockInputView(List<List<String>> inputPlayersNames,
                         List<Integer> inputHeightOfLadder,
                         List<List<String>> inputRewards,
                         List<List<String>> inputTargetPlayers,
                         List<String> inputContinue) {
        this.inputPlayersNames = inputPlayersNames;
        this.inputHeightOfLadder = inputHeightOfLadder;
        this.inputRewards = inputRewards;
        this.inputTargetPlayers = inputTargetPlayers;
        this.inputContinue = inputContinue;
    }

    @Override
    public List<String> inputPlayerNames() {
        if (inputPlayersNames.size() == orderOfInputPlayerNames) {
            orderOfInputPlayerNames = 0;
        }
        return inputPlayersNames.get(orderOfInputPlayerNames++);
    }

    @Override
    public int inputHeightOfLadder() {
        if (inputHeightOfLadder.size() == orderOfInputHeightOfLadder) {
            orderOfInputHeightOfLadder = 0;
        }
        return inputHeightOfLadder.get(orderOfInputHeightOfLadder++);
    }
    
    // ...

}
  • 동일한 입력 메소드가 여러번 반복될 수 있으므로 그 입력 더미를 List로 받고 그에 대해서 몇번째 메소드가 호출 됐는지 orderOfInputXXX의 변수의 값을 하나씩 올려가며 체크하였다.
  • 예를 들어 사다리 높이를 List.of(3,5,6)로 주입하면, inputHeightOfLadder()호출할 때 반환 값이 3, 5, 6, 3, 5, 6, 3, ... 순서대로 되도록 하였다.
    • (Test Double spy에 대해서 추가적으로 공부해보자)

MockResultView.java

public class MockResultView implements Result {

    private List<String> players;
    private List<Line> ladder;
    private List<Reward> rewards;
    private Map<Player, Reward> gameResult;
    private Boolean hasError;

    @Override
    public void printError(String errorMessage) {
        this.hasError = true;
    }

    @Override
    public void printLadder(Players players, Ladder ladder, Rewards rewards) {
        this.players = players.getNames();
        this.ladder = ladder.getLadder();
        this.rewards = rewards.getRewards();
    }

    @Override
    public void printGameResult(Map<Player, Reward> gameResult) {
        this.gameResult = gameResult;
    }

    public Boolean hasError() {
        return hasError;
    }

    public List<String> getPlayers() {
        return players;
    }

    public List<Line> getLadder() {
        return ladder;
    }

    public List<Reward> getRewards() {
        return rewards;
    }

    public Map<Player, Reward> getGameResult() {
        return gameResult;
    }


}
  • ResultView는 더 단순하다. 원래 ResultView에 전달해 줘야하는 값을 필드로 갖지고 있어 그에 대한 게터를 가지고 값이 잘 전달되었는지만 확인한다.
    • 이 값들이 어떻게 출력되는지는 ResultVeiw의 영역이므로 이는 현재 관심 밖이다.

MockBooleanGenerator.java

public class MockBooleanGenerator implements BooleanGenerator {

    private final List<Boolean> generatedBoolean;

    private int orderOfBoolean;

    public MockBooleanGenerator(List<Boolean> generatedBoolean) {
        this.generatedBoolean = generatedBoolean;
    }

    @Override
    public boolean generateBoolean() {
        if (generatedBoolean.size() == orderOfBoolean) {
            orderOfBoolean = 0;
        }
        return generatedBoolean.get(orderOfBoolean++);
    }

}
  • 해당 목객체를 생성 시 주입한 List 값을 순차적으로 리턴하는 형식으로 구성하였다.

테스트 코드

    @Test
    @DisplayName("사다리 컨트롤러 정상 테스트")
    void ladderControllerTest() {
    	//given
        MockInputView inputView = new MockInputView(
                List.of(List.of("a", "b", "c", "d")),
                List.of(3),
                List.of(List.of("1", "2", "3", "4")),
                List.of(List.of("all")),
                List.of("n"));
        MockResultView resultView = new MockResultView();
        MockBooleanGenerator booleanGenerator = new MockBooleanGenerator(List.of(true, false));
        ladderController = new LadderController(inputView, resultView, booleanGenerator);
        
        //when
        ladderController.run();
        List<String> resultPlayers = resultView.getPlayers();
        List<Line> resultLadder = resultView.getLadder();
        List<Reward> rewards = resultView.getRewards();
        Map<Player, Reward> gameResult = resultView.getGameResult();

		//then
        assertThat(resultPlayers).isEqualTo(List.of("a", "b", "c", "d"));
        assertThat(resultLadder.get(0).getLine()).isEqualTo(List.of(MOVABLE_BAR, UNMOVABLE_BAR, UNMOVABLE_BAR));
        assertThat(resultLadder.get(1).getLine()).isEqualTo(List.of(MOVABLE_BAR, UNMOVABLE_BAR, UNMOVABLE_BAR));
        assertThat(resultLadder.get(2).getLine()).isEqualTo(List.of(MOVABLE_BAR, UNMOVABLE_BAR, UNMOVABLE_BAR));
        assertThat(rewards.get(0).getReward()).isEqualTo("1");
        assertThat(rewards.get(1).getReward()).isEqualTo("2");
        assertThat(rewards.get(2).getReward()).isEqualTo("3");
        assertThat(rewards.get(3).getReward()).isEqualTo("4");
        assertThat(gameResult.get(new Player(new Name("a"))).getReward()).isEqualTo("2");
        assertThat(gameResult.get(new Player(new Name("b"))).getReward()).isEqualTo("1");
        assertThat(gameResult.get(new Player(new Name("c"))).getReward()).isEqualTo("3");
        assertThat(gameResult.get(new Player(new Name("d"))).getReward()).isEqualTo("4");
    }
  • 여전히, 깔끔한 테스트는 아닌 것 같다.
  • controller.run()은 많은 입력 값과 그에 대한 많은 실행 결과가 있어서 일 것 같다.
  • 일련의 과정을 포함하고 있는 메소드이므로 run의 일부분을 테스트 한다는 것은 너무 복잡해보인다.
  • 또한 그 각각의 기능에 대해서는 도메인에서 이미 단위테스트가 일어나고 있으므로, run()이라는 메소드 자체를 분리할 것이아니라면,
  • 특정 Input에 의해서 맞게 동작하여 예상된 Result를 갖는지 판단하면 되는 것으로 보려한다.

정리

  • 컨트롤러 또한 단위 테스트의 단위라고 본다.
  • 컨트롤러 테스트를 할 때는 InputView, OutputView와 같은 내용을 테스트 하지 않도록 주의하자.
  • 테스트란, 어떠한 입력에서 의도한 대로 동작하여 관측 가능한 반환 값이나 상태를 변경시키는 지를 검증한다고 생각하여 짜도록 해보자.
profile
1일 1산책 1커밋

0개의 댓글