오늘은 객체 설계와 구현을 시작했다. 지금 구현하는 객체는 스프링부트 프로젝트에도 쓰일 객체이기 때문에 신중하게 설계했다.
오늘 만들 객체는 총 6개이다.
- Lotto: 로또 한 장을 저장하는 객체이다. 이 객체는 프리코스 3주차때 제공된 Lotto 객체에 기능을 추가해서 완성할 것이다.
- WinningLotto: 당첨 로또를 저장하는 객체이다. 로또 한 장과 추가로 보너스 번호가 저장된 객체이다. 3주차 미션때와 다른 점은 당첨 로또에 당첨된 날짜를 포함시켜 일주일마다 당첨 번호가 달라질 수 있도록 수정하였다.
- MyLotto: 랜덤 생성한 로또나 사용자가 저장한 로또를 저장하는 객체이다. 로또 한 장과 이름이 같이 저장될 수 있도록 설계하였다. 이는 3주차 미션때는 구현하지 않은 내용이다.
- PurchasedLotto: MyLotto에 저장된 로또 중 구매한 로또가 있다면 구매한 로또 목록으로 복사할 수 있다. 또한 랜덤 생성이나 저장한 로또가 아니더라도 이곳에서 구매한 로또 번호도 저장할 수 있다. 구매한 로또는 따로 이름이 없어도 될 것 같아서 로또만 저장하면 될 것 같다. 이름은 없지만 구매한 날짜는 같이 저장해야 한다. 그래야 구매한 날짜에 해당하는 당첨 번호로 수익을 낼 수 있기 때문이다. 이 객체도 3주차 미션때는 구현하지 않은 내용이다.
- LottoRank: 당첨 번호와 로또를 비교시 당첨되는 조건이 담긴 객체이다. 이는 Enum을 사용해 구현하였고 3주차 미션에서 구현한 것과 거의 동일하다.
- LottoResult: 당첨된 로또와 구매한 로또를 이용해 수익률과 당첨 결과를 계산하는 객체이다. 3주차 미션에서 구현한 것과 거의 동일하다.
오늘의 목표: 객체 6개를 다 만들기 + 객체에 대한 각각의 단위 테스트 코드도 작성 (3주차 미션에서 만들었던 객체들이라도 다시 만들어보기)
주말동안 목표:
- 스프링 프로젝트 생성: start.spring.io에서 새 프로젝트를 만들기
- domain 패키지 복사
- domain 객체 수정
- Controller / Service / Repository 구현:
- 처음부터 스프링 방식으로 LottoController 설계
- 처음부터 스프링 방식으로 LottoService 설계
- 처음부터 스프링 방식으로 LottoRepository 설계
이렇게 목표를 설정한 이후는 도메인은 3주차 미션처럼 구현해도 거의 동일하게 활용 가능한데 컨트롤러와 서비스, 레포지토리 와 같은 경우는 스프링부트만의 방식이 있기 때문에 시작을 스프링부트에서 하는 것이 더 좋을 것이라고 생각했기 때문이다. 그래서 오늘까지 도메인에 해당되는 객체를 만들고 테스트 코드를 통해 단위테스트까지 다 완료한 후 주말동안 스프링부트 프로젝트를 시작할 것이다.
1-1. Lotto
package lotto;
import java.util.List;
public class Lotto {
// 로또 한장을 저장하는 객체
private final List<Integer> numbers;
public Lotto(List<Integer> numbers) {
validate(numbers);
this.numbers = numbers;
}
// 이 객체는 로또 한 장에 해당하는 데이터를 이용해 검증, 보너스 숫자 존재, 당첨번호와 몇 개 일치하는 지 스스로 확인
private void validate(List<Integer> numbers) {
// 로또 번호 개수 확인
if (numbers.size() != 6) {
throw new IllegalArgumentException("[ERROR] 로또 번호는 6개여야 합니다.");
}
// 로또 숫자 범위 1~45 확인
for(Integer num : numbers) {
if(num < 1 || num > 45) {
throw new IllegalArgumentException("[ERROR] 로또 번호의 범위는 1~45여야 합니다.");
}
}
// 로또 숫자가 서로 중복되지 않는 지 확인
if(numbers.size() != numbers.stream().distinct().count()) {
throw new IllegalArgumentException("[ERROR] 로또 번호는 서로 중복되면 안됩니다.");
}
}
// TODO: 추가 기능 구현
// 보너스 번호가 리스트 중에 존재하는지 확인해서 true/false 를 돌려주는 메서드
public boolean hasBonusNum(int bonusNum) {
return numbers.contains(bonusNum);
}
// 당첨 번호와 몇 개가 일치하는 확인해서 일치하는 개수를 돌려주는 메서드 (추가예정)
// public int matchCount() {
//
// }
}
1-2. LottoTest
package lotto;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
class LottoTest {
@Test
void 로또_번호의_개수가_6개가_넘어가면_예외가_발생한다() {
assertThatThrownBy(() -> new Lotto(List.of(1, 2, 3, 4, 5, 6, 7)))
.isInstanceOf(IllegalArgumentException.class);
}
@DisplayName("로또 번호에 중복된 숫자가 있으면 예외가 발생한다.")
@Test
void 로또_번호에_중복된_숫자가_있으면_예외가_발생한다() {
assertThatThrownBy(() -> new Lotto(List.of(1, 2, 3, 4, 5, 5)))
.isInstanceOf(IllegalArgumentException.class);
}
// TODO: 추가 기능 구현에 따른 테스트 코드 작성
// 로또 숫자 범위 1~45 확인
@DisplayName("로또 번호에 1~45 범위에 없는 숫자가 있으면 예외가 발생한다")
@Test
void 로또_번호에_1보다_작고_45보다_큰_숫자가_있으면_예외가_발생한다() {
assertThatThrownBy(() -> new Lotto(List.of(0, 1, 2, 3, 4, 5)))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new Lotto(List.of(1, 2, 3, 4, 5, 46)))
.isInstanceOf(IllegalArgumentException.class);
}
// 보너스 번호가 리스트 중에 존재하는지 확인해서 true/false 를 돌려주는 메서드
// 객체가 데이터를 가지고 스스로 일하는지를 검증
@DisplayName("hasBonusNum 메서드가 숫자를 올바르게 확인한다")
@Test
void 보너스_번호가_로또_번호_리스트에_포함되어_있으면_boolean_값을_돌려준다() {
Lotto lotto = new Lotto(List.of(1, 2, 3, 4, 5, 6));
boolean return_true = lotto.hasBonusNum(6);
assertThat(return_true).isTrue();
boolean return_false = lotto.hasBonusNum(10);
assertThat(return_false).isFalse();
}
}
우선 로또 객체를 먼저 만들었다. 기본 형태는 3주차 미션때 제공된 형태이고 생성자와 메서드를 수정하고 추가해서 구현하였다. 아직까지Test 코드는 작성할 때마다 너무 헷갈린다😂 로또 객체는 6개의 번호로 구성된 리스트를 데이터로 가지며 해당 데이터로 검증과 메서드를 구현했다.
2-1. WinningLotto
package lotto;
import java.time.LocalDate;
import java.util.List;
import net.bytebuddy.asm.Advice.Local;
public class WinningLotto {
// 당첨 번호를 가지는 객체 (6개 번호 + 1개의 보너스 번호)
private final Lotto winningLotto;
private final int bonusNum;
private final LocalDate drawDate;
public WinningLotto(Lotto winningLotto, int bonusNum, LocalDate drawDate) {
validate(winningLotto, bonusNum);
this.winningLotto = winningLotto;
this.bonusNum = bonusNum;
this.drawDate = drawDate;
}
// 보너스 번호를 가지는 객체이므로 보너스 번호에 대한 검증은 winningLotto가 해야함
void validate(Lotto winningLotto, int bonusNum) {
if(bonusNum < 1 || bonusNum > 45) {
throw new IllegalArgumentException("[ERROR] 보너스 번호의 범위는 1~45여야 합니다.");
}
if(winningLotto.hasBonusNum(bonusNum)) {
throw new IllegalArgumentException("[ERROR] 보너스 번호는 당첨 번호와 중복되면 안됩니다.");
}
}
// 서비스에서 보너스 번호, 당첨번호, 만든 날짜를 사용하기 위한 getter
public Lotto getWinningLotto() {return winningLotto;}
public int getBonusNum() {return bonusNum;}
public LocalDate getDrawDate() {return drawDate;}
}
2-2. WinningLottoTest
package lotto;
import java.time.LocalDate;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
public class WinningLottoTest {
private Lotto testWinningLotto;
private LocalDate drawDate;
@BeforeEach
void setUp() {
// 테스트에서 공통으로 사용할 '기본 당첨 번호' Lotto 객체를 미리 생성
testWinningLotto = new Lotto(List.of(1, 2, 3, 4, 5, 6));
drawDate = LocalDate.now();
}
@DisplayName("보너스 번호가 1~45 범위에 없는 숫자라면 예외가 발생한다.")
@Test
void 보너스_번호가_1보다_작거나_45보다_크면_예외가_발생한다() {
assertThatThrownBy(() -> new WinningLotto(testWinningLotto, 46, drawDate))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("[ERROR] 보너스 번호의 범위는 1~45여야 합니다.");
assertThatThrownBy(() -> new WinningLotto(testWinningLotto, 0, drawDate))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("[ERROR] 보너스 번호의 범위는 1~45여야 합니다.");
}
@DisplayName("보너스 번호가 로또 당첨와 중복되면 예외가 발생한다.")
@Test
void 보너스_번호가_당첨_번호와_중복되면_예외가_발생한다() {
assertThatThrownBy(() -> new WinningLotto(testWinningLotto, 6, drawDate))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("[ERROR] 보너스 번호는 당첨 번호와 중복되면 안됩니다.");
}
@DisplayName("정상적인 당첨 번호와 보너스 번호로 WinningLotto가 생성된다.")
@Test
void 당첨_번호와_보너스_번호가_정상적으로_WinningLotto가_생성된다() {
int testBonusNum = 7;
WinningLotto winningLotto = new WinningLotto(testWinningLotto, testBonusNum, drawDate);
assertThat(winningLotto.getWinningLotto()).isEqualTo(testWinningLotto);
assertThat(winningLotto.getBonusNum()).isEqualTo(testBonusNum);
assertThat(winningLotto.getDrawDate()).isEqualTo(drawDate);
}
}
Lotto 객체의 matchCount() 메서드를 완성하기 위해서는 먼저 WinningLotto 객체를 만들어야 했기 때문에 2번째로 구현했다. 3주차와 다른 점은 당첨 로또 객체 안에 당첨 날짜를 저장해둔 것이다. 이는 11월 1주차에 해당하는 당첨번호로 만들기 위해서 추가했다. 그리고 우선은 테스트와 서비스가 사용하게 하기 위해서 getter도 만들었다. 아직까지는 이 단계에서 getter를 다 만들어두는 게 옳은 방법인지는 잘 모르겠다. 그래도 메인에서 객체를 분리할 때와는 달리 테스트를 위해 코드를 변경하는 행동은 하지 않았기에 그 부분에서는 뿌듯하다! 그리고 테스트 코드에서 BeforeEach를 사용하는 방법에 조금 더 익숙해진 것 같다🤗
3-1. MyLotto
package lotto;
import java.util.Objects;
public class MyLotto {
private final Lotto myLotto;
private final String lottoName;
public MyLotto(Lotto myLotto, String lottoName) {
this.myLotto = Objects.requireNonNull(myLotto, "[ERROR] 로또가 비워져 있습니다.");
Objects.requireNonNull(lottoName, "[ERROR] 로또 이름은 null일 수 없습니다.");
validateLottoName(lottoName);
this.lottoName = lottoName;
}
private void validateLottoName(String lottoName) {
if(lottoName.length() > 20 || lottoName.isEmpty()) {
throw new IllegalArgumentException("[ERROR] 로또 이름은 1~20글자 범위로 입력해주세요.");
}
}
public Lotto getMyLotto() {return myLotto;}
public String getLottoName() {return lottoName;}
}
3-2. MyLottoTest
package lotto;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
public class MyLottoTest {
Lotto validLotto = new Lotto(List.of(1, 2, 3, 4, 5, 6));
@DisplayName("로또가 비워져있으면 myLotto에 등록할 수 없습니다.")
@Test
void 로또가_null_이면_myLotto에_추가할_시_예외가_발생한다() {
assertThatThrownBy(() -> new MyLotto(null, "내 로또"))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("[ERROR] 로또가 비워져 있습니다.");
}
@DisplayName("로또 이름이 20글자 이상이거나 비워져있으면 myLotto에 등록할 수 없습니다.")
@Test
void 로또이름이_20글자_이상이거나_null_이면_myLotto에_추가할_시_예외가_발생한다() {
assertThatThrownBy(() -> new MyLotto(validLotto, "내 로또1234567891011121314151617181920"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new MyLotto(validLotto, null))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("[ERROR] 로또 이름은 null일 수 없습니다.");
assertThatThrownBy(() -> new MyLotto(validLotto, ""))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("[ERROR] 로또 이름은 1~20글자 범위로 입력해주세요.");
}
@DisplayName("정상적인 로또와 이름으로 myLotto가 생성된다.")
@Test
void 정상적인_로또와_이름으로_myLotto가_생성된다() {
String testLottoName = "나만의 로또";
MyLotto myLotto = new MyLotto(validLotto, testLottoName);
assertThat(myLotto.getMyLotto()).isEqualTo(validLotto);
assertThat(myLotto.getLottoName()).isEqualTo(testLottoName);
}
}
이 객체는 3주차 미션과는 완전히 새로운 객체이다. 랜덤으로 생성한 로또를 나만의 로또에 저장하거나 내가 만들고 싶은 번호를 저장할 때 사용하는 객체이다. 이 객체는 내가 기록하고 싶은 로또를 저장하는 역할이다. 내가 만든 새로운 요구사항을 객체를 만드니 어떤 정보를 저장해야 하고 어떤 역할을 해야할 지 정하는 게 생각보다 어려웠고 다시 한 번 설계의 중요함을 느꼈다.
4-1. PurchasedLotto
package lotto;
import java.time.LocalDate;
import java.util.Objects;
import java.time.temporal.WeekFields;
import java.util.Locale;
public class PurchasedLotto {
private final Lotto purchasedLotto;
private final LocalDate purchaseDate;
public PurchasedLotto(Lotto purchasedLotto, LocalDate purchaseDate) {
this.purchasedLotto = Objects.requireNonNull(purchasedLotto, "[ERROR] 로또가 비워져 있습니다.");
this.purchaseDate = Objects.requireNonNull(purchaseDate, "[ERROR] 날짜가 비워져 있습니다.");
}
public boolean isPurchasedInWeek(LocalDate dateToCompare) {
WeekFields weekRule = WeekFields.of(Locale.KOREA);
int myYear = this.purchaseDate.getYear();
int myWeekNumber = this.purchaseDate.get(weekRule.weekOfWeekBasedYear());
int compareYear = dateToCompare.getYear();
int compareWeekNumber = dateToCompare.get(weekRule.weekOfWeekBasedYear());
return (myYear == compareYear) && (myWeekNumber == compareWeekNumber);
}
public Lotto getPurchasedLotto() {return purchasedLotto;}
public LocalDate getPurchaseDate() {return purchaseDate;}
}
4-2. PurchasedLottoTest
package lotto;
import java.time.LocalDate;
import net.bytebuddy.asm.Advice.Local;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
public class PurchasedLottoTest {
private Lotto validLotto;
private LocalDate validPurchaseDate;
@BeforeEach // 모든 테스트 전에 '초기화'
void setUp() {
validLotto = new Lotto(List.of(1, 2, 3, 4, 5, 6));
validPurchaseDate = LocalDate.now();
}
@DisplayName("로또가 비워져있으면 PurchasedLotto에 등록할 수 없습니다.")
@Test
void 로또가_null_이면_PurchasedLotto에_추가할_시_예외가_발생한다() {
assertThatThrownBy(() -> new PurchasedLotto(null, validPurchaseDate))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("[ERROR] 로또가 비워져 있습니다.");
}
@DisplayName("날짜가 비워져있으면 PurchasedLotto에 등록할 수 없습니다.")
@Test
void 날짜가_null_이면_PurchasedLotto에_추가할_시_예외가_발생한다() {
assertThatThrownBy(() -> new PurchasedLotto(validLotto, null))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("[ERROR] 날짜가 비워져 있습니다.");
}
@DisplayName("정상적인 로또와 날짜로 PurchasedLotto가 생성된다.")
@Test
void 정상적인_로또와_날짜로_purchasedLotto가_생성된다() {
PurchasedLotto purchasedLotto = new PurchasedLotto(validLotto, validPurchaseDate);
assertThat(purchasedLotto.getPurchasedLotto()).isEqualTo(validLotto);
assertThat(purchasedLotto.getPurchaseDate()).isEqualTo(validPurchaseDate);
}
@DisplayName("isPurchasedInWeek()가 같은 주, 다른 주를 올바르게 구별한다.")
@Test
void isPurchasedInWeek_메서드가_주를_올바르게_구분한다() {
LocalDate myPurchaseDate = LocalDate.of(2025, 11, 3); // 월요일
LocalDate sameWeekDate = LocalDate.of(2025, 11, 5); // 수요일 (같은 주 O)
LocalDate diffWeekDate = LocalDate.of(2025, 11, 10); // 다음 주 월요일 (같은 주 X)
LocalDate diffYearDate = LocalDate.of(2024, 11, 3); // 작년 (같은 주 X)
PurchasedLotto purchasedLotto = new PurchasedLotto(validLotto, myPurchaseDate);
// 같은 주 테스트
assertThat(purchasedLotto.isPurchasedInWeek(sameWeekDate)).isTrue();
// 자기 자신 테스트
assertThat(purchasedLotto.isPurchasedInWeek(myPurchaseDate)).isTrue();
// 다른 주 테스트
assertThat(purchasedLotto.isPurchasedInWeek(diffWeekDate)).isFalse();
// 다른 연도 테스트
assertThat(purchasedLotto.isPurchasedInWeek(diffYearDate)).isFalse();
}
}
이번 객체도 3주차 미션의 요구사항에 해당되지 않는 새로운 것이다. 나만의 로또 리스트에서 구매를 한 로또가 있다면 구매 리스트로 옮겨 나중에 당첨 결과와 수익률 계산을 하기 위해 구매 로또를 저장하는 객체이다. 아직까지는 객체를 만들 때 객체가 가져야 할 데이터를 생각하는 것은 비교적 쉬우나 이 객체를 똑똑하게 사용하기 위해 객체가 저장한 데이터를 사용해서 어떤 메서드를 만들지는 한 번에 생각해내기 어려운 것 같다. 사람이 생각하기에는 이번주 로또를 샀다면 토요일에 추첨한 당첨번호로 이번주에 산 모든 로또로 당첨 결과를 비교하는 것이 당연하지만 이것을 어떻게 구현해야 할 지 고민하는 것이 힘들었다. 그러면서 WeekFields라는 것을 사용해서 내가 원하는 결과를 내는 데 성공했다. 스프링부트로 옮기고 나서도 내가 원하는 대로 작동할 지는 잘 모르겠지만 테스트 코드는 우선 통과했으니 만족한다! 😁
5-1. LottoRank
package lotto;
import java.util.Arrays; // 👈 import 추가
public enum LottoRank {
FIRST(6, 2000000000L, false),
SECOND(5, 30000000L, true),
THIRD(5, 1500000L, false),
FOURTH(4, 50000L, false),
FIFTH(3, 5000L, false),
NONE(0, 0, false);
private final int winningCount;
private final long winningPrize;
private final boolean isNeedBonus;
LottoRank(int winningCount, long winningPrize, boolean needBonus) {
this.winningCount = winningCount;
this.winningPrize = winningPrize;
this.isNeedBonus = needBonus;
}
public boolean matches(int count, boolean bonus) {
// 'FIRST', 'FOURTH', 'FIFTH' 는 보너스 여부에 관심이 없다.
if (this.winningCount != 5) {
return this.winningCount == count; // 보너스 있고 없고를 전달할 필요가 없음
}
// 'SECOND', 'THIRD' (5개인 경우)
return this.winningCount == count && this.isNeedBonus == bonus;
}
public static LottoRank find(int winningCount, boolean hasBonus) {
return Arrays.stream(LottoRank.values()) // 모든 Rank 상수를 가져와서
.filter(rank -> rank.matches(winningCount, hasBonus)) // 각자 matches?
.findAny() // "true"라고 대답한 랭크를 찾아서 반환
.orElse(LottoRank.NONE); // 아무도 없으면 NONE을 반환
}
public int getWinningCount() {return winningCount;}
public long getWinningPrize() {return winningPrize;}
public boolean isNeedBonus() {return isNeedBonus;}
}
5-2. LottoRankTest
package lotto;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.assertj.core.api.Assertions.assertThat;
class LottoRankTest {
@DisplayName("일치 개수와 보너스 여부에 따라 정확한 랭크(당첨 순위)을 반환해야 한다.")
@ParameterizedTest
@CsvSource({
"6, false, FIRST", // 1등
"6, true, FIRST", // 1등 (보너스 여부 상관 없음)
"5, true, SECOND", // 2등
"5, false, THIRD", // 3등
"4, true, FOURTH", // 4등 (보너스 여부 상관 없음)
"4, false, FOURTH", // 4등
"3, true, FIFTH", // 5등 (보너스 여부 상관 없음)
"3, false, FIFTH", // 5등
"2, true, NONE", // 꽝
"2, false, NONE", // 꽝
"1, false, NONE", // 꽝
"0, false, NONE" // 꽝
})
void 정확한_당첨_순위를_반환해야_한다(int winningCount, boolean hasBonus, LottoRank expectedRank) {
LottoRank actualRank = LottoRank.find(winningCount, hasBonus);
assertThat(actualRank).isEqualTo(expectedRank);
}
@Test
@DisplayName("FIRST(1등) Enum 상수가 올바른 상금과 일치 개수를 가지고 있는지 확인한다.")
void 일등이_올바른_상금과_당첨_번호와_6개_일치하지_않으면_예외가_발생한다() {
LottoRank first = LottoRank.FIRST;
assertThat(first.getWinningCount()).isEqualTo(6);
assertThat(first.getWinningPrize()).isEqualTo(2_000_000_000L);
assertThat(first.isNeedBonus()).isFalse();
}
@Test
@DisplayName("SECOND(2등) Enum 상수가 올바른 상금, 일치 개수, 보너스 필요 여부를 가지고 있는지 확인한다.")
void 이등이_올바른_상금과_보너스_번호_여부와_당첨_번호와_5개_일치하지_않으면_예외가_발생한다() {
LottoRank second = LottoRank.SECOND;
assertThat(second.getWinningCount()).isEqualTo(5);
assertThat(second.getWinningPrize()).isEqualTo(30_000_000L);
assertThat(second.isNeedBonus()).isTrue();
}
@Test
@DisplayName("NONE(꽝) Enum 상수가 올바른 상금과 일치 개수를 가지고 있는지 확인한다.")
void 꽝이_상금이_있고_당첨_번호와_3개_이상_일치시_예외가_발생한다() {
LottoRank none = LottoRank.NONE;
assertThat(none.getWinningCount()).isEqualTo(0);
assertThat(none.getWinningPrize()).isEqualTo(0L);
assertThat(none.isNeedBonus()).isFalse();
}
}
LottoRank 객체는 3주차 미션과 동일한 역할을 하는 객체이다. 그래서 테스트 코드는 동일하게 사용하였고 LottoRank 코드는 동일하게 Enum 을 사용하여 구현하였다. 테스트도 지난 테스트 코드와는 다르게 @ParameterizedTest를 사용하였다. 어렵다😭 그리고 지난번 로또 랭크 코드와는 다르게 구현한 것이 있다.
// 몇 개 맞았는 지, 보너스 번호가 있는 지 검사해서 로또 랭크를 반환해주는 메서드
public static LottoRank find(int winningCount, boolean needBonus) {
if(winningCount == 6) {return LottoRank.FIRST;}
if(winningCount == 5 && needBonus) {return LottoRank.SECOND;}
if(winningCount == 5) {return LottoRank.THIRD;}
if(winningCount == 4) {return LottoRank.FOURTH;}
if(winningCount == 3) {return LottoRank.FIFTH;}
return LottoRank.NONE;
}
바로 이부분이다. 지난 번에는 메서드가 모든 당첨 규칙을 1등부터 5등까지 다 외우고 있어야 하도록 구현했다. 이를 3주차 피드백을 기반으로 객체를 좀 더 객체답게 구현하기 위해서 위 코드와 같이 수정하였다. 예전에는 있는 답에 해당하는 등수를 돌려주는 형식이였으면 이번에는 질문(stream)을 하면 Enum 상수가 스스로 matches 하는 지 판별해서 결과를 알려주도록 수정하였다. if-else를 사용하지 않도록 하는 것이 최선이라고 생각했는 데 전혀 다른 방법으로도 이 로직을 완성할 수도 있었다는 것이 신기했다.
6-1. LottoResult
package lotto;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
public class LottoResult {
private static final int MIN_PURCHASE_PRICE = 0;
private static final double DEFAULT_RATE_OF_RETURN = 0.0;
private static final double PERCENTAGE_MULTIPLIER = 100.0;
private final Map<LottoRank, Integer> statistics;
public LottoResult(Map<LottoRank, Integer> statistics) {
this.statistics = Objects.requireNonNull(statistics, "[ERROR] 통계판이 비워져 있습니다.");
}
// 2. 총상금 계산 (private long calculateTotalWinningPrize())
private double calculateTotalWinningPrize() {
double totalWinningPrize = 0;
for (Map.Entry<LottoRank, Integer> entry : this.statistics.entrySet()) {
long prize = entry.getKey().getWinningPrize();
int count = entry.getValue();
totalWinningPrize += (double) prize * (double) count;
}
return totalWinningPrize;
}
public double getRateOfReturn(int purchaseAmount) {
double totalWinningPrize = calculateTotalWinningPrize();
if (purchaseAmount == MIN_PURCHASE_PRICE) {
return DEFAULT_RATE_OF_RETURN;
}
return (totalWinningPrize / (double)purchaseAmount) * PERCENTAGE_MULTIPLIER;
}
public Map<LottoRank, Integer> getStatistics() {
return Collections.unmodifiableMap(this.statistics);
}
}
6-2. LottoResultTest
package lotto;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.EnumMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.offset;
class LottoResultTest {
@DisplayName("생성자에 null 통계 맵이 전달되면 예외가 발생한다.")
@Test
void 생성자는_null_맵을_허용하지_않는다() {
assertThatThrownBy(() -> new LottoResult(null))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("[ERROR] 통계판이 비워져 있습니다.");
}
@DisplayName("5등 1장, 8000원 구매 시 수익률 62.5%를 반환한다.")
@Test
void 수익률계산_5등_1장_8000원() {
Map<LottoRank, Integer> statisticsMap = new EnumMap<>(LottoRank.class);
statisticsMap.put(LottoRank.FIRST, 0);
statisticsMap.put(LottoRank.SECOND, 0);
statisticsMap.put(LottoRank.THIRD, 0);
statisticsMap.put(LottoRank.FOURTH, 0);
statisticsMap.put(LottoRank.FIFTH, 1); // 5등 1개
statisticsMap.put(LottoRank.NONE, 7); // 꽝 7개 (총 8000원 구매 가정)
int lottoPrice = 8000;
LottoResult lottoResult = new LottoResult(statisticsMap);
double rateOfReturn = lottoResult.getRateOfReturn(lottoPrice);
assertThat(rateOfReturn).isEqualTo(62.5, offset(0.001));
}
@DisplayName("당첨금이 0원일 때 0.0%를 반환한다.")
@Test
void 수익률계산_당첨금_0원() {
Map<LottoRank, Integer> statisticsMap = new EnumMap<>(LottoRank.class);
statisticsMap.put(LottoRank.NONE, 1); // 꽝 1개
int lottoPrice = 1000;
LottoResult lottoResult = new LottoResult(statisticsMap);
double rateOfReturn = lottoResult.getRateOfReturn(lottoPrice);
assertThat(rateOfReturn).isEqualTo(0.0);
}
@DisplayName("getStatistics()가 수정 불가능한 맵을 반환한다.")
@Test
void getStatistics_는_수정불가능한_맵을_반환한다() {
Map<LottoRank, Integer> statisticsMap = new EnumMap<>(LottoRank.class);
statisticsMap.put(LottoRank.FIFTH, 1);
LottoResult lottoResult = new LottoResult(statisticsMap);
Map<LottoRank, Integer> unmodifiableMap = lottoResult.getStatistics();
assertThatThrownBy(() -> unmodifiableMap.put(LottoRank.FIRST, 1)) // 맵 수정을 시도하면
.isInstanceOf(UnsupportedOperationException.class); // 예외가 발생해야 함
}
}
LottoResult 객체도 3주차 미션에서 구현했던 객체였다. 이번에는 피드백을 통해 객체의 역할에 대해서 조금 수정해보았다. 저번에는 통계판을 계산하는 로직 모두 LottoResult 객체가 담당하도록 하였는데 이번에는 그 역할을 서비스로 넘기고 LottoResult는 그 결과를 받아 수익률을 계산하는 역할만 하도록 수정해보았다. 객체의 역할이 어디까지인지 서비스와 컨트롤러는 이 객체를 어떻게 사용해야 하는 지 고민하면서 지난 미션보다 좀 더 발전된 코드를 구현해본 것 같다!!
이렇게 오늘은 객체를 모두 구현해보았는 데 고작 몇일 전에 구현해본 객체도 다시 구현한 것인데도 어려웠다😱 그래도 조금 더 객체의 역할이 무엇일까를 고민해보면서 발전해나가는 하루였던 것같다. main에 구현해둔 코드에서 객체를 분리하는 것보다 이렇게 객체를 먼저 생성하고 객체만 따로 단위테스트를 하면서 구현해나가는 것이 오히려 덜 복잡한 것 같다. 이번 미션이 끝나면 1,2주차 미션도 이 방식으로 다시 한 번 구현해보고 싶다. 내일은 이제 이 객체를 활용하기 위해서 스프링부트 프로젝트를 시작해볼 것이다. 아자자🏃🏻♀️➡️