NEXTSTEP에서 진행하는 교육과정 DDD 세레나데 2기다.
교육을 수강한 지 1년이 지났지만, 이제야 후기를 작성한다.
1주차 후기
1주차는 레거시 코드를 통해 도메인 주도 설계 등장 배경을 겪어보고, 왜 도메인 주도 설계인가를 알아본다.
리팩토링을 하기 위한 첫걸음으로 테스트 코드를 학습한다.
테스트 코드를 작성하는 방법은 여러 가지로 모두 경험해보고 어느 상황에 사용하면 좋을지 알아본다.
텐트를 세우기 위해 말뚝이 필요하듯이 리팩터링을 하기 위해선 테스트 코드가 필요하다.
나는 TDD, 클린코드 with Java 11기가 끝난 지 얼마 안 된 시점이라 생소한 개념이 많아서 따라가기 힘들었는데
아래의 개념을 들어봤거나 사용해본 사람이라면 무난하게 진행할 수 있다.
SpringBootTest
실제 애플리케이션 전체를 테스트하는 통합 테스트로 신뢰성이 가장 높다. 대신 속도가 느리다.
WebMvcTest
애플리케이션 전체가 아닌 Web Layer(Controller)만 테스트한다. 서비스 로직 결과는 스텁빙한다.
@WebMvcTest(MenuGroupRestController.class)
class MenuGroupRestControllerTest extends MockMvcSupport {
@Autowired
private MockMvc webMvc;
@MockBean
private MenuGroupService menuGroupService;
@BeforeEach
void setUp() {
fixtureMenuGroups();
this.webMvc = ofUtf8MockMvc();
}
@DisplayName("메뉴그룹 생성하기")
@Test
void createMenuGroup() throws Exception {
// given
MenuGroup menuGroup = menuGroups.get(0);
given(menuGroupService.create(any())).willReturn(menuGroup);
// when
ResultActions perform = webMvc.perform(
post("/api/menu-groups")
.content("{\"name\":\"추천메뉴\"}")
.contentType("application/json")
);
// then
perform
.andDo(print())
.andExpect(status().is2xxSuccessful())
.andExpect(jsonPath("$.id").isNotEmpty())
.andExpect(jsonPath("$.name").value(menuGroup.getName()));
}
...
}
Mockito
Mockito를 이용한 Mock 테스트다.
@ExtendWith(MockitoExtension.class)
public class MenuServiceTest extends FixtureData {
@Mock
private MenuRepository menuRepository;
@Mock
private MenuGroupRepository menuGroupRepository;
@Mock
private ProductRepository productRepository;
@Mock
private PurgomalumClient purgomalumClient;
@InjectMocks
private MenuService menuService;
...
@DisplayName("메뉴 생성")
@Test
void createMenu() {
// given
Menu menu = menus.get(0);
given(menuGroupRepository.findById(menu.getMenuGroupId())).willReturn(Optional.of(menu.getMenuGroup()));
given(productRepository.findAllById(any())).willReturn(Arrays.asList(menu.getMenuProducts().get(0).getProduct()));
given(productRepository.findById(any())).willReturn(Optional.of(products.get(0)));
given(menuRepository.save(any())).willReturn(menu);
// when
Menu createMenu = menuService.create(menu);
// then
assertThat(createMenu).isNotNull();
}
}
테스트 대상의 외부 의존성을 Mock객체로 진행하는 테스트로 스텁빙을 통해 하나의 연극처럼 테스트를 작성한다.
Fake 객체
독특했던 경험인 Fake 객체 테스트 방법으로 객체를 상속받아 진짜처럼 작동하게 만든다.
Mockito을 대체할 수 있지만, 작성하고 관리하는데 손이 많이간다.
public class FakeMenuGroupRepository implements MenuGroupRepository {
private final Map<UUID, MenuGroup> menuGroups = new HashMap<>();
@Override
public MenuGroup save(MenuGroup menuGroup) {
menuGroups.put(menuGroup.getId(), menuGroup);
return menuGroup;
}
@Override
public Optional<MenuGroup> findById(UUID uuid) {
return Optional.ofNullable(menuGroups.get(uuid));
}
@Override
public List<MenuGroup> findAll() {
return new ArrayList<>(menuGroups.values());
}
}
class MenuGroupRepositoryTest {
// Fake 객체
private FakeMenuGroupRepository menuGroupRepository = new FakeMenuGroupRepository();
protected static final UUID FIRST_ID = UUID.randomUUID();
protected static final UUID SECOND_ID = UUID.randomUUID();
private MenuGroup createMenuGroup(UUID id, String menuGroupName) {
MenuGroup menuGroup = new MenuGroup();
menuGroup.setId(id);
menuGroup.setName(menuGroupName);
return menuGroup;
}
@DisplayName("메뉴그룹 저장")
@Test
void save() {
MenuGroup menuGroup = createMenuGroup(FIRST_ID, "일식");
MenuGroup saveMenuGroup = menuGroupRepository.save(menuGroup);
assertThat(menuGroup.getId()).isEqualTo(saveMenuGroup.getId());
}
...
}
이렇게 테스트를 통한 코드 보호로 리팩토링 준비가 끝났다.
1단계 - 문자열 덧셈 계산기
요구 사항
- 쉼표(,) 또는 콜론(:)을 구분자로 가지는 문자열을 전달하는 경우 구분자를 기준으로 분리한 각 숫자의 합을 반환 (예: “” => 0, "1,2" => 3, "1,2,3" => 6, “1,2:3” => 6)
- 앞의 기본 구분자(쉼표, 콜론) 외에 커스텀 구분자를 지정할 수 있다. 커스텀 구분자는 문자열 앞부분의 “//”와 “\n” 사이에 위치하는 문자를 커스텀 구분자로 사용한다. 예를 들어 “//;\n1;2;3”과 같이 값을 입력할 경우 커스텀 구분자는 세미콜론(;)이며, 결과 값은 6이 반환되어야 한다.
- 문자열 계산기에 숫자 이외의 값 또는 음수를 전달하는 경우 RuntimeException 예외를 throw 한다.
1주차 1단계는 JUnit5 테스트 코드에 익숙해지는 미션이다.
간단한 미션인데, 테스트 코드에만 몰입하다가 계산기 역할을 분리하지 않고 하나로 뭉치는 실수와
제공된 코드를 세심히 확인하지 않고 그대로 사용했더니 매번 비싼 비용을 치르는 문제가 있었는데
제대로 인지하지 못했고 리뷰어의 지적에 뒤늦게 수정했다.
2단계 - 요구사항 정리
요구 사항
- kitchenpos 패키지의 코드를 보고 키친포스의 요구 사항을 README.md에 작성한다.
- 미션을 진행함에 있어 아래 문서를 적극 활용한다.
레거시 코드를 분석해 요구사항을 파악하는 미션이다.
코드만으로 어떻게 요구사항을 정리하냐고 생각할 수 있지만 제공되는 파일 중에는 .http, .sql 있다.
menus.http 파일
###
POST {{host}}/api/menus
Content-Type: application/json
{
"name": "후라이드+후라이드",
"price": 19000,
"menuGroupId": "f1860abc-2ea1-411b-bd4a-baa44f0d5580",
"displayed": true,
"menuProducts": [
{
"productId": "3b528244-34f7-406b-bb7e-690912f66b10",
"quantity": 2
}
]
}
CREATE TABLE orders (
id BIGINT(20) NOT NULL AUTO_INCREMENT,
order_table_id BIGINT(20) NOT NULL,
order_status VARCHAR(255) NOT NULL,
ordered_time DATETIME NOT NULL,
PRIMARY KEY (id)
);
참고하면서 파악하면 아래와 같은 요구사항을 뽑아낼 수 있다.
# 키친포스
## 요구 사항
간단한 식당 관리 앱을 구현한다.
메뉴와 상품, 주문(배달, 포장, 취식), 테이블로 구성된다.
### 메뉴 그룹
- [] 사용자가 보는 메뉴그룹을 생성한다.
- [] 메뉴그룹명은 반드시 한글자 이상 입력해야 한다.
- [] 메뉴그룹명은 중복이 가능하다.
- [] 등록된 메뉴그룹 내역을 확인할 수 있다.
...
코드를 파악하다보면 유효성 체크에서 이상한 부분이 있어서 궁금해서 질문했는데
간단하게 레거시에서 쉽게 접할 수 있는 오류 케이스였다.
요구사항을 문서화했으면 이번 단계는 끝난다.
3단계 - 테스트 코드를 통한 코드 보호
요구 사항
- 정리한 키친포스의 요구 사항을 토대로 테스트 코드를 작성한다.
- 모든 Business Object에 대한 테스트 코드를 작성한다.
- @SpringBootTest를 이용한 통합 테스트 코드
- 또는 @ExtendWith(MockitoExtension.class)를 이용한 단위 테스트 코드를 작성한다.
- Controller에 대한 테스트 코드 작성은 권장하지만 필수는 아니다.
- 미션을 진행함에 있어 아래 문서를 적극 활용한다.
레거시에 테스트 코드를 붙이는 미션으로 이전에 비해 많은 어려움을 느꼈다.
우선 테스트 코드를 작성하는 방법을 다양화한다.
- Mockito를 활용한 Mock 테스트
- Web Layer 테스트를 위한 @WebMvcTest
- 실객체를 상속받아 구현하는 Fake 객체
- 테스트 픽스처(Test Fixture)
단위 테스트만 알던 나에게 생소한 게 개념이라 구글링으로 찾아보고 간단히 학습한 뒤 진행했지만 테스트 코드 작성에 시간이 오래 걸렸다.
테스트를 작성하는데 일주일이 넘게 걸렸지만, 덕분에 여러가지 테스트 방법을 알아보고 사용해보면서 서로 무엇이 다른지 경험해보는 시간이었다.
특히, 테스트를 작성할수록 테스틑 코드가 꼬이는 걸 경험했다.
공통적으로 사용되는 테스트 데이터를 Fixture로 분리하니 처음엔 편했는데
작성되는 테스트가 많아질수록 복잡도가 높아지고 Data Set을 수정하면 연관된 테스트 코드 모두가 실패했다.
아래는 문제의 FixtureData 코드다.
public class FixtureData {
protected List<MenuGroup> menuGroups = new ArrayList<>();
protected List<Product> products = new ArrayList<>();
protected List<Menu> menus = new ArrayList<>();
protected List<MenuProduct> menuProducts = new ArrayList<>();
protected List<OrderTable> orderTables = new ArrayList<>();
protected List<OrderLineItem> orderLineItems = new ArrayList<>();
protected List<Order> orders = new ArrayList<>();
protected static final UUID FIRST_ID = UUID.randomUUID();
protected static final UUID SECOND_ID = UUID.randomUUID();
protected static final Boolean MENU_HIDE = false;
protected static final Boolean MENU_SHOW = true;
protected static final Boolean TABLE_CLEAR = true;
protected static final Boolean TABLE_SIT = false;
protected static final int TABLE_DEFAULT_NUMBER_OF_GUEST = 0;
protected static final ObjectMapper objectMapper = new ObjectMapper();
protected void fixtureMenuGroups() {
setMenuGroups();
}
protected void fixtureProducts() {
setMenuGroups();
setProducts();
setMenuProducts();
setMenus();
}
protected void fixtureMenus() {
setMenuGroups();
setProducts();
setMenuProducts();
setMenus();
}
protected void fixtureOrderTables() {
setOrderTables();
}
protected void fixtureOrders() {
setMenuGroups();
setProducts();
setMenuProducts();
setMenus();
setOrderTables();
setOrderLineItems();
setOrders();
}
private void setMenuGroups() {
MenuGroup menuGroup = new MenuGroup();
menuGroup.setId(FIRST_ID);
menuGroup.setName("추천메뉴");
MenuGroup menuGroup2 = new MenuGroup();
menuGroup2.setId(SECOND_ID);
menuGroup2.setName("중식");
menuGroups.add(menuGroup);
menuGroups.add(menuGroup2);
}
private void setProducts() {
Product product = new Product();
product.setId(FIRST_ID);
product.setPrice(ofPrice(1000));
product.setName("후라이드");
Product product2 = new Product();
product2.setId(SECOND_ID);
product2.setPrice(ofPrice(1100));
product2.setName("양념");
products.add(product);
products.add(product2);
}
private void setMenuProducts() {
MenuProduct menuProduct = new MenuProduct();
menuProduct.setSeq(1L);
menuProduct.setProduct(products.get(0));
menuProduct.setQuantity(1L);
MenuProduct menuProduct2 = new MenuProduct();
menuProduct2.setSeq(2L);
menuProduct2.setProduct(products.get(1));
menuProduct2.setQuantity(1L);
menuProducts.add(menuProduct);
menuProducts.add(menuProduct2);
};
private void setMenus() {
Menu menu = new Menu();
menu.setId(FIRST_ID);
menu.setMenuGroup(menuGroups.get(0));
menu.setName("점심 한정");
menu.setPrice(ofPrice(900));
menu.setDisplayed(MENU_SHOW);
menu.setMenuProducts(Arrays.asList(menuProducts.get(0)));
Menu menu2 = new Menu();
menu2.setId(SECOND_ID);
menu2.setMenuGroup(menuGroups.get(1));
menu2.setName("저녁 한정");
menu2.setPrice(ofPrice(1000));
menu2.setDisplayed(MENU_SHOW);
menu2.setMenuProducts(Arrays.asList(menuProducts.get(1)));
menus.add(menu);
menus.add(menu2);
}
private void setOrderTables() {
OrderTable orderTable = new OrderTable();
orderTable.setName("1번");
orderTable.setId(FIRST_ID);
orderTable.setEmpty(TABLE_CLEAR);
orderTable.setNumberOfGuests(TABLE_DEFAULT_NUMBER_OF_GUEST);
OrderTable orderTable2 = new OrderTable();
orderTable2.setName("2번");
orderTable2.setId(SECOND_ID);
orderTable2.setEmpty(TABLE_CLEAR);
orderTable2.setNumberOfGuests(TABLE_DEFAULT_NUMBER_OF_GUEST);
orderTables.add(orderTable);
orderTables.add(orderTable2);
}
private void setOrderLineItems() {
OrderLineItem orderLineItem = new OrderLineItem();
orderLineItem.setMenu(menus.get(0));
orderLineItem.setQuantity(1);
orderLineItem.setPrice(menus.get(0).getPrice());
orderLineItems.add(orderLineItem);
};
private void setOrders() {
Order order = new Order();
order.setId(FIRST_ID);
order.setType(OrderType.EAT_IN);
order.setStatus(OrderStatus.WAITING);
order.setOrderDateTime(LocalDateTime.now());
order.setOrderLineItems(orderLineItems);
order.setOrderTable(orderTables.get(0));
Order order2 = new Order();
order2.setId(SECOND_ID);
order2.setType(OrderType.DELIVERY);
order2.setStatus(OrderStatus.WAITING);
order2.setOrderDateTime(LocalDateTime.now());
order2.setOrderLineItems(orderLineItems);
order2.setDeliveryAddress("대한민국 어딘가");
orders.add(order);
orders.add(order2);
}
protected BigDecimal ofPrice(int price) {
return BigDecimal.valueOf(price);
}
}
한눈에 봐도 복잡한 게 보인다.
다양한 도메인 데이터를 하나의 픽스쳐로 사용하려고 했다가 무서운 괴물이 되어버렸다. (도메인에 맞게 Fixture를 분리하자.)
이전까지 단위 테스트 경험만 있어서 테스트 코드 관리에 자세히 고민해본 적도 없었는데
덕분에 테스트 코드 관리를 어떻게 해야 할지 고민하게 되었다.
그렇게 요구사항에 맞게 테스트 케이스를 다 작성했으면 1주차는 마무리된다.
참고하면 좋은 정보
'교육 및 인강 > DDD 세레나데' 카테고리의 다른 글
DDD 세레나데 2주차 - 크게 소리 내어 모델링하기 (1) | 2022.11.14 |
---|---|
DDD 세레나데 시작하면서... (0) | 2022.10.31 |