Skip to content

🚀 2단계 - 수강신청(도메인 모델) #732

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 38 commits into
base: jieung2
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
330def7
docs step1 요구사항 추가
JiEung2 Apr 9, 2025
45603af
test 질문 권한 검증 테스트 추가
JiEung2 Apr 9, 2025
7bc74ca
refactor 질문의 작성자가 본인인지 검사하는 기능 질문 클래스로 이동
JiEung2 Apr 9, 2025
90f69fa
test 답변 검증 테스트 추가
JiEung2 Apr 9, 2025
1434fba
refactor 답변 작성자를 검증하는 기능 Answer 클래스로 이동
JiEung2 Apr 9, 2025
2104de2
feat Answer의 삭제 상태를 true로 만들고 DeleteHistory를 반환하는 기능 추가
JiEung2 Apr 9, 2025
8c94a3d
refactor Answer 일급컬렉션 Answers 생성 및 answers 관련 권한 이동
JiEung2 Apr 9, 2025
14635ef
feat DeleteHistory에서 answer와 question을 받아 생성하는 기능 추가
JiEung2 Apr 9, 2025
163d903
test 질문 삭제의 모든 과정을 delete 메서드에 추가하기 위해 테스트 수정
JiEung2 Apr 9, 2025
622012c
refactor 질문 및 답변 삭제 과정 질문 클래스에서 진행하도록 리팩토링
JiEung2 Apr 9, 2025
6d53e29
fix 피드백 반영
JiEung2 Apr 13, 2025
aaeb502
fix 피드백 반영
JiEung2 Apr 14, 2025
8a8a195
test 예외 처리 메시지가 변경됨에 따른 테스트 변경
JiEung2 Apr 14, 2025
c23a134
docs step2 기능 목록 추가
JiEung2 Apr 14, 2025
e3f5f4d
test SessionPeriod 테스트 추가
JiEung2 Apr 15, 2025
ac80d4f
feat 강의 기간 클래스 생성 및 강의 시작일, 종료일 검증 기능 추가
JiEung2 Apr 15, 2025
8059451
test 이미지 크기 테스트 추가
JiEung2 Apr 15, 2025
ea80407
feat 이미지 클래스 생성 및 이미지 크기 검증 기능 추가
JiEung2 Apr 15, 2025
1e6b44f
test 이미지 타입 검사 테스트 추가
JiEung2 Apr 15, 2025
1e733eb
feat 이미지 타입 생성 및 검증 추가
JiEung2 Apr 15, 2025
dd15699
test 이미지 가로, 세로 길이 테스트 추가
JiEung2 Apr 15, 2025
3a38507
feat 이미지 가로, 세로 길이 검증 기능 추가
JiEung2 Apr 15, 2025
a14350f
test 이미지 가로 세로 비율 테스트 추가
JiEung2 Apr 15, 2025
5c177c3
feat 이미지의 가로 세로 비율이 3:2인지 확인하는 기능 추가
JiEung2 Apr 15, 2025
ec7e477
test 등록 인원 테스트 추가
JiEung2 Apr 15, 2025
aab7ce1
feat 정원이 초과했는지 검사하는 기능 추가
JiEung2 Apr 15, 2025
02cb05a
docs 수강 인원 증가 기능 추가
JiEung2 Apr 15, 2025
9265ddb
feat 현재 수강 인원을 증가시키는 기능 추가
JiEung2 Apr 15, 2025
2ecb6bd
test 결제 금액과 강의 금액 일치 여부 테스트 추가
JiEung2 Apr 15, 2025
f547d74
feat 수강료와 결제 금액이 일치하는지 확인하는 기능 추가
JiEung2 Apr 15, 2025
835cf53
test 강의 상태에 따른 수강신청 테스트 추가
JiEung2 Apr 15, 2025
31f9f94
feat Session 클래스 생성 및 수강신청 시 강의 상태가 모집중인지 확인하는 기능 추가
JiEung2 Apr 15, 2025
7ecb687
docs 현재 필요없어보이는 기능 삭제
JiEung2 Apr 15, 2025
db7209e
feat 결제 금액을 반환하는 기능 추가
JiEung2 Apr 15, 2025
cf74b39
feat SessionType 추가
JiEung2 Apr 15, 2025
fa79243
Merge branch 'jieung2' into step2
JiEung2 Apr 15, 2025
6fe969c
fix: MAX_SIZE 표현 방식 변경 및 매직넘버 상수 선언
Apr 17, 2025
fb64a35
fix: Date LocalDate로 변경 및 변수 final 선언
Apr 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/step2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
## 기능 요구사항
- 과정(Course)은 기수 단위로 운영하며, 여러 개의 강의(Session)를 가질 수 있다.
- 강의는 시작일과 종료일을 가진다.
- 강의는 강의 커버 이미지 정보를 가진다.
- 이미지 크기는 1MB 이하여야 한다.
- 이미지 타입은 gif, jpg(jpeg 포함),, png, svg만 허용한다.
- 이미지의 width는 300픽셀, height는 200픽셀 이상이어야 하며, width와 height의 비율은 3:2여야 한다.
- 강의는 무료 강의와 유료 강의로 나뉜다.
- 무료 강의는 최대 수강 인원 제한이 없다.
- 유료 강의는 강의 최대 수강 인원을 초과할 수 없다.
- 유료 강의는 수강생이 결제한 금액과 수강료가 일치할 때 수강 신청이 가능하다.
- 강의 상태는 준비중, 모집중, 종료 3가지 상태를 가진다.
- 강의 수강신청은 강의 상태가 모집중일 때만 가능하다.
- 유료 강의의 경우 결제는 이미 완료한 것으로 가정하고 이후 과정을 구현한다.
- 결제를 완료한 결제 정보는 payments 모듈을 통해 관리되며, 결제 정보는 Payment 객체에 담겨 반한된다.

## 기능 목록
- [x] 강의의 종료일보다 시작일이 더 이후인지 확인한다.
- [x] 강의 커버 이미지의 크기가 1MB 이하인지 확인한다.
- [x] 이미지 타입이 gif, jpg, png, svg인지 확인한다.
- [x] 이미지의 width와 height가 각각 300, 200 픽셀 이상인지 확인한다.
- [x] 이미지의 width와 height 비율이 3:2인지 확인한다.
- [x] 유료 강의의 강의 최대 수강 인원을 초과하는지 확인한다.
- [x] 현재 수강 인원을 증가시킨다.
- [x] 유료 강의의 수강료와 수강생이 결제한 금액이 일치하는지 확인한다.
- [x] 수강 신청을 한다.
- [x] 강의 상태가 모집중인지 확인한다.
- [x] 결제 금액을 반환한다.
44 changes: 44 additions & 0 deletions src/main/java/nextstep/enrollment/domain/Enrollment.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package nextstep.enrollment.domain;

import nextstep.payments.domain.Payment;
import nextstep.sessions.domain.Session;
import nextstep.users.domain.NsUser;

import java.time.LocalDateTime;
import java.util.Objects;

public class Enrollment {
private final NsUser student;
private final Session session;
private final LocalDateTime enrolledAt;
private final Payment payment;
Comment on lines +10 to +14
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 객체의 역할은 vaildator 에 가까운 것 같은데요.
student 변수는 활용되는 곳이 없어서 제거하면 어떨까 싶어요.
아직 미구현 단계라도 예상되는 코드는 제거하고 적은 역할만 부여해보셔도 좋을 것 같아요.

그리고 수강 신청이라는 기능은 Session의 기능이니 sessions 패키지 하위로 이동하면 어떨까요?
sessions와 동일한 레벨에 위치하신 이유가 궁금합니다 🤔

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@testrace
좋은 피드백 감사합니다!

말씀 주신 validator 역할이라는 관점도 공감이 가지만, 저는 Enrollment를 학생과 수업 간의 다대다 관계를 명시적으로 표현하는 도메인 객체로 보고 있습니다.
수강신청은 단순히 유효성 검사를 넘어서, "누가 어떤 수업에 얼마를 지불하고 신청했는가"라는 도메인 이벤트를 포착하고 있다고 생각했습니다.

패키지 구조에 대해서도, Enrollment가 단순히 Session의 하위 기능이라기보다는, 학생과 수업 사이의 독립된 관계이자 행위라고 보아 enrollment 패키지로 따로 분리했습니다.

혹시 수강신청에 대해서 제가 잘못이해하고 있다면 알려주시면 감사하겠습니다.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수강신청 자체에 대해선 잘못이해하신 것은 없으신 것 같아요 😃

수강신청 미션은 이벤트 기반 방식은 고려하지 않았기 때문에 (사실 모든 미션이)
도메인 이벤트 객체라고 인지하지 않아서 Enrollment 의 역할을 단순히 validator 라고 생각했습니다.
이벤트를 위한 객체라면 클래스명을 EnrolledEvent 로 변경하면 어떨까 싶어요.

패키지 구조에서 말씀하신 �학생과 수업 사이의 독립된 관계이자 행위를 이해하지 못했는데요.
그럼 session 하위 기능으로는 둘 수 없다는 의견이신지, 둘 다 가능하지만 독립적인 객체로 설계 하신건지 조금 더 자세하게 설명 부탁드려요.


public Enrollment(NsUser student, Session session, Payment payment) {
validate(session, payment);
this.student = student;
this.session = session;
this.enrolledAt = LocalDateTime.now();
this.payment = payment;
}

private void validate(Session session, Payment payment) {
validateSessionStatus(session);
validatePaidCorrectly(session, payment);
}

private void validateSessionStatus(Session session) {
if (!session.isRecruiting()) {
throw new IllegalArgumentException("강의 상태가 모집중일 때만 수강 신청이 가능합니다.");
}
}

private void validatePaidCorrectly(Session session, Payment payment) {
if (!hasPaidCorrectly(session, payment)) {
throw new IllegalArgumentException("결제 금액이 수강료와 일치하지 않습니다.");
}
}
Comment on lines +35 to +39
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 2단계를 아직 완전히 완료하지는 못했습니다.
진행하면서 요구사항 중 어디까지 구현해야 하는지 조금 헷갈리는 부분이 있어,
우선 도메인 단위로 제가 생각한 요구사항을 만족하는 최소한의 기능만 구현해둔 상태입니다.
혹시 이 상태에서 서비스 레이어까지 구현을 확장해도 될지 궁금합니다.
또한, 현재는 결제와 수강신청을 별개의 로직으로 분리해두었는데요,
수강신청을 결제의 연장선으로 보고 결제 도메인 안에서 처리하는 게 맞을지 판단이 잘 서지 않아 고민 중입니다.

프레임워크가 적용되어도 크게 다르지 않으니 지난 미션처럼 TDD 사이클로 기능 구현하시고,
진입점을 main 메서드가 아닌 서비스 레이어로 구현해 주시면 됩니다.

수강 신청이 결제 이후 진행되는 것은 맞지만 결제 도메인이 수강신청의 책임을 가질 필요는 없다고 생각합니다.
결제 도메인이 처리를 한다면 추후 결제가 필요한 다른 기능이 생길 때 결제 도메인은 너무 많은 것을 알고 있어야 합니다.
그러니 결제 도메인에서 수강신청 기능을 처리하기 보다 지금처럼 분리해 주시는게 좋습니다 😃


private boolean hasPaidCorrectly(Session session, Payment payment) {
return Objects.equals(payment.amount(), session.price());
}
}
4 changes: 4 additions & 0 deletions src/main/java/nextstep/payments/domain/Payment.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,8 @@ public Payment(String id, Long sessionId, Long nsUserId, Long amount) {
this.amount = amount;
this.createdAt = LocalDateTime.now();
}

public Long amount() {
return this.amount;
}
Comment on lines +30 to +32
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

금액을 비교하기 위한 메서드라면 메시지를 보내 검증하면 어떨까요?

Copy link
Author

@JiEung2 JiEung2 Apr 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@testrace
좋은 피드백 감사합니다! 코드를 짜면서 고민이 있었는데요.

'수강생이 결제한 금액과 수강료가 일치할 때 수강 신청이 가능하다'라는 요구사항을 처리하면서, 해당 비교 책임을 어느 객체에 둘지에 대해 고민이 있었습니다.

Payment에 수업 가격을 전달하여 내부에서 비교하는 방식도 고려했지만,
이 경우 결국 Payment가 Session의 가격 정보를 알아야 하며(Session은 결국 getter를 통해 값을 외부로 노출),
한쪽 도메인 객체가 다른 도메인 객체의 맥락에 의존하게 되는 구조가 되어버린다고 생각했습니다.

반면 Enrollment는 수강신청이라는 행위를 중심으로 student, session, payment를 모두 알고 있는 도메인 객체이기 때문에, 이곳에서 두 객체의 상태를 조회해 비교하는 것이 양쪽 도메인의 책임을 침범하지 않으면서도 자연스럽다고 판단했습니다.

혹시 이렇게 두 객체의 값을 비교하는 상황에서는 어떻게 하는게 좋을까요??

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Session(또는 하위 도메인) 내부에서 payment 객체를 주입받아서 메시지를 보내도 괜찮지 않을까요?
payment.hasPaidCorrectly(price);

Enrollment가 행위 중심이라고 하셨는데 어떤 행위를 갖고 있나요?
validate 말고는 행위라고 판단할 수 있는 메서드는 없는 것 같아서요.

현재 구조에선 수강료와 결제금액을 비교하기 위해 도메인 객체가 다른 도메인 객체에게 의존하는 구조가 나쁘다고 생각하진 않습니다.
의존 방향만 잘 설정하면 특별히 문제가 되진 않을 것 같아요.
그럼에도 의존성을 완전히 제거해야 한다면 '도메인 서비스'를 두면 어떨까요?
이미 Enrollment 가 그 역할을 하고 있는데 이벤트를 위한 객체가 아닌 금액 비교를 위한 서비스를 추가해도 좋을 것 같아요.

}
2 changes: 1 addition & 1 deletion src/main/java/nextstep/qna/domain/Answer.java
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public void validateOwnership(NsUser loginUser) throws CannotDeleteException {
public DeleteHistory delete(NsUser loginUser) throws CannotDeleteException {
validateOwnership(loginUser);
deleted = true;
return DeleteHistory.deleteOfAnswer(this);
return DeleteHistory.ofAnswer(this);
}

public Long getId() {
Expand Down
8 changes: 4 additions & 4 deletions src/main/java/nextstep/qna/domain/DeleteHistory.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ public class DeleteHistory {
public DeleteHistory() {
}

public static DeleteHistory deleteOfQuestion(Question q) {
return new DeleteHistory(ContentType.QUESTION, q.getId(), q.getWriter(), LocalDateTime.now());
public static DeleteHistory ofQuestion(Question question) {
return new DeleteHistory(ContentType.QUESTION, question.getId(), question.getWriter(), LocalDateTime.now());
}

public static DeleteHistory deleteOfAnswer(Answer a) {
return new DeleteHistory(ContentType.ANSWER, a.getId(), a.getWriter(), LocalDateTime.now());
public static DeleteHistory ofAnswer(Answer answer) {
return new DeleteHistory(ContentType.ANSWER, answer.getId(), answer.getWriter(), LocalDateTime.now());
}

public DeleteHistory(ContentType contentType, Long contentId, NsUser deletedBy, LocalDateTime createdDate) {
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/nextstep/qna/domain/Question.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public List<DeleteHistory> delete(NsUser loginUser) throws CannotDeleteException
this.deleted = true;

List<DeleteHistory> deleteHistories = new ArrayList<>();
deleteHistories.add(DeleteHistory.deleteOfQuestion(this));
deleteHistories.add(DeleteHistory.ofQuestion(this));
deleteHistories.addAll(answers.deleteAnswers(loginUser));

return deleteHistories;
Expand Down
30 changes: 30 additions & 0 deletions src/main/java/nextstep/sessions/domain/EnrollmentCapacity.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package nextstep.sessions.domain;

public class EnrollmentCapacity {
private int maxEnrollment;
private int currentEnrollment;
Comment on lines +3 to +5
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

캡슐화 👍
수강생 목록을 가지면 어떨까요?
중복신청도 처리할 수 있을 것 같아요.


public EnrollmentCapacity(int maxEnrollment) {
this(maxEnrollment, 0);
}

public EnrollmentCapacity(int maxEnrollment, int currentEnrollment) {
this.maxEnrollment = maxEnrollment;
this.currentEnrollment = currentEnrollment;
}

public boolean isFull() {
return currentEnrollment >= maxEnrollment;
}

public void increaseEnrollment() {
validate();
currentEnrollment++;
}

private void validate() {
if (isFull()) {
throw new IllegalStateException("정원이 초과되었습니다.");
}
}
}
61 changes: 61 additions & 0 deletions src/main/java/nextstep/sessions/domain/Image.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package nextstep.sessions.domain;

public class Image {
private static final Long MAX_SIZE_BYTES = 1024 * 1024L;
private static final Float MIN_SIZE_WIDTH = 300F;
private static final Float MIN_SIZE_HEIGHT = 200F;
Comment on lines +5 to +6
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

int 타입이어도 괜찮지 않을까요?

private static final Float ASPECT_RATIO_TOLERANCE = 0.01f;

private Long id;
private Long sizeInBytes;
private String title;
private ImageType type;
private Float width;
private Float height;

public Image(Long sizeInBytes, String title, ImageType type, Float width, Float height) {
this(sizeInBytes, 0L, title, type, width, height);
}

public Image(Long sizeInBytes, Long id, String title, ImageType type, Float width, Float height) {
validate(sizeInBytes, title, type, width, height);
this.sizeInBytes = sizeInBytes;
this.id = id;
this.title = title;
this.type = type;
this.width = width;
this.height = height;
}

private void validate(Long sizeInBytes, String title, ImageType type, Float width, Float height) {
validateSize(sizeInBytes);
validateWidth(width);
validateHeight(height);
validateAspectRatio(width, height);
}

private void validateSize(Long sizeInBytes) {
if (sizeInBytes > MAX_SIZE_BYTES) {
throw new IllegalArgumentException("이미지 크기는 최대 1MB 이하여야 합니다.");
}
}

private void validateWidth(Float width) {
if (width < MIN_SIZE_WIDTH) {
throw new IllegalArgumentException("이미지 가로 길이는 최소 300픽셀 이상이어야 합니다.");
}
}

private void validateHeight(Float height) {
if (height < MIN_SIZE_HEIGHT) {
throw new IllegalArgumentException("이미지 세로 길이는 최소 200픽셀 이상이어야 합니다.");
}
}

private void validateAspectRatio(float width, float height) {
float ratio = width / height;
if (Math.abs(ratio - (MIN_SIZE_WIDTH / MIN_SIZE_HEIGHT)) > ASPECT_RATIO_TOLERANCE) {
throw new IllegalArgumentException("이미지 비율은 3:2이어야 합니다.");
}
}
}
13 changes: 13 additions & 0 deletions src/main/java/nextstep/sessions/domain/ImageType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package nextstep.sessions.domain;

public enum ImageType {
GIF, JPG, JPEG, PNG, SVG;

public static ImageType from(String type) {
try {
return ImageType.valueOf(type.toUpperCase());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("지원하지 않는 이미지 타입입니다.");
}
}
}
40 changes: 40 additions & 0 deletions src/main/java/nextstep/sessions/domain/Session.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package nextstep.sessions.domain;

public class Session {
private Long id;
private String title;
private SessionType type;
private SessionStatus status;
private SessionPeriod period;
private Image coverImage;
private EnrollmentCapacity enrollmentCapacity;
private Long price;
Comment on lines +3 to +11
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

규칙 7: 3개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다.
인스턴스 변수의 수를 줄이기 위해 도전한다.

  • 변수의 수를 줄여 보시면 어떨까요?
  • id 변수는 DB를 고려한 변수인 것 같아요. 2단계는 DB를 고려하지 않는 객체 설계가 목적이니 제거하고 구현해 보시면 어떨까요?


public Session(Long price) {
this.price = price;
}

public Session(SessionStatus status, Long price) {
this.status = status;
this.price = price;
}
Comment on lines +13 to +20
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

주 생성자를 호출해 보시면 좋을 것 같아요.


public Session(Long id, String title, SessionType type, SessionStatus status, SessionPeriod period, Image coverImage, EnrollmentCapacity enrollmentCapacity, Long price) {
this.id = id;
this.title = title;
this.type = type;
this.status = status;
this.period = period;
this.coverImage = coverImage;
this.enrollmentCapacity = enrollmentCapacity;
this.price = price;
}

public Long price() {
return this.price;
}

public boolean isRecruiting() {
return status.equals(SessionStatus.RECRUITING);
}
}
20 changes: 20 additions & 0 deletions src/main/java/nextstep/sessions/domain/SessionPeriod.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package nextstep.sessions.domain;

import java.time.LocalDate;

public class SessionPeriod {
private final LocalDate startDate;
private final LocalDate endDate;

public SessionPeriod(LocalDate startDate, LocalDate endDate) {
validate(startDate, endDate);
this.startDate = startDate;
this.endDate = endDate;
}

private void validate(LocalDate startDate, LocalDate endDate) {
if (startDate.isAfter(endDate)) {
throw new IllegalArgumentException("시작일은 종료일보다 이전이어야 합니다.");
}
}
}
5 changes: 5 additions & 0 deletions src/main/java/nextstep/sessions/domain/SessionStatus.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package nextstep.sessions.domain;

public enum SessionStatus {
READY, RECRUITING, CLOSED
}
5 changes: 5 additions & 0 deletions src/main/java/nextstep/sessions/domain/SessionType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package nextstep.sessions.domain;

public enum SessionType {
FREE, PAID
}
40 changes: 40 additions & 0 deletions src/test/java/nextstep/enrollment/domain/EnrollmentTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package nextstep.enrollment.domain;

import nextstep.payments.domain.Payment;
import nextstep.sessions.domain.Session;
import nextstep.sessions.domain.SessionStatus;
import nextstep.users.domain.NsUser;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.*;

class EnrollmentTest {
@Test
@DisplayName("결제 금액이 강의 수강료와 일치하면 수강 신청에 성공한다.")
void 결제_금액_강의_금액_일치() {
new Enrollment(new NsUser(), new Session(10_000L), new Payment("user_id", 0L, 0L, 10_000L));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ImageTypeTest를 제외하곤 성공 테스트의 경우 assertion이 없네요.
최소한의 구현이라 없는 것인지, 객체가 생성된 것만으로 테스트의 의도를 검증된다고 생각하시는지 궁금합니다 🤔

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@testrace
좋은 피드백 감사합니다!
객체가 생성된 것만으로 테스트의 의도가 검증된다고 생각했었는데 그게 아니었나보군요.

혹시 equals를 활용한 객체의 비교나 assertDoesNotThrow() 같은 메서드를 활용해야할까요?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

객체의 역할을 서로 다르게 해석했기에 생성만으로 검증이 충분하다고 판단하신 것 같아요 😃
생성외엔 기능이 없다면 말씀하신대로 equals, assertDoesNotThrow 검증을 하면 좋을 것 같아요.

다만, 학습 목표가 객체 설계와 구현이니 적절한 책임을 가진 도메인 객체를 도출해서 '기능'을 검증해 보시면 좋을 것 같습니다.
예를 들어 '신청에 성공하면 수강생 정보를 반환한다'가 될 수 있겠네요.

Enrollment enrollment = new Enrollment();
Student student = enrollment.enroll(user, payment);
assertThat(student).equals(new Student(...));

이벤트를 고려하셨다니 도메인간 결합을 최대한 끊으려 하신 것 같은데요.
다양한 시도는 좋지만 학습 목표와는 조금 거리가 있는 느낌을 받았습니다.
수강신청 미션도 자동차, 로또, 사다리와 다르지 않게 단순한 단일 애플리케이션입니다.
지난 미션들과 같은 규칙과 원칙들을 적용해서 구현해 보시면 어떨까 싶어요.
이벤트 기반을 적용해 보고 싶으시다면 4단계에서 해보시는 것을 추천드립니다.
(혹시 제가 잘못 이해했다면 코멘트 꼭 남겨주세요 😃 )

}

@Test
@DisplayName("결제 금액이 강의 수강료와 다르면 예외를 던진다.")
void 결제_금액_강의_금액_불일치() {
assertThatIllegalArgumentException().isThrownBy(() ->
new Enrollment(new NsUser(), new Session(10_000L), new Payment("user_id", 0L, 0L, 20_000L)))
.withMessage("결제 금액이 수강료와 일치하지 않습니다.");
}

@Test
@DisplayName("강의 상태가 모집중이면 수강 신청이 가능하다.")
void 모집중_강의_수강_신청() {
new Enrollment(new NsUser(), new Session(SessionStatus.RECRUITING, 10_000L), new Payment("user_id", 0L, 0L, 10_000L));
}

@Test
@DisplayName("강의 상태가 모집중이 아니면 수강 신청 시 예외를 던진다.")
void 모집중_아닌_강의_수강_신청() {
assertThatIllegalArgumentException().isThrownBy(() ->
new Enrollment(new NsUser(), new Session(SessionStatus.CLOSED, 10_000L), new Payment("user_id", 0L, 0L, 10_000L)))
.withMessage("강의 상태가 모집중일 때만 수강 신청이 가능합니다.");
}
}
2 changes: 1 addition & 1 deletion src/test/java/nextstep/qna/domain/AnswerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@ public class AnswerTest {
void 답변_작성자가_타인일_경우_예외가_발생한다() {
Assertions.assertThatThrownBy(() -> A1.validateOwnership(NsUserTest.SANJIGI))
.isInstanceOf(CannotDeleteException.class)
.hasMessageContaining("다른 사람이 쓴 답변이 있어 삭제할 없습니다.");
.hasMessageContaining("답변을 삭제할 권한이 없습니다.");
}
}
24 changes: 24 additions & 0 deletions src/test/java/nextstep/sessions/domain/EnrollmentCapacityTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package nextstep.sessions.domain;

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

import static org.assertj.core.api.Assertions.*;

class EnrollmentCapacityTest {
@Test
@DisplayName("정원이 가득 찬 경우 인원 증가 시 예외를 던진다.")
void 등록_인원_초과() {
EnrollmentCapacity enrollmentCapacity = new EnrollmentCapacity(1, 1);
assertThatThrownBy(enrollmentCapacity::increaseEnrollment)
.isInstanceOf(IllegalStateException.class)
.hasMessage("정원이 초과되었습니다.");
}
Comment on lines +9 to +16
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요구사항에 따라 무료 강의는 정원 제한이 없기에 EnrollmentCapacity#increaseEnrollment 는 예외를 던지지 않아야 할 것 같아요.
무료강의에 대한 테스트도 추가하면 어떨까요?

더불어 예외 검증 시 assertThatThrownBy, assertThatIllegalArgumentException 가 혼용되고 있는데
일관성을 위해 하나의 assertThat~ 메서드로 통일하면 어떨까요?


@Test
@DisplayName("정원이 가득 차지 않은 경우 등록 인원을 정상적으로 증가시킨다..")
void 등록_인원_증가() {
EnrollmentCapacity enrollmentCapacity = new EnrollmentCapacity(1, 0);
enrollmentCapacity.increaseEnrollment();
}
}
Loading