Skip to content
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

3단계 - 테스트를 통한 코드 보호 #258

Open
wants to merge 40 commits into
base: testrace
Choose a base branch
from

Conversation

testrace
Copy link
Member

안녕하세요 리뷰어님 😄
3단계 리뷰 요청드립니다!

요구사항대로만 테스트 코드 작성하면 되겠지 생각하고 시작했는데 결코 쉽지 않았습니다. 😢
특히 요구사항대로 테스트 코드를 작성하고 실행 후 테스트를 실패하게 되었을 때 어느 부분(레거시 코드, 요구사항, 테스트 코드)에서 문제가 있어서
테스트가 실패한 건지 찾는 과정이 힘들었습니다.

stubbing(given()) 코드가 중복되는 게 많다고 생각되고, Fixture를 활용했으나 유용하게 활용했는지 잘 모르겠습니다.
stubbing, fixture에 대해서도 피드백 주시면 감사하겠습니다. (__)

상품을 포함하는 메뉴의 진열여부 변경 기준이
상품들의 가격을 합산 하지 않고 있었음
fixture 객체를 수정하여 다른 테스트에 영향을 끼쳐
fixture 객체를 직접 수정하지 않도록 변경
fixture 객체를 수정하여 다른 테스트에 영향을 끼쳐
fixture 객체를 직접 수정하지 않도록 변경
mock 대신 fake 객체 활용
mock 대신 fake 객체 활용
DisplayName을 요구사항과 일치하도록 변경
mock 대신 fake 객체 활용
DisplayName을 요구사항과 일치하도록 변경
mock 대신 fake 객체 활용
mock 대신 fake 객체 활용
@testrace
Copy link
Member Author

안녕하세요 😃
지난 강의 이후로 Fake객체를 활용하여 테스트 코드를 작성했습니다!
리팩토링을 위한 테스트 코드를 작성하는데 많은 것을 배운게 많아서 좋았던 것 같습니다!!
OrderServiceTest에서는 표준 예외 대신 커스텀 예외를 사용했더니 지난번에 작성했던 테스트 코드가 정상이 아니라는 걸 알았을 때는 스스로 민망하더라구요 😆

3단계 리뷰요청이 좀 늦었지만 잘 부탁드립니다 (__)

Copy link
Contributor

@wotjd243 wotjd243 left a comment

Choose a reason for hiding this comment

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

테스트 픽스처, 가짜 객체 등을 사용하여 테스트 코드를 잘 작성해 주셨습니다. 👍
테스트 코드를 작성하는 이유 중에는 '테스트 코드를 통해 요구 사항을 이해할 수 있다'와 '클라이언트가 객체를 사용하는 방법에 관한 적절한 코드 예제를 제공하는 것'이 있습니다.
그 외에도 몇 가지 의견을 남겨 놓았으니 충분히 고민하고 도전해 보세요!
진행 과정에서 궁금한 점은 댓글이나 Slack을 통해 물어보세요.
아래의 글이 도움 되면 좋겠어요.
https://blog.kingbbode.com/52

@@ -179,4 +180,47 @@ public Order complete(final UUID orderId) {
public List<Order> findAll() {
return orderRepository.findAll();
}


static class OrderTypeNotExistException extends IllegalStateException {
Copy link
Contributor

Choose a reason for hiding this comment

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

커스터마이징 한 예외 클래스 👍


static class OrderTypeNotExistException extends IllegalStateException {
public OrderTypeNotExistException() {
super("주문 유형이 올바르지 않습니다");
Copy link
Contributor

Choose a reason for hiding this comment

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

예외 메시지를 잘 작성했네요. 👍

Comment on lines 185 to 225
static class OrderTypeNotExistException extends IllegalStateException {
public OrderTypeNotExistException() {
super("주문 유형이 올바르지 않습니다");
}
}

static class OrderLineItemNotExistException extends IllegalStateException {
public OrderLineItemNotExistException() {
super("주문 상품이 없습니다.");
}
}

static class OrderLineItemNotMatchException extends IllegalStateException {
public OrderLineItemNotMatchException() {
super("등록되지 않은 메뉴는 주문할 수 없습니다.");
}
}

static class OrderInvalidQuantityException extends IllegalStateException {
public OrderInvalidQuantityException(long quantity) {
super("최소 주문 수량은 0개 이상입니다. 주문 수량 : " + quantity);
}
}

static class OrderDisplayException extends IllegalStateException {
public OrderDisplayException() {
super("진열되지 않은 메뉴는 주문할 수 없습니다.");
}
}

static class OrderLineItemPriceException extends IllegalArgumentException {
public OrderLineItemPriceException(String menu, long menuPrice, long requestPrice) {
super("가격이 일치하지 않습니다. 메뉴명: " + menu + ", 메뉴 가격: " + menuPrice + ", 지불 가격: " + requestPrice);
}
}

static class OrderDeliveryAddressException extends IllegalArgumentException {
public OrderDeliveryAddressException() {
super("배달 주소가 없습니다.");
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

별도의 파일로 분리하면 어떨까요?

Optional<MenuGroup> findById(UUID menuGroupId);
}

interface JpaMenuGroupRepository extends MenuGroupRepository, JpaRepository<MenuGroup, UUID> {
Copy link
Contributor

Choose a reason for hiding this comment

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

아래의 Google Java Style Guide를 참고해 주세요.

Each top-level class resides in a source file of its own.

class DefaultMenuGroupServiceTest {

private final MenuGroupRepository menuGroupRepository = new InMemoryMenuGroupRepository();
private DefaultMenuGroupService menuGroupService;
Copy link
Contributor

Choose a reason for hiding this comment

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

인터페이스를 타입으로 사용하는 습관을 길러 두면 프로그램이 훨씬 유연해질 거예요. 인터페이스와 다형성에 대해 알아보고 아래의 글도 참고하면 좋을 것 같아요.
https://stackoverflow.com/questions/11683044/return-arraylist-or-list/11683073

@Test
void createMenuGroupNotExistException() {
//given
Menu 메뉴_그룹_없는_메뉴 = 메뉴_생성(11_000);
Copy link
Contributor

Choose a reason for hiding this comment

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

테스트 조건이 명확하게 드러나지 않은 것 같아요. 어떤 상황을 테스트하고 싶은지 코드로 표현해 보세요.

assertThatThrownBy(actual).isInstanceOf(IllegalArgumentException.class);
}

@DisplayName("메뉴는 수량이 부족한 상품을 포함할 수 없다.")
Copy link
Contributor

Choose a reason for hiding this comment

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

"메뉴는 수량이 부족한 상품을 포함할 수 없다."라는 이름은 적절할까요?

@Test
void create() {
//given
OrderTable 신규_테이블 = 식탁_생성("3번");
Copy link
Contributor

Choose a reason for hiding this comment

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

아래의 코드로 개선해 보면 어떨까요?

Suggested change
OrderTable 신규_테이블 = 식탁_생성("3번");
OrderTable 신규_식탁 = 식탁_생성("3번");

Copy link
Member Author

Choose a reason for hiding this comment

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

용어를 통일하지 않았네요 🥲

Comment on lines 75 to 106
@DisplayName("가격을 변경할 수 있다. 가격이 해당 상품을 포함하는 메뉴의 가격보다 크면 메뉴를 진열하지 않는다.")
@ParameterizedTest(name = "변경할 가격: [{0}], 진열 여부: [{1}]")
@CsvSource(value = {
"8000, false",
"15000, true"
})
void changePrice(long price, boolean expectedDisplayed) {
//given
Product 신규_상품 = productRepository.save(상품_생성(12_000));

MenuProduct menuProduct = new MenuProduct();
menuProduct.setProduct(신규_상품);
menuProduct.setProductId(신규_상품.getId());
menuProduct.setQuantity(1);

Menu menu = new Menu();
menu.setDisplayed(true);
menu.setMenuProducts(Arrays.asList(menuProduct, 콜라_1개));
menu.setPrice(BigDecimal.valueOf(11_000L));
menuRepository.save(menu);

Product 변경할_금액 = 상품_생성(price);

//when
Product product = productService.changePrice(신규_상품.getId(), 변경할_금액);

//then
assertAll(
() -> assertThat(product.getPrice()).isEqualTo(BigDecimal.valueOf(price)),
() -> assertThat(menu.isDisplayed()).isEqualTo(expectedDisplayed)
);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

일반적으로 테스트마다 하나의 논리적 개념을 테스트하는 것이 좋아요. 아래의 글이 도움 되면 좋겠어요.
https://softwareengineering.stackexchange.com/questions/7823/is-it-ok-to-have-multiple-asserts-in-a-single-unit-test

Copy link
Member Author

Choose a reason for hiding this comment

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

링크 감사합니다!
레거시 코드의 여러 책임을 테스트 코드에서도 동일하게 검증하고,
리팩토링할 때 테스트 코드도 같이 리팩토링 하면 되지 않을까 생각했었는데, 잘못된 생각이었네요 😭

테스트 코드는 프로덕션 코드를 검증하기도 하지만 리팩토링을 위한 안전 장치이기도 한데
테스트 코드를 레거시 코드와 동일한 형태로 만든다면 무의미한 안전장치가 될 것이라는 생각이 들었습니다. 😀

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;

@TestConfiguration
Copy link
Contributor

Choose a reason for hiding this comment

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

@TestConfiguration 👍 이번 기회에 @Configuration@TestConfiguration의 차이점을 알아보면 어떨까요?

Copy link
Member Author

@testrace testrace Mar 30, 2022

Choose a reason for hiding this comment

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

SpringBootTest와 같이 Configuration를 읽어 들이는 테스트 환경에서 TestConfiguration은 제외되는 걸 확인했습니다. 😄
Configuration이 필수 설정이라면 TestConfiguration은 선택 설정이라고 할 수 있을 것 같아요.
필수 설정은 모두 적용되지만 선택 설정은 필요한 설정만 골라서 적용하거나, 여러 테스트에서도 적용할 수 있고, 필수 설정의 일부분을 재정의해서 활용할 수 있을 것 같아요!
단위테스트에서도 로드되지 않는 빈을 추가하여 원하는 테스트 환경을 만들 수도 있네요 😄

Controller 테스트에서 가짜객체를 활용해 테스트 해보고 싶었을 뿐인데 피드백 안주셨으면
TestConfiguration은 단순히 빈을 등록하기 위해 사용하는 거라고 생각하고 넘어 갔을 것 같아요.
감사합니다!

순서와 상관없이 elements 일치 여부를 검증할 수 있다.
repository의 save는 수정 기능도 포함하고 있다. ID가 없을 경우에만 부여한다.
enum은 equals로 비교할 필요 없다.
테스트의 의도를 명확하게 표현할 수 있는 변수명으로 변경
테스트의 의도를 명확하게 표현할 수 있는 테스트 이름과 변수명으로 변경
테이블 -> 식탁
일치 하지 않은 용어로 혼란을 줄 수 있는 요소 제거.
테스트는 하나의 논리적 개념만 검증한다.
레거시 코드에서 여러 책임을 수행하고 있더라도
테스트 코드는 하나의 논리적 개념만 검증하도록 해야 한다.
레거시 코드의 순서대로 테스트를 하고 있다.
레거시 코드가 변경되면 테스트의 성공여부를 확신할 수 없다.
즉 안전한 테스트 코드라고 할 수 없다.
레거시 코드가 변경되어도 테스트는 온전한 기능을 해야한다.
순서에 의존하지 않아도 되는 코드에는 선행조건을 만들지 말고
독립적인 실행을 보장해야 한다.
Copy link
Contributor

@wotjd243 wotjd243 left a comment

Choose a reason for hiding this comment

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

피드백을 잘 반영해 주셨습니다. 👍
간단한 생각거리를 남겼으니 코드에 반영해 주셔도 좋습니다.
다음 리뷰 요청 때는 Merge 하도록 하겠습니다.

Comment on lines 9 to 10
Optional<Order> findById(UUID orderId);
boolean existsByOrderTableAndStatusNot(OrderTable orderTable, OrderStatus status);
Copy link
Contributor

Choose a reason for hiding this comment

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

IntelliJ IDEA의 코드 자동 정렬 기능(⌥⌘L, Ctrl+Alt+L)을 사용하면 더 깔끔한 코드를 볼 수 있을 거예요.

Suggested change
Optional<Order> findById(UUID orderId);
boolean existsByOrderTableAndStatusNot(OrderTable orderTable, OrderStatus status);
Optional<Order> findById(UUID orderId);
boolean existsByOrderTableAndStatusNot(OrderTable orderTable, OrderStatus status);

Comment on lines 71 to 74
assertAll(
() -> assertThat(menuGroups).hasSize(2),
() -> assertThat(menuGroups).containsExactlyInAnyOrder(세트메뉴, 추천메뉴)
);
Copy link
Contributor

Choose a reason for hiding this comment

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

아래의 코드로 개선해 보면 어떨까요?

Suggested change
assertAll(
() -> assertThat(menuGroups).hasSize(2),
() -> assertThat(menuGroups).containsExactlyInAnyOrder(세트메뉴, 추천메뉴)
);
assertThat(menuGroups).containsExactlyInAnyOrder(세트메뉴, 추천메뉴);

import kitchenpos.domain.MenuGroup;
import kitchenpos.domain.MenuProduct;

public class MenuBuilder {
Copy link
Contributor

Choose a reason for hiding this comment

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

빌더 패턴 👍

Comment on lines 88 to 89
.withMenuGroup(세트_메뉴)
.withMenuGroupId(세트_메뉴.getId())
Copy link
Contributor

Choose a reason for hiding this comment

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

아래 코멘트에 대해 고민해 봐요.
#258 (comment)

Suggested change
.withMenuGroup(세트_메뉴)
.withMenuGroupId(세트_메뉴.getId())
.withMenuGroupId(세트_메뉴.getId())
.withMenuProducts(Collections.emptyList())

Comment on lines 103 to 105
MenuGroup 세트_메뉴 = menuGroupRepository.save(세트메뉴);
productRepository.save(맛초킹);
productRepository.save(콜라);
Copy link
Contributor

Choose a reason for hiding this comment

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

다른 테스트 코드에도 필요한 테스트 조건인 것 같아요. 아래 코멘트에 대해 고민해 봐요.
#258 (comment)

@Test
void createOrderTypeException() {
//given
Order order = new Order();
Copy link
Contributor

Choose a reason for hiding this comment

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

아래 코멘트에 대해 고민해 봐요.
#258 (comment)

containsExactly 는 hasSize를 포함한다.
Preferences - Tools - Actions to Save
Reformat code, Optimize imports
활성화
테스트 조건(given)이 명확하지 않아 테스트의 의도를 파악하기 어려움이 있었음.
Builder 패턴을 적용하여 시간적 결합과 의도를 모두 표현할 수 있도록 변경
시간적 결합을 제거하고
테스트의 의도를 코드로 표현
@testrace
Copy link
Member Author

시간적 결합을 찾기 위해서 주문 생성에서 발생할 수 있는 예외를 커스텀 예외로 분리했습니다.
표준예외만 사용하고 있기도하고 예외 메시지가 없으니 의도한 예외인지 아닌지를 구분하기가 쉽지 않았습니다 😭
빌더패턴을 적용해서 테스트의 의도를 쉽게 파악할 수 있도록 해봤습니다.
이전의 코드보다는 확실히 나아진 것 같은데, 적절하게 활용했는지는 모르겠습니다 ㅎㅎ

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants