TDD 모험기

이석환·2023년 11월 8일
0

우아한 테크 코스

목록 보기
2/2
post-thumbnail

저번 글 의식적인 연습으로 TDD, 리팩토링 연습하기 에서 학습한 내용과 최범균님의 TDD 실습을 학습하며, 이번 로또 미션에 TDD를 적용해보기로 했다.

이번 글에서는 내가 어떻게 TDD를 적용했는 지 작성하고자 한다.
나는 이번 미션을 진행하며, README에 작성했던 순서대로 테스트 코드를 작성했다.

❓ How

내가 TDD를 구현하며, 가장 많이 참고했던 자료는 최범균님의 세미나 공유 - TDD 소개 및 시연이다.
굉장히 도움이 되는 내용이 많으니, 한 번 보면 좋을 것 같다.
기본적으로 나는 이번 미션의 요구 사항인 도메인에 대한 테스트에 집중해서 테스트 코드를 작성했다.

순서는 다음과 같다.
1. 실행이 되지 않는 테스트 코드를 작성한다.
2. 그에 맞춰 Test 패키지 안에 프로덕션 코드를 작성한다.
3. 테스트 코드를 Run 시켜 작동이 되는 걸 확인한다.
4. 프로덕션 코드를 리팩토링한다.
5. 테스트를 다시 Run 시켜서 작동이 된다면 Main 패키지로 이동시킨다.

💡 로또 구입 금액을 입력받는다.

구입한 금액이 1000원 단위가 아닐 경우 예외가 발생한다.

Test Code

package lotto.domain;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

public class UserTest {
    @Test
    @DisplayName("구입한 금액이 1000원 단위가 아닐 경우 예외가 발생한다.")
    public void 구입_금액_천원_단위_테스트() throws Exception {
        Assertions.assertThrows(IllegalArgumentException.class, () -> {
            new User().purchaseLotto("1500"); 
        });
    }

}

현재 이 상태에서는 User가 존재하지 않기 때문에 빨간불이 뜨며, 정상적으로 테스트가 되지 않는다.

Production Code

package lotto.domain;

public class User {
    public void purchaseLotto(String purchaseAmount) {
        throw new IllegalArgumentException("[ERROR] 구입 금액은 1,000원 단위여야 합니다.");
    }
}

위에 테스트 코드에 맞춰서 프로덕션 코드를 작성한다.
조건에 맞게 수정한다.

package lotto.domain;

public class User {
    public void purchaseLotto(String purchaseAmount) {
        int purchasedAmount = Integer.parseInt(purchaseAmount);
        if (purchasedAmount % 1000 != 0) {
            throw new IllegalArgumentException("[ERROR] 구입 금액은 1,000원 단위여야 합니다.");
        }
    }
}

같은 방식으로 다른 예외 상황 테스트를 작성해보겠다.

구입한 금액에 문자열이 입력되면 예외가 발생한다.

Test

package lotto.domain;

import static org.junit.jupiter.api.Assertions.assertThrows;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

public class UserTest {
    @Test
    @DisplayName("구입한 금액이 1000원 단위가 아닐 경우 예외가 발생한다.")
    public void 구입_금액_천원_단위_테스트() throws Exception {
		    [...]
    }

    @Test
    @DisplayName("구입한 금액에 문자열이 입력되면 예외가 발생한다.")
    public void 구입_금액_문자열_테스트() throws Exception {
		    assertThrows(IllegalArgumentException.class, () -> {
            new User().purchaseLotto("aaa");
        });
    }
}

Production Code

package lotto.domain;

public class User {
    public void purchaseLotto(String purchaseAmount) {
        if (!purchaseAmount.matches("\\d+")) {
            throw new IllegalArgumentException("[ERROR] 구입 금액은 숫자로만 입력되어야 합니다.");
        }
        [...]
    }
}

구입 금액이 최소 1,000원이 아니면 예외가 발생한다.

Test

package lotto.domain;

import static org.junit.jupiter.api.Assertions.assertThrows;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

public class UserTest {
    @Test
    @DisplayName("구입한 금액이 1000원 단위가 아닐 경우 예외가 발생한다.")
    public void 구입_금액_천원_단위_테스트() throws Exception {
				[...]
    }

    @Test
    @DisplayName("구입한 금액에 문자열이 입력되면 예외가 발생한다.")
    public void 구입_금액_문자열_테스트() throws Exception {
				[...]
    }

    @Test
    public void 구입_금액_최소_테스트() throws Exception {
				assertThrows(IllegalArgumentException.class, () -> {
            new User().purchaseLotto("0");
        });
    }

}

Production Code

package lotto.domain;

public class User {
    public void purchaseLotto(String purchaseAmount) {
        [...]

        if(purchasedAmount == 0){
            throw new IllegalArgumentException("[ERROR] 구입 금액은 최소 1000원입니다.");
        }
    }
}

전체 리팩토링

package lotto.domain;

public class User {
    public void purchaseLotto(String purchaseAmount) {
        validatePurchaseAmount(purchaseAmount);
    }

    private void validatePurchaseAmount(String purchaseAmount){
        validateOnlyNumberAmount(purchaseAmount);
        int purchasedAmount = Integer.parseInt(purchaseAmount);
        validate1000UnitAmount(purchasedAmount);
        validateZeroAmount(purchasedAmount);
    }

    private void validateOnlyNumberAmount(String purchaseAmount) {
        if (!purchaseAmount.matches("\\d+")) {
            throw new IllegalArgumentException("[ERROR] 구입 금액은 숫자로만 입력되어야 합니다.");
        }
    }

    private void validateZeroAmount(int purchasedAmount) {
        if(purchasedAmount == 0){
            throw new IllegalArgumentException("[ERROR] 구액 금액은 최소 1000원입니다.");
        }
    }

    private void validate1000UnitAmount(int purchasedAmount) {
        if (purchasedAmount % 1000 != 0) {
            throw new IllegalArgumentException("[ERROR] 구입 금액은 1,000원 단위여야 합니다.");
        }
    }
}

테스트 코드 확인 후 이상없이 작동된다면 Main 패키지로 이동한다.

✏️ 느낀점

TDD 방식으로 구현하면서 좋았던 점은 예외 상황을 미리 컨트롤 할 수 있었다.
문자로 입력되거나 0원이 입력되는 케이스는 기존에 README를 작성하며 생각하지 못했다.
Test code를 작성하며 어떤 점을 테스트 할 수 있을 지 계속해서 생각하며 탐구하니 찾을 수 있었다.

🤔 고민

리팩토링을 하는 과정에서 테스트 코드도 수정해야 하는 상황이 발생했다.
예를 들어 값 변경을 시키고 싶지 않아서 final로 선언해야 하는 상황인데, 그렇게 하려면 생성자로 초기화를 해야해서 테스트 코드를 바꿔야 하는 일이 생겼다.
초기에 테스트 코드를 잘못 작성해서 생긴 문제였다.

💡 당첨 번호를 입력받는다.

당첨 번호가 6개가 아닐 경우에 예외가 발생한다.

Test

package lotto.domain;

import static org.junit.jupiter.api.Assertions.assertThrows;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

public class LottoWinningNumbersTest {
    @Test
    @DisplayName("입력받은 당첨 번호가 6개보다 많으면 예외가 발생한다.")
    public void 당첨_번호_6개보다_많은_경우() throws Exception {
        //given
        String winningNumbersString = "1,2,3,4,5,6,7";

        //when
        List<Integer> winningNumbers = Arrays.stream(winningNumbersString.split(","))
                .map(Integer::parseInt)
                .collect(Collectors.toList());
        
        //then
        assertThrows(IllegalArgumentException.class, () -> new LottoWinningNumbers(winningNumbers));
    };

    @Test
    @DisplayName("입력받은 당첨 번호가 6개보다 적으면 예외가 발생한다.")
    public void 당첨_번호_6개보다_적은_경우_테스트() throws Exception {
        String winingNumbers = "1,2,3,4,5"`
		[...]
    };

}

Production Code

package lotto.domain;

import java.util.List;

public class LottoWinningNumbers {
    private final List<Integer> winningNumbers;

    public LottoWinningNumbers(List<Integer> winningNumbers) {
        validateWinningNumbersSize(winningNumbers);
        this.winningNumbers = winningNumbers;
    }

    private void validateWinningNumbersSize(List<Integer> winningNumbers){
        if(winningNumbers.size() != 6){
            throw new IllegalArgumentException("[ERROR] 당첨 번호는 6글자로 입력되어야 합니다.");
        }
    }
}

순서
String 하나 생성 List로 파싱 → 생성자로 던져줌 → 주황불 없애기 → LottoWinningNumbers 클래스 만듬
이전 User에서 final로 선언할 때 생성자로 던져줘야 했었기 때문에 이번엔 미리 생성자로 생성해서 리팩토링 과정 최소화
7글자 입력들어와서 예외처리 확인하면 5글자도 확인하는 테스트를 만들었다.

리팩토링

중복되는 코드를 메서드로 분리했다.

package lotto.domain;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

public class LottoWinningNumbersTest {

    @Test
    @DisplayName("입력받은 당첨 번호가 6개보다 많으면 예외가 발생한다.")
    public void 당첨_번호_6개보다_많은_경우() throws Exception {
        [...]

        //when
        List<Integer> winningNumbers = convertToIntegerList(winningNumbersString);

        [...]
    }

    

    @Test
    @DisplayName("입력받은 당첨 번호가 6개보다 적으면 예외가 발생한다.")
    public void 당첨_번호_6개보다_적은_경우() throws Exception {
        //given
        [...]

        //when
        List<Integer> winningNumbers = convertToIntegerList(winningNumbersString);

        [...]
    }

    private List<Integer> convertToIntegerList(String numbersString) {
        return Arrays.stream(numbersString.split(","))
                .map(Integer::parseInt)
                .collect(Collectors.toList());
    }
}

1 ~ 45 범위가 아니면 예외가 발생한다.

Test

package lotto.domain;

import static org.junit.jupiter.api.Assertions.assertThrows;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

public class LottoWinningNumbersTest {

[...]

@Test
public void 당첨_번호_범위() throws Exception {
    //given
    String winningNumberZeroString = "0,1,2,3,4,5";
    String winningNumberOverString = "1,2,3,4,5,46";

    //when
    List<Integer> winningNumberZero = convertToIntegerList(winningNumberZeroString);
    List<Integer> winningNumberOver = convertToIntegerList(winningNumberOverString);
    //then
    assertThrows(IllegalArgumentException.class, () -> new LottoWinningNumbers(winningNumberZero));
    assertThrows(IllegalArgumentException.class, () -> new LottoWinningNumbers(winningNumberOver));
}


[...]

}

Production Code

package lotto.domain;

import java.util.List;

public class LottoWinningNumbers {
    private final List<Integer> winningNumbers;

    public LottoWinningNumbers(List<Integer> winningNumbers) {
        validateWinningNumbersSize(winningNumbers);
        validateWinningNumberRange(winningNumbers);
        this.winningNumbers = winningNumbers;
    }

    private void validateWinningNumbersSize(List<Integer> winningNumbers){
        [...]
    }

    public void validateWinningNumberRange(List<Integer> winningNumbers){
        for(int i = 0; i < winningNumbers.size(); i++){
            if(winningNumbers.get(i) <= 0 || winningNumbers.get(i) > 45){
                throw new IllegalArgumentException("[ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다.");
            }
        }
    }
}

당첨 번호끼리 중복이 되면 예외가 발생한다.

Test

package lotto.domain;
[...]

public class LottoWinningNumbersTest {

   [...]

    @Test
    @DisplayName("입력받은 당첨 범위에 중복이 있을 경우 예외가 발생한다.")
    public void 당첨_번호_중복() throws Exception {
        //given
        String winnerNumberDuplicateString = "1,1,2,3,4,5";

        //when
        List<Integer> winningNumberDuplicate = convertToIntegerList(winnerNumberDuplicateString);

        //then
assertThrows(IllegalArgumentException.class, () -> new LottoWinningNumbers(winningNumberDuplicate));
    }


    [...]
}

Production Code

package lotto.domain;

[...]

public class LottoWinningNumbers {
    private final List<Integer> winningNumbers;

    public LottoWinningNumbers(List<Integer> winningNumbers) {
        [...]
        validateWinningNumberDuplicate(winningNumbers);
        this.winningNumbers = winningNumbers;
    }

    private void validateWinningNumbersSize(List<Integer> winningNumbers) {
        [...]
    }

    private void validateWinningNumberRange(List<Integer> winningNumbers) {
        [...]
    }

    private void validateWinningNumberDuplicate(List<Integer> winningNumbers) {
        Set<Integer> winningNumbersDuplicate = new HashSet<>();
        for (Integer number : winningNumbers) {
            if (!winningNumbersDuplicate.add(number)) {
                throw new IllegalArgumentException("[ERROR] 로또 번호는 중복이 입력될 수 없습니다.");
            }
        }
    }

}

전체 리팩토링

[...]

public class LottoWinningNumbers {
    private final List<Integer> winningNumbers;

    public LottoWinningNumbers(List<Integer> winningNumbers) {
        validateWinningNumbers(winningNumbers);
        this.winningNumbers = winningNumbers;
    }

    private void validateWinningNumbers(List<Integer> winningNumbers){
        validateWinningNumbersSize(winningNumbers);
        validateWinningNumberRange(winningNumbers);
        validateWinningNumberDuplicate(winningNumbers);
    }
    private void validateWinningNumbersSize(List<Integer> winningNumbers) {
        [...]
    }

    private void validateWinningNumberRange(List<Integer> winningNumbers) {
        if (winningNumbers.stream().anyMatch(number -> number <= 0 || number > 45)) {
            throw new IllegalArgumentException("[ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다.");
        }
    }

    private void validateWinningNumberDuplicate(List<Integer> winningNumbers) {
        if (winningNumbers.stream().distinct().count() != winningNumbers.size()) {
            throw new IllegalArgumentException("[ERROR] 로또 번호는 중복이 입력될 수 없습니다.");
        }
    }

}

테스트 코드 확인 후 이상없이 작동된다면 Main 패키지로 이동한다.

🤔 고민

여기서 List 자료형으로 가지려고 했기 때 파싱할 때 정수가 아닌 게 들어오는 지 검사하려면 String → List 파싱할 때 해줘야 한다.
물론 NumberFormatException 던져주겠지만 IllegalArgument를 던지기 위해서 작업이 필요하다.

근데 이렇게 구현한 거 자체가 DTO → List로 만드려고 했던 것이다.
그래서 당시에 테스트 코드와 프로덕션 코드를 작성할 때는 잘못된 입력에 대한 처리는 테스트 하지 않고 DTO를 생성하고 만드려주려고 넘어갔었다.

회고록에서 이야기하겠지만, 여기서 고민한 내용때문에 정말 오래 그리고 깊게 생각했고 덕분에 테스트 코드만 이틀은 잡고 고민했었다.
그리고 덕분에 다음 미션에 대한 방향성도 어느 정도 잡히게 되었다.

💡 보너스 번호를 입력받는다.

❓ 테스트 전 고민

우선 보너스 번호를 입력받는 테스트 코드를 작성하기 전에 고민했던 것이 있다.
당첨 번호와 보너스 번호를 같은 객체에서 관리할 것인가 였다.
사실 난 당시에는 단순히 이번 미션에서 중요하게 생각했던 점이 도메인의 분리였기 때문에 두 개로 나누었으나,
나중에 수익률을 계산하기 위해 메서드 매개 변수로 3개의 입력을 줘야하는 아이러니한 상황이 발생했다.
아마 돌아간다면 하나로 관리했을 것 같다.
당시 나는 3가지를 고민했었다.

  1. 단일 객체로 관리
  • LottoWinningNumbers 클래스 내에 bonusNumber 필드를 추가.
  • 이렇게 하면 한 번에 당첨 번호와 보너스 번호를 한 객체에서 관리할 수 있다.
  • LottoWinningNumbers 클래스가 보너스 번호의 유효성을 검사하는 역할도 수행할 수 있다.
  public class LottoWinningNumbers {
    private List<Integer> winningNumbers;
    private int bonusNumber;

    public LottoWinningNumbers(List<Integer> winningNumbers, int bonusNumber) {
        // 유효성 검사 및 초기화
    }

    // 다른 메서드 및 필드 추가
}
  1. 별도의 객체로 분리
  • LottoWinningNumbers 클래스와 LottoWinningBonusNumbers 클래스를 별도로 생성하여 각각 당첨 번호와 보너스 번호를 관리할 수 있다.
  • 각 객체는 각자의 유효성 검사와 초기화 로직을 포함한다.
  • 번호를 독립적으로 관리하고자 할 때 유용할 것 같았다.
public class LottoWinningNumbers {
    private List<Integer> winningNumbers;

    public LottoWinningNumbers(List<Integer> winningNumbers) {
        // 유효성 검사 및 초기화
    }

    // 다른 메서드 및 필드 추가
}

public class LottoWinningBonusNumbers {
    private int bonusNumber;

    public LottoWinningBonusNumbers(int bonusNumber) {
        // 유효성 검사 및 초기화
    }

    // 다른 메서드 및 필드 추가
}
  1. 조합하여 사용
  • LottoWinningNumbers 클래스 내에 LottoWinningBonusNumbers 객체를 포함한다.
  • 이렇게 하면 두 번호를 별도로 관리하면서 하나의 객체 안에서 조합하여 사용할 수 있다.
public class LottoWinningNumbers {
    private List<Integer> winningNumbers;
    private LottoWinningBonusNumbers bonusNumbers;

    public LottoWinningNumbers(List<Integer> winningNumbers, LottoWinningBonusNumbers bonusNumbers) {
        // 유효성 검사 및 초기화
    }

    // 다른 메서드 및 필드 추가
}

public class LottoWinningBonusNumbers {
    private int bonusNumber;

    public LottoWinningBonusNumbers(int bonusNumber) {
        // 유효성 검사 및 초기화
    }

    // 다른 메서드 및 필드 추가
}

나는 고민하다가 클래스를 분리하는 것에 중점을 두고자 2번 별도의 객체로 분리하여 관리했다.
다만 위에서도 언급했듯이, 클린 코드에서는 메서드 인자 개수를 통제하는 것을 중요시하게 여겼는데 이렇게 분리하여 관리하니 오히려 반대로 매개 변수의 수가 늘어나는 단점이 생겼다.
이럴 때는 어떤 선택을 해야 옳은 것인지 조금 더 생각을 해봐야 알 것 같다.

보너스 번호의 범위가 1 ~ 45 범위가 아니면 에외가 발생한다.

Test

package lotto.domain;

import static org.junit.jupiter.api.Assertions.assertThrows;

import org.junit.jupiter.api.Test;

public class LottoWinningBonusNumberTest {
    @Test
    public void 보너스_번호_범위() throws Exception {
        //given
        String winningBonusNumberZeroString = "0";
        String winningBonusNumberOverString = "46";

        //when
        int winningBonusNumberZero = convertToInteger(winningBonusNumberZeroString);
        int winningBonusNumberOver = convertToInteger(winningBonusNumberOverString);

        //then
assertThrows(IllegalArgumentException.class, () -> {
            new LottoWinningBonusNumber(winningBonusNumberZero);
        });

assertThrows(IllegalArgumentException.class, () -> {
            new LottoWinningBonusNumber(winningBonusNumberOver);
        });

    }


    private int convertToInteger(String winningBonusNumberString) {
        //todo: service에서 처리할 로직, 정수에 대한 입력이 아닐 경우 예외 처리
try {
            int bonusNumber = Integer.parseInt(winningBonusNumberString);
            return bonusNumber;
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException("[ERROR] 보너스 번호는 숫자로 입력되어야 합니다.");
        }
    }
}

Production Code

package lotto.domain;

public class LottoWinningBonusNumber {
    private final int winningBonusNumber;
    public LottoWinningBonusNumber(int winningBonusNumber) {
        validateBonusNumberRange(winningBonusNumber);
        this.winningBonusNumber= winningBonusNumber;
    }

    private void validateBonusNumberRange(int winningBonusNumber){
        if(winningBonusNumber < 1 || winningBonusNumber > 45){
            throw new IllegalArgumentException("[ERROR] 보너스 번호는 1부터 45 사이의 숫자여야 합니다.");
        }
    }
}

당첨 번호와 중복되는 숫자가 존재하면 예외가 발생한다.

우선 여기서 내가 별도의 객체로 관리한 이유가 나온다.
여기서 보너스 번호가 당첨 번호에 이미 있으면 생성 안 되게 하려면 어쩔 수 없이 보너스 번호 객체에서 당첨 번호를 알고 있어야 한다.
그래서 winningBonusNumber 생성자에 List<Integer> winningNumbers를 매개 변수로 넘겨줬다

왜 그렇게 했나 ?
실제 로또 로직을 생각해보니 어차피 2등에 당첨될 때만 보너스 번호가 필요하다.
그래서 비교할 때는 로또가 일단 5개 맞고 1개 틀린 상태에서 보너스를 비교하면 되는데
객체 자체를 그럼 분리하는게 맞다고 생각해서 당첨 번호랑 보너스 두 개를 분리시켰다.
당첨 번호 도메인에 보너스 번호도 필드값으로 가지고 있으면 더 편하겠지만, 로직상 이게 맞다고 판단함
그래서 LottoWinningBonusNumber의 생성자가 변경된 걸 볼 수 있다.

Test

package lotto.domain;

import java.util.List;

public class LottoWinningBonusNumber {
    private final int winningBonusNumber;
    public LottoWinningBonusNumber(int winningBonusNumber, List<Integer> winningNumbers) {
        validateBonusNumberRange(winningBonusNumber);
        validateWinningBonusNumberAlreadyExists(winningBonusNumber, winningNumbers);
        this.winningBonusNumber= winningBonusNumber;
    }

    private void validateBonusNumberRange(int winningBonusNumber){
        if(winningBonusNumber < 1 || winningBonusNumber > 45){
            throw new IllegalArgumentException("[ERROR] 보너스 번호는 1부터 45 사이의 숫자여야 합니다.");
        }
    }

    private void validateWinningBonusNumberAlreadyExists(int bonusNumber, List<Integer> winningNumbers){
        if(winningNumbers.contains(bonusNumber)){
            throw new IllegalArgumentException("[ERROR] 보너스 번호가 이미 당첨 번호에 존재합니다.");
        }
    }
}

Production Code

package lotto.domain;

import java.util.List;

public class LottoWinningBonusNumber {
    private final int winningBonusNumber;
    public LottoWinningBonusNumber(int winningBonusNumber, List<Integer> winningNumbers) {
        validateBonusNumberRange(winningBonusNumber);
        validateWinningBonusNumberAlreadyExists(winningBonusNumber, winningNumbers);
        this.winningBonusNumber= winningBonusNumber;
    }

    private void validateBonusNumberRange(int winningBonusNumber){
        if(winningBonusNumber < 1 || winningBonusNumber > 45){
            throw new IllegalArgumentException("[ERROR] 보너스 번호는 1부터 45 사이의 숫자여야 합니다.");
        }
    }

    private void validateWinningBonusNumberAlreadyExists(int bonusNumber, List<Integer> winningNumbers){
        if(winningNumbers.contains(bonusNumber)){
            throw new IllegalArgumentException("[ERROR] 보너스 번호가 이미 당첨 번호에 존재합니다.");
        }
    }
}

보너스 번호는 리팩토링할 것이 딱히 없었다.
테스트 코드 확인 후 이상없이 작동된다면 Main 패키지로 이동한다.

💡 구입 금액만큼 로또를 발행한다.

로또 생성에 관해서는 우테코에서 제공해주는 테스트가 있었다.
거기에 맞게 중복을 검사하는 메소드만 추가해주면 끝났다.
따로 TDD할 게 없었다.
로또 발행에서 내가 생각한 예외처리는 6개, 중복된 숫자, 1~45인데 이미 우테코 측에서 제공해주는 테스트에 처음 두 개가 있고, 범위는 라이브러리 Random에서 이미 예외처리 되어 있었다.
그래서 그냥 1~45 발행시키는 로직이랑 발행된 로또 저장하는 도메인만 구현했다.

LottoServiceTest

Test

package lotto.service;

import static org.junit.jupiter.api.Assertions.assertEquals;

import lotto.domain.PurchasedLotto;
import lotto.domain.User;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

public class LottoServiceTest {

    @Test
    @DisplayName("구입 금액만큼 로또가 생성되는지 검사한다.")
    public void 로또_구매_테스트() throws Exception {
        //given
        User user = new User("10000");

        //when
        PurchasedLotto purchasedLotto = LottoService.lottoGenerator(user.getAmount());

        //then
assertEquals(10, purchasedLotto.getPurchasedLotto().size());
    }

}

Production Code

package lotto.service;

import camp.nextstep.edu.missionutils.Randoms;
import java.util.ArrayList;
import java.util.List;
import lotto.Lotto;
import lotto.domain.PurchasedLotto;

public class LottoService {
    public static PurchasedLotto lottoGenerator(int purchaseAmount) {
        int pickCount = purchaseAmount / 1000;
        List<Lotto> lottos = new ArrayList<>();
        for (int i = 0; i < pickCount; i++) {
            List<Integer> purchasedOneLotto = Randoms.pickUniqueNumbersInRange(1, 45, 6);
            Lotto lotto = new Lotto(purchasedOneLotto);
            lottos.add(lotto);
        }
        return new PurchasedLotto(lottos);
    }
}

테스트 코드 확인 후 이상없이 작동된다면 Main 패키지로 이동한다.
추가로 Service는 거의 바꼈다.
이것도 회고록에 따로 서술하겠다.

💡 로또를 출력한다.

우선 DTO를 만들었다. (해당 내용은 또 회고록에 따로 서술하겠다. 할 말이 많다.)
그리고 테스트 코드 작성하고
Service에 로직을 작성하는 TDD로 하기로 했다.


왜 이렇게 했냐
model이랑 view랑 만든 다음 레고처럼 Controller를 제일 마지막에 구현해서 조립하고 싶었다.
근데 직접 테스트 하려면 Controller가 필요했다.
그래서 테스트로 내 코드가 맞는 지 확인하고 싶어서 만든 테스트다.
약간 무식한 방법의 테스트였다고 생각한다.
물론 이 코드는 프로덕션 코드를 작성하며 약간의 수정이 있었다.

LottoServiceTest

Test

@Test
@DisplayName("생성된 로또가 출력되는지 검사한다.")
public void 로또_출력() throws Exception {
    //given
    Lotto lotto1 = new Lotto(List.of(1, 2, 3, 4, 5, 6));
    Lotto lotto2 = new Lotto(List.of(10, 11, 12, 13, 14, 15));
    Lotto lotto3 = new Lotto(List.of(40, 41, 42, 43, 44, 45));
    List<Lotto> lottos = new ArrayList<>();
    lottos.add(lotto1);
    lottos.add(lotto2);
    lottos.add(lotto3);

    //when
    PurchasedLottoNumbers purchasedLottoNumbers = new PurchasedLottoNumbers(lottos);
    PurchasedLottoDTO purchasedLottoDTO = LottoService.purchasedLottoToDTO(purchasedLottoNumbers);

    //then
    String lottoDTO1 = Arrays.toString(purchasedLottoDTO.getPurchasedLotto().get(0).getNumbers().toArray());
    String lottoDTO2 = Arrays.toString(purchasedLottoDTO.getPurchasedLotto().get(1).getNumbers().toArray());
    String lottoDTO3 = Arrays.toString(purchasedLottoDTO.getPurchasedLotto().get(2).getNumbers().toArray());
    Assertions.assertEquals("[1, 2, 3, 4, 5, 6]", lottoDTO1);
    Assertions.assertEquals("[10, 11, 12, 13, 14, 15]", lottoDTO2);
    Assertions.assertEquals("[40, 41, 42, 43, 44, 45]", lottoDTO3);
}

👍 내가 느낀 TDD의 장점

  • 예외 상황을 미리 컨트롤 할 수 있다.
    최대한 테스트에 중점적으로 생각하다보니 요구사항을 분석하며 README를 작성할 때 발견하지 못했던 예외를 찾을 수 있었다.

  • 빠른 피드백이 가능하다.
    기존 방식대로 프로덕션 코드를 먼저 작성하고 테스트를 작성할 때는 내가 생각한 대로 로직이 되지 않을 경우가 많았다. 그리고 그럴 때마다 프로덕션 코드를 테스트에 끼워 맞추기 위해 작성하는 과정이 굉장히 오래 걸렸다.

    또한, 테스트에 통과하기 위한 프로덕션 코드를 수정하며 내부에 있던 다른 메서드를 호출하는 로직도 수정해야 하는 경우가 빈번했다.
    하지만, 미리 테스트 코드를 작성하고 프로덕션 코드를 작성하니 즉시 테스트 케이스를 실행하니 별다른 수정없이 프로덕션 코드를 작성할 수 있었다.

👎 내가 느낀 단점

  • 기존에 프로덕션 코드 작성 → 테스트 코드 작성보다 더 많은 시간이 요구 되었다.
    프리코스의 경우 일주일의 시간이 주어진다.
    그래서 덕분에 학습도 하고 틀린 부분에 대해서 꼼꼼하게 따져보며 작성할 수 있었다.
    다만, 최종 코딩테스트와 같이 시간내에 구현해야 하는 상황이 발생한다면 이를 적용하기에는 쉽지 않을 것 같다.


  • 프로덕션 코드를 리팩토링 하는 과정에서 테스트 코드를 수정해야 하는 상황이 생긴다.
    물론 테스트 코드 또한 코드이기 때문에 리팩토링 하는 것이 당연하다. 또한, TDD를 처음 해보았기 때문에 처음부터 여러 상황을 고려하지 않고 코드를 작성하여 생긴 문제라고 생각한다.

    예를 들어 나는 다음과 같은 상황이 발생했다.
    이번 주차는 유효성 검사를 통해 올바른 입력이 들어올 때까지 다시 입력받아야 한다.
    기존에는 원본 객체에서 유효성 검사를 진행했기 때문에 테스트 코드와 프로덕션 코드 모두 원본 객체를 대상으로 작성했다.
    이후, View에서 들어오는 데이터를 DTO로 받으면서 유효성 검사를 원본 객체와 DTO 둘 중 한 곳에서 해야하는 상황이 발생했다.
    나는 원본 객체에는 순수한 값을 가지고 있고, DTO가 레이어간의 전송을 담당하기 때문에 당연히 DTO에서 유효성을 검사하고 안전한 데이터라면 원본 객체에 저장하는 것이 맞다고 생각하였다.

    그래서 DTO에서 유효성 검사를 진행하니, 모든 테스트 코드를 수정해야 하는 일이 생겼다.
    물론 크게 복잡하지는 않았다.
    예를 들어 new LottoNumber(”1,2,3,4,5,6”) 를 new LottoNumberDTO(”1,2,3,4,5,6”)와 같이 이름만 바꿔주면 모든 테스트가 통과했다.
    하지만, 이러한 식으로 리팩토링을 하는 것이 맞는가라는 생각을 했다.

💡 마무리

TDD에 관해서 찾다보니 아직까지 많은 논쟁이 있는 것 같다.
나도 사용하기 전에는 반대 입장처럼 프로덕션 코드를 작성하지 않고 테스트 코드를 작성한다는 것이 마치 화장실을 가기 전에 손을 씻고 나올 때 안 씻는 거 아닐까? 라는 생각을 했다.
막상 TDD 방식으로 작성하고 나니 생각보다 안정적인 개발을 할 수 있고 장점이 많다는 생각이 들었다.
제일 좋았던 점은 미리 테스트 케이스를 처리하니 그 후로 안정적인 코드를 작성할 수 있다는 점이다.
다만, 단점에서도 언급했듯이 리팩토링을 하는 과정에서 테스트 코드를 계속 수정하는 점이 마음에 걸렸다.
TDD를 제대로 구현하지 못해 발생한 문제일 수도 있다.
아마 당분간은 TDD 방식으로 개발하는 걸 연습해볼 예정이다.
연습하다보면 단점으로 생각했던 점도 장점이 될 수 있을 것이다.

profile
반갑습니다.

2개의 댓글

comment-user-thumbnail
2023년 11월 9일

역시 모험가 석환님이시네요! 일부로 메이플스토리 직업도 모험가로 만렙 찍으셨다는데 열심히하는 모습 보기 좋습니다

답글 달기
comment-user-thumbnail
2023년 11월 19일

화장실을 가기 전에 손을 씻나요 석환님?

답글 달기