티스토리 뷰

이번 포스팅에선 3주차 과제를 진행하면서 배운 내용, 아쉬웠던 점과 느낀 점을 쓴다. 2주차 회고는 너무 코드 위주로 설명을 해서 아쉬웠다. (회고록을 어떻게 써야하는지 몰랐었다.) 이제라도 회고다운 글을 써볼까 한다. 그럼 배운내용부터 작성하도록 하겠다.

 

도메인 단위 테스트를 할 때는 하드코딩을 할 것

3주차 추가된 요구사항에서 도메인 로직에 단위 테스트를 구현하라고 했다. 단위 테스트가 처음은 아니지만, 도메인 로직을 단위 테스트로 구현하는 것은 처음이었기에 어떻게 작성해야할지 고민이었다. 정확한 해답 없이 코드를 구현하고 있을 때, 이동욱님의 블로그글을 보면서 한가지 깨달은 점이 있다. 

 

아래코드는 한 가지 문제점이 있는 코드다.

    @DisplayName("Lotto용지가 생성되는지 확인")
    @Test
    void when_createLotto_Expect_numbers() {
        List<Integer> numbers = List.of(1,32,35,29,10,8);
        Lotto lotto = new Lotto(numbers);
        assertThat(String.valueOf(lotto.getNumbers())).isEqualTo(numbers);
    }

 

Lotto 생성자를 가보면 문제점을 알 수 있다. 

public class Lotto {
    private final List<Integer> numbers;

    public Lotto(List<Integer> numbers) {
        this.numbers = numbers;
    }
    
    public List<Integer> getNumbers() {
        return this.numbers;
    }    

    ...
}

 

 

이상한 점을 발견하였나? 매개변수로 들어온 숫자리스트를 그대로 클래스변수에 초기화하고, getNumbers로 가져온다.

아직 이상한 점을 발견하지 못했다면, 이동욱님이 예시로 들어준 극단적인 코드를 봐보자. (예시 코드를 자바로 변경하였습니다.)

public int sum(int num1, int num2) {
	return num1 + num2;
}

...

@DisplayName("sum 테스트")
@Test
void test() {
    int num1 = 1;
    int num2 = 2;
    int result = sum(num1, num2);
    assertThat(result).isEqualTo(num1 + num2);
}

 

이제 깨달았을 것이다. a+b 로직의 검증을 a+b로 하는 것이 의미가 있는 테스트인지 생각해보자. 위 테스트코드를 작성하는 것은 테스트코드를 작성하지 않은 것과 같다. 아까 문제점이 있는 코드도, 매개변수로 들어온 리스트를 그대로 가져오는 코드이기 때문에, 위 예시 코드와 다를 바가 없다. 이러한 코드를 소프트 코딩이라고 한다. 소프트 코딩 방식으로 검증부를 구현하면 다음과 같은 문제를 일으킬 수 있다.

 

  • 무의미한 검증
  • 구현코드와의 결합도가 강해짐
  • 거짓 성공
  • 결과 예측의 어려움
  • Test First 개발의 어려움

문제점들에 대해서는 이동욱님의 블로그를 꼭 읽어보기를 바란다. 

 

그럼 다시 돌아와서, 문제를 일으킨 코드를 리팩토링 해보자. 

    @DisplayName("Lotto용지가 생성되는지 확인")
    @Test
    void when_createLotto_Expect_numbers() {
        List<Integer> numbers = List.of(1,32,35,29,10,8);
        Lotto lotto = new Lotto(numbers);
        assertThat(String.valueOf(lotto.getNumbers())).isEqualTo("[1, 32, 35, 29, 10, 8]");
    }

 

검증부(assertThat)에 도메인 로직 코드를 없애고 하드코딩했다. 이렇게 단위 테스트에서는 검증부 부분을 하드코딩으로 작성해야 한다.

 

빨간 메세지는 에러 메세지가 아니다

아래 테스트를 실패했다. 에러메세지에 [ERROR] 가 포함되어있나 확인하는 테스트인데, 계속해서 실패하는 바람에 1시간을 날렸다. 

    @Test
    void 예외_테스트() {
        assertSimpleTest(() -> {
            runException("1000j");
            assertThat(output()).contains(ERROR_MESSAGE);
        });
    }

 

나는 처음에 아래 메세지가 에러메세지라고 생각했다. 

 

 

분명 메세지에 [ERROR]를 포함하고 있는데 왜 자꾸 실패할까? 라고 생각했다. 그래서 우테코에서 만든 라이브러리를 보면서 어떻게 비교하는지 역추적해보았다. 일단 output()에 에러 형식이 포함되어있나 확인하는 메서드이기 때문에, output()에 들어가보았다. 

 

 

보니까 captor라는 변수를 문자열로 출력을 한다. 

 

 

captor가 뭔지 찾아보니, OutputStream에 대한 변수였다. 

 

 

위 사진의 빨간 박스들을 중요하게 봐야한다. OutputStream은 테스트를 진행할 때 콘솔에 찍히는 것을 저장했다가 출력할 때 사용될 수 있다. (아래는 OutputStream을 사용하는 코드다. 실제로 테스트케이스를 작성할 때 사용하였다.)

    @DisplayName("통계 가격이 잘 나오는지 확인")
    @Test
    void when_stats_Expect_getAmount() {
        OutputStream outputStream = new ByteArrayOutputStream();
        System.setOut(new PrintStream(outputStream));
        System.out.println(Stats.getAmount(Stats.THREE));
        assertThat(outputStream.toString()).contains("5,000");
    }

 

아래와 같이 해주면 그 다음부터 콘솔에 찍히는 것들은 outputStream에 저장된다. 

OutputStream outputStream = new ByteArrayOutputStream();
        System.setOut(new PrintStream(outputStream));

 

이 말은, 에러메세지가 콘솔에 찍혀야 한다는 것이었다. 즉 콘솔에 찍히지 않는 빨간 메세지는 에러메세지가 아니었다.

그럼 어떻게 내가 원하는 메세지를 찍고 종료시킬 수 있을까 ? 바로 try, catch를 이용한다. 

        try {
            Game game = new Game();
            game.play();
        } catch (IllegalArgumentException e) {
            System.out.println(e.getMessage());
        }

 

play()에서 발생된 IllegalArgumentException을 다 잡아서, 해당 메세지를 출력한다. 그럼 우리는 해당 메세지만 커스텀해주면 된다. (아래는 예시코드다.)

throw new IllegalArgumentException("[ERROR] 숫자가 아닙니다.");

 

get/set 메서드를 지양하라

최근에 OOP와 객체지향이랑 같이 관심이 있었던 도메인 주도 개발에 대해서 알아보았다. 도메인 주도 개발의 특징 중 하나는 get/set 메서드를 넣지 않는 것이다. (줄여서 get 메서드라고 부르겠습니다.) get메서드를 사용하지 않고 해당 도메인에서 로직을 처리함으로써 유지보수와 객체지향의 원칙 중 하나인 디미터의 법칙을 지킬 수 있다. 또한 객체에 데이터가 아닌 메세지를 보내는 것을 중심으로 설계하는데 도움이 된다. 

 

그럼 어떻게 get 메서드를 없앨 수 있는지 설명하겠다. 통계에서는 구매자의 로또번호들과 당첨 로또 번호를 비교하는 메서드가 있다. 

    public static void compare(List<Lotto> lottos, List<Integer> winLottoNumbers, int bonusNumber) {
        lottos.forEach(lotto -> {
            boolean hasBonusNumber = lotto.getNumbers().contains(bonusNumber);
            List<Integer> tempLottoGetNumbers = new ArrayList<>(lotto.getNumbers());
            tempLottoGetNumbers.retainAll(winLottoNumbers);
            Stats stats = valueOfName(tempLottoGetNumbers);
            addCount(stats, hasBonusNumber);
        });

        printStats();
    }

 

여기서 get 메서드가 보인다. 위 코드를 보면 로또용지에 적힌 번호들을 가지고 와서 보너스 번호가 포함하는지 확인하는 로직이 존재한다.

boolean hasBonusNumber = lotto.getNumbers().contains(bonusNumber);

 

보너스 번호가 포함하는지 확인하는 일은 로또가 할 수 있다. 따라서 다음과 같이 바꾸었다. 

boolean hasBonusNumber = lotto.isContainsBonusNumber(bonusNumber);

 

Lotto.java

    public boolean isContainsBonusNumber(int bonusNumber) {
        return this.numbers.contains(bonusNumber);
    }

 

벌써 get 메서드 한개를 지웠다! 남은 한개도 얼른 지워보자. 아래코드는 로또용지를 복사해서 복사한 용지의 번호에서 당첨된 로또 번호들을 제외한 나머지 숫자들을 삭제한다. 예를 들어 로또 용지가 [3,4,6,7,8,9] 이고, 당첨된 로또 번호는 [3,4,19,45,32,2] 라고 한다면, 중복되는 3,4를 제외한 나머지 숫자를 제거하는 식이다. 

List<Integer> tempLottoGetNumbers = new ArrayList<>(lotto.getNumbers());
            tempLottoGetNumbers.retainAll(winLottoNumbers);

 

이 로직도 로또가 할 수 있는 일이다. 그럼 바꿔보자. 

List<Integer> lottoNumbers = lotto.deleteNotWinLottoNumbers(winLottoNumbers);

 

Lotto.java

    public List<Integer> deleteNotWinLottoNumbers(List<Integer> winLottoNumbers) {
        List<Integer> tempLottoGetNumbers = new ArrayList<>(this.numbers);
        tempLottoGetNumbers.retainAll(winLottoNumbers);
        return tempLottoGetNumbers;
    }

 

한결 깔끔해졌다. 통계에서 존재했던 불필요한 로직을 분리함으로써 가독성도 좋아졌다. 이렇게 get 메서드를 지양하면서 좀 더 객체지향적으로 구현할 수 있었다. 과제를 하면서 아쉬웠던 점도 있었다.

 

MVC 패턴을 사용하지 못한 점

이번 과제에서 MVC 패턴을 적용해볼까 했지만, 결국 적용하지 못했다. 아래는 이번 과제에서 분리한 패키지 방식인데, Game을 controller 패키지에 따로 분리시키고, domain을 model로 바꾸면 그럴싸한 MVC패턴이 될 것 같지만 그냥 이대로 두었다. (뭔가 OOP와 객체지향에 집중한 패키지 형식이 좋았다.)

 

 

4주차에서는 MVC 패턴을 적용해봐야겠다. 

 

테스트하기 좋은 코드

아래 테스트코드는 구매자가 구입한 금액만큼 로또용지가 생성되는지 확인하는 코드다.

    @DisplayName("입력한 금액만큼 로또용지들이 생성되는지 확인")
    @Test
    void createLottoByLottoAmount() {
        Buyer buyer = new Buyer();
        String amount = "9000";
        InputStream inputStream = new ByteArrayInputStream(amount.getBytes());
        System.setIn(inputStream);
        buyer.buyLotto();
        assertThat(buyer.getLottos().size()).isEqualTo(9);
    }

 

여기서 문제점은 구매자가 로또를 사는 메서드 안에서 입력을 하기 때문에 위와 같이 InputStream을 부가적으로 써주어야 한다.  해당 코드에서 입력을 매개변수로 받으면 더 간단하게 테스트할 수 있다. 

 

    @DisplayName("입력한 금액만큼 로또용지들이 생성되는지 확인")
    @Test
    void createLottoByLottoAmount() {
        Buyer buyer = new Buyer();
        String amount = "9000";
        buyer.buyLotto(amount);
        assertThat(buyer.getLottos().size()).isEqualTo(9);
    }

 

하지만 과연 이렇게 리팩토링하는 것이 맞는지 모르겠다. 왜냐하면 테스트만을 위한 코드를 생성하거나 수정하는 것을 조심해야 하기 때문이다. 적어도 내 코드는 테스트 하기 좋지 않은 코드였고, 많이 아쉬웠다. 

 

느낀점

처음에는 회고록에 대해서 부정적이었다. 하지만 이렇게 작성을 해보니까 , 뜻밖의 이득을 얻었다!

배운내용에 get/set 메서드를 지양하라 부분에서 get 메서드를 쓴 부분을 나열해보니 리팩토링할 부분이 생겼다. (그래서 얼른 고쳤다.) 또한 내 머릿속을 다시 한번 정리할 수 있었고, 기록으로 남겨지니까 다음에 또 찾아볼 수 있다. 남은 과제도 끝나면 회고를 써봐야겠다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday