이 글은 내가 우아한테크캠프에서 배웠던 내용들을 복습하기 위해 진행하는 토이프로젝트(https://github.com/minseokLim/woowahan-tech-camp-review)를 중심으로 작성되었다.

 

1. TDD란?

  • TDD란 Test Driven Development의 약자로, 테스트 주도 개발이라는 개발 방법론이다.
  • TDD = TFD(Test First Development) + 리팩토링

 TDD의 장점은 아래와 같다.

 1) 디버깅 시간을 줄여준다.

 2) 동작하는 문서의 역할을 한다.

 3) 변화에 대한 두려움을 줄여준다.

 

2. TDD 사이클

 1) 실패하는 테스트를 구현한다.

 2) 테스트가 성공하도록 프로덕션 코드를 구현한다.

 3) 프로덕션 코드와 테스트 코드를 리팩토링한다.

 TDD로 개발 시, 이와 같은 과정을 계속 반복하여 개발을 진행한다.

3. TDD 원칙

 1) 실패하는 단위 테스트를 작성할 때까지 프로덕션 코드(production code)를 작성하지 않는다.
 2) 컴파일은 실패하지 않으면서 실행이 실패하는 정도로만 단위 테스트를 작성한다.
 3) 현재 실패하는 테스트를 통과할 정도로만 실제 코드를 작성한다.

 

4. 클래스 설계

* [ ] 로또를 구매한다.
  * [ ] 입력 값으로 로또 구입 금액, 수동으로 입력된 로또의 리스트를 받는다.
  * [ ] 구입 금액이 1000미만이거나 1000으로 나누어 떨어지지 않거나 숫자가 아닐 경우, 예외를 발생시킨다. (로또 1장 가격은 1000원)
  * [ ] 수동으로 입력된 로또의 수가, 앞서 입력한 구입금액으로 살 수 있는 로또 수의 최댓값을 초과할 경우, 예외를 발생시킨다.
  * [ ] 수동으로 입력된 로또들의 번호는 1~45까지의 중복되지 않는 숫자 6개로 이루어져 있어야 한다. 그렇지 않을 경우 예외를 발생시킨다.
  * [ ] 구입 금액에서 수동으로 입력된 로또의 수를 제외한 나머지에 해당하는 수만큼 자동으로 로또가 생성되어 구매된다.
  * [ ] 토요일 오후 8시 ~ 9시에는 구매가 불가능하다.
  * [ ] 회차별 구매 내역과 구매한 로또들이 DB에 저장된다.
  * [ ] 로또의 번호들은 ','를 구분자로 하는 오름차순으로 정렬된 문자열로 저장된다.

 위 목록은 구현해야할 기능들이다. 구현할 로또 기능들의 도메인 클래스들을 대략적으로 설계해봤다. 이는 '실패하는 단위 테스트를 작성할 때까지 프로덕션 코드(production code)를 작성하지 않는다.'는 TDD의 원칙을 어기는 것처럼 보일 수도 있다. 근데 대략적으로라도 클래스 설계를 해야 테스트 코드를 시작할 수 있지 않을까?;; 대략적인 클래스 설계가 목적이기 때문에, 일급 컬렉션이나 원시값 포장 등은 고려하지 않았다.

 

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Purchase {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private BigDecimal payment;

    private int round;

    private Long userId;

    @OneToMany(mappedBy = "purchase", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST)
    private List<Lotto> lottos = new ArrayList<>();

    private LocalDateTime createdDate;
}

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Lotto {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private List<Integer> numbers;

    private LottoType type;

    @ManyToOne(fetch = FetchType.LAZY)
    private PaymentHistory paymentHistory;
}

enum LottoType {
    MANUAL, AUTO
}

 연관관계를 어떻게 설정해야할지 많은 고민을 했다. 일반적으로는 다대일 단방향을 추천하곤 한다. 근데 이 경우에는 Purchase와 Lotto가 항상 같은 라이프 사이클을 가질 것이고, 유효성 검사나 기능 구현 측면에서 봤을 때 Purchase가 Lotto를 참조할 필요가 있어보였다. 그렇다고 일대다 단방향으로 하기엔, Lotto 저장 시 insert, update 2번의 쿼리가 발생할 것이고, 이는 성능상 이슈가 될 수 있을 것 같았다. 결국 좀 까다롭지만, 양방향 연관관계를 설정하기로 했다. 

 

5. 테스트 코드 작성

 실패한 테스트 코드 작성까지 완료한 코드는 아래와 같다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Purchase {
    static final int LOTTO_PRICE = 1000;
    static final String PAYMENT_ERR_MSG = "지불 금액은 " + LOTTO_PRICE + "의 배수이어야 합니다.";
    static final String OVER_MANUAL_LOTTOS_ERR_MSG = "지불 금액에 비해 수동으로 입력된 로또의 수가 너무 많습니다.";
    static final String PURCHASE_NOT_ALLOWED_ERR_MSG = "토요일 오후 8시 ~ 9시에는 구매가 불가능합니다.";

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private BigDecimal payment;

    private int round;

    private Long userId;

    @OneToMany(mappedBy = "purchase", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST)
    private List<Lotto> lottos = new ArrayList<>();

    private LocalDateTime createdDate;

    Purchase(
        final int payment,
        final int round,
        final Long userId,
        final List<Collection<Integer>> manualNumbers
    ) {
        this(payment, round, userId, manualNumbers, LocalDateTime.now());
    }

    Purchase(
        final int payment,
        final int round,
        final Long userId,
        final List<Collection<Integer>> manualNumbers,
        final LocalDateTime createdDate
    ) {

    }

    public List<Lotto> getLottos() {
        return Collections.unmodifiableList(lottos);
    }
}

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Lotto {
    static final String INVALID_NUMBER_ERR_MSG = "로또의 번호들은 1~45까지의 중복되지 않는 숫자 6개로 이루어져 있어야 합니다.";

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private List<Integer> numbers = new ArrayList<>();

    private LottoType type;

    @ManyToOne(fetch = FetchType.LAZY)
    private Purchase purchase;

    Lotto(final Collection<Integer> numbers, final LottoType type, final Purchase purchase) {

    }

    boolean isAuto() {
        return type == LottoType.AUTO;
    }

    public List<Integer> getNumbers() {
        return Collections.unmodifiableList(numbers);
    }
}
class PurchaseTest {
    @Test
    @DisplayName("지불 금액에서 수동으로 입력된 로또의 수를 제외한 나머지에 해당하는 수만큼 자동으로 로또가 생성되는지 테스트")
    void create() {
        // when
        final Purchase purchase = new Purchase(10000, 1, 1L, List.of(Set.of(1, 2, 3, 4, 5, 6)));

        // then
        assertThat(purchase.getLottos()).filteredOn(Lotto::isAuto).hasSize(9);
    }

    @ParameterizedTest
    @ValueSource(ints = {999, 1001})
    @DisplayName("지불 금액이 " + LOTTO_PRICE + "원 미만이거나 " + LOTTO_PRICE + "원으로 나누어 떨어지지 않는 경우 예외 발생")
    void createByInvalidPayment(final int invalidPayment) {
        // when, then
        assertThatThrownBy(() -> new Purchase(invalidPayment, 1, 1L, List.of(Set.of(1, 2, 3, 4, 5, 6))))
            .isInstanceOf(BadRequestException.class)
            .hasMessageContaining(PAYMENT_ERR_MSG);
    }

    @Test
    @DisplayName("지불 금액에 비해 입력되는 로또의 수가 많을 때 예외 발생")
    void createByTooManyManualLottos() {
        // when, then
        assertThatThrownBy(() ->
            new Purchase(1000, 1, 1L, List.of(Set.of(1, 2, 3, 4, 5, 6), Set.of(7, 8, 9, 10, 11, 12))))
            .isInstanceOf(BadRequestException.class)
            .hasMessageContaining(OVER_MANUAL_LOTTOS_ERR_MSG);
    }

    @Test
    @DisplayName("토요일 오후 8시 ~ 9시에는 구매 시 예외 발생")
    void createByInvalidCreatedDate() {
        // given (토요일 오후 8시)
        final LocalDateTime invalidCreatedDate = LocalDateTime.of(2022, 3, 26, 20, 0);

        // when, then
        assertThatThrownBy(() ->
            new Purchase(10000, 1, 1L, List.of(Set.of(1, 2, 3, 4, 5, 6)), invalidCreatedDate))
            .isInstanceOf(BadRequestException.class)
            .hasMessageContaining(PURCHASE_NOT_ALLOWED_ERR_MSG);
    }
}

class LottoTest {
    private static final Purchase DEFAULT_PURCHASE =
        new Purchase(1000, 1, 1L, List.of(Set.of(1, 2, 3, 4, 5, 6)));

    @Test
    void create() {
        // when
        final Lotto lotto = new Lotto(List.of(6, 1, 3, 2, 4, 5), LottoType.MANUAL, DEFAULT_PURCHASE);

        // then
        assertThat(lotto.getNumbers()).containsExactly(1, 2, 3, 4, 5, 6);
    }

    @ParameterizedTest
    @MethodSource("provideInvalidNumbers")
    @DisplayName("로또의 번호들이 1~45까지의 중복되지 않는 숫자 6개로 이루어져 있지 않을 때 예외 발생")
    void createByInvalidNumbers(final List<Integer> invalidNumbers) {
        // when, then
        assertThatThrownBy(() -> new Lotto(invalidNumbers, LottoType.MANUAL, DEFAULT_PURCHASE))
            .isInstanceOf(BadRequestException.class)
            .hasMessageContaining(INVALID_NUMBER_ERR_MSG);
    }

    private static Stream<List<Integer>> provideInvalidNumbers() {
        return Stream.of(
            List.of(1, 2, 3, 4, 5, 46),
            List.of(1, 2, 3, 4, 5, 5),
            List.of(1, 2, 3, 4, 5),
            List.of(1, 2, 3, 4, 5, 6, 7)
        );
    }
}

 

 오늘은 실패한 테스트 코드까지 작성해봤다. 다음엔 테스트 코드가 성공하도록 프로덕션 코드를 작성하면서 TDD 사이클을 진행해보겠다.

+ Recent posts