워밍업 클럽 3기 BE 클린코드&테스트 - 4주차 발자국
회고
짧지만 긴 한달 간의 워밍업 클럽도 이제 막을 내립니다. 시작하기 직전까지만 해도 저는 워밍업 클럽 3기가 이렇게 혹독할거라곤 생각하지 못했습니다. 그저 산책 같을 거라 지레짐작했던 그 실체는 혹독한 마라톤이었습니다. 하루에 대략 하나의 섹션, 평균적으론 두시간 정도인 수업과 미션들에 반나절을 할애할 줄 누가 알았을까요.
한눈 팔 새도 없이 진도 따라 수업을 따라가다보니 어느덧 끝이 왔습니다. 무척 어렵고, 낯선 내용들이라 배울 때는 머리 속에 애써 넣어도 다 튕겨나가는 기분이라 아쉽기만 한 시간이었네요. 그래도 아쉽지만은 않습니다. 이렇게 좋은 강의를 알게 됐다는 것만 해도 큰 소득이라고 생각합니다. n회차 앞에서는 그 어떤 어려운 강의도 만만할 거라는 믿음을 재차 떠올려봅니다.
강의 내용 요약
Test Double
영어로는 Stunt Double 이라고도 한다
Test Double의 다섯가지 종류
Dummy
아무 것도 하지 않는 깡통 객체
Fake
단순한 형태로 동일한 기능은 수행하나, 프로덕션에서 쓰기에는 부족한 객체
ex. FakeRepository(실제 db와 연결되지 않고 내부 메모리만을 사용하는 임시 리포지토리)
Stub
테스트에서 요청한 것에 대해 미리 준비한 결과를 제공하는 객체
정의된 내용 외에는 응답하지 않는다.
Spy
Stub이면서 호출된 내용을 기록하여 보여줄 수 있는 객체
일부는 실제 객체처럼 동작시키고 일부만 Stubbing 할 수 있다
Mock
행위에 대한 기대를 명세하고, 그에 따라 동작하도록 만들어진 객체
Sutb 과 Mock의 차이
Stub
상태 검증
Stateful
Mock
행위검증
Stateless
더 나은 테스트를 작성하기 위한 구체적 조언
한 문단에 한 주제
글을 쓸 때, 한 문단은 한 주제만을 가져야 한다
테스트는 문서로서의 기능을 한다
테스트 코드는 글쓰기의 관점에서 하나하나의 테스트가 한 문단이다
완벽하게 제어하기
테스트 환경을 조성할 때, 모든 조건을 완벽하게 제어할 수 있어야 한다
테스트 환경의 독립성을 보장하자
테스트하고자 하는 대상 함수 외에 다른 함수를 같이 사용할 경우 테스트의 성패가 다른 함수에 기인할 수 있다
생성자, builder와 같은 함수 외에는 다른 함수를 사용하면 안된다
정적 팩토리 메소드 또한 지양 대상 (정적 팩토리 메소드 자체에 의미가 부여되어있을 경우)
테스트 간 독립성을 보장하자
한 테스트는 다른 테스트에 영향을 주지 않아야 한다
공유 자원을 사용해선 안된다
한눈에 들어오는 Test Fixture 구성하기
Test Fixture
고정물, 고정되어 있는 물체
given 절에서 생성하는 모든 물체들을 의미
테스트를 위해 원하는 상태로 고정시킨 일련의 객체
Test Fixture는 각 테스트마다 명시함으로써 한 눈에 들어오는 테스트를 작성하자
@BeforeEach 나 @BeforeAll 도 가급적 지양한다
그렇다면 언제 쓸 수 있는가? : 테스트를 한 눈에 보기에 어려움이 없는 간접적인 요소의 중복 제거
Test Fixture 클렌징
tearDown() 함수를 만들 경우 deleteAll() 보다는 deleteAllInBatch()를 사용함으로써 비용을 줄일 수 있다
deleteAllInBatch)
쿼리를 한번만 날린다
deleteAll()
테이블 내 모든 로우를 조회 후 각 로우마다 delete 쿼리를 날린다
@ParameterizedTest
@CsvSource, @MethodSource 등과 같이 사용한다
하나의 테스트 케이스지만 여러개의 값을 대입해보고 싶을 경우에 사용할 수 있다
@DynamicTest
시나리오를 기반으로 테스트를 할 때 사용한다
하나의 테스트에서 상태를 공유하면서 내부적으로 다양한 하위 테스트를 진행할 수 있다
환경 통합하기
테스트 수행도 비용이기 때문에 최대한 줄여야 한다
매 테스트시마다 스프링 부트를 초기화하는데 드는 비용을 최소화 하는 것이 가능하다
@WebMvcTest, @DataJpaTest, @SpringBootTest와 같은, 스프링부트를 초기화하는 어노테이션을 쓸 경우
부모 클래스 (ex. IntegrationTestSupport)에 어노테이션을 넣어서 상속하는 방식을 사용한다
mockBean의 구성이 다를 경우에도 새롭게 초기화하기 때문에 부모 클래스에 담는다
이때 protected를 사용하여 상속받는 클래스에서도 사용이 가능하게끔 설정한다
서비스, 리포지토리 레이어는 통합, 컨트롤러는 단위 테스트로 진행할 경우
WebMvcTest 따로, @DataJpaTest, @SpringBootTest 따로 총 두번의 초기화가 되게끔 작성하는 방식을 사용
private 메서드에 테스트하기
private 메서드는 테스트를 하면 안된다
private 메서드는 보통 public 함수 테스트 시에 같이 테스트가 된다
만약 그럼에도 불구하고 테스트가 필요하다고 느낄 경우
해당 클래스가 너무 많은 역할을 가졌다는 신호
private 메서드를 따로 분리하여 새로운 클래스에 역할을 위임한 후 테스트한다
테스트에서만 사용하는 메서드
사용 가능하지만 가급적 지양
보수적인 측면에서 접근해야 한다
학습 테스트
잘 모르는 기능, 라이브러리, 프레임워크를 학습하기 위해 작성하는 테스트
여러 테스트 케이스를 스스로 정의하고 검증하는 과정을 통해 보다 구체적인 동작과 기능을 학습할 수 있다
관련 문서만 읽는 것보다 훨씬 재미있게 학습할 수 있다
Spring REST Docs
테스트 코드를 통한 API 문서 자동화 도구
API 명세를 문서로 만들고 외부에 제공함으로써 협업을 원활하게 한다
기본적으로 AsciiDoc을 사용하여 문서를 작성한다
REST Docs vs Swagger
REST Docs
장점
테스트를 통과해야 문서가 만들어진다
신뢰도가 높다
프로덕션 코드에 비침투적이다
단점
코드 양이 많다
설정이 어렵다
Swagger
장점
적용이 쉽다
문서에서 바로 API 호출을 수행해볼 수 있다
단점
프로덕션 코드에 침투적이다
테스트와 무관하기 때문에 신뢰도가 떨어질 수 있다.
미션
Day 16
미션 설명
Layered Architecture 구조의 레이어별 테스트 작성법을 알아보았습니다.
레이어별로 1) 어떤 특징이 있고, 2) 어떻게 테스트를 하면 좋을지, 자기만의 언어로 다시 한번 정리해 볼까요?
나의 답
Persistence Layer
특징
DB 데이터에 직접 접근하는 역할을 가진다
데이터 제공 외의 역할을 해선 안된다 (비즈니스 가공 로직은 포함해선 안된다)
데이터에 대한 CRUD에 집중한 레이어다
테스트 코드 작성법
DB 접근을 통해 수행하는 CRUD 작업이 정상적으로 수행됐는지 검증한다
DataJpaTest 어노테이션을 활용하여 좀 더 가벼운(더 빠른) 테스트 코드 작성이 가능하다
Persistence Layer와 관련된 빈들만을 스프링 컨테이너에 올린다
테스트 시 사용하는 DB는 실제 배포 환경에서 사용하는 DB를 일치시켜야 한다
두 환경의 DB를 다르게 할 경우, 코드의 안정성을 보장할 수 없다
@ActiveProfiles("test") 어노테이션 application.yml 파일에 설정해둔 테스트 환경 전용 설정을 사용할 수 있다.
코드 예시
@ActiveProfiles("test")
@DataJpaTest
class ProductRepositoryTest {
@Autowired
private ProductRepository productRepository;
@DisplayName("원하는 판매상태를 가진 상품들을 조회한다.")
@Test
void findAllBySellingStatusIn() {
// given
Product product1 = createProduct("001", HANDMADE, SELLING, "아메리카노", 4000);
Product product2 = createProduct("002", HANDMADE, HOLD, "카페라떼", 4500);
Product product3 = createProduct("003", HANDMADE, STOP_SELLING, "팥빙수", 7000);
productRepository.saveAll(List.of(product1, product2, product3));
// when
List<Product> products = productRepository.findAllBySellingStatusIn(List.of(SELLING, HOLD));
// then
assertThat(products).hasSize(2)
.extracting("productNumber", "name", "sellingStatus")
.containsExactlyInAnyOrder(
tuple("001", "아메리카노", SELLING),
tuple("002", "카페라떼", HOLD)
);
}
private Product createProduct(String productNumber, ProductType type, ProductSellingStatus sellingStatus, String name, int price) {
return Product.builder()
.productNumber(productNumber)
.type(type)
.sellingStatus(sellingStatus)
.name(name)
.price(price)
.build();
}
}
application.yml 파일 예시
spring:
application:
name: cafekiosk
profiles:
default: local
datasource:
url: jdbc:h2:mem:~/cafeKioskApplication
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: none
---
spring:
config:
activate:
on-profile: local
jpa:
hibernate:
ddl-auto: create
show-sql: true
properties:
hibernate:
format_sql: true
defer-datasource-initialization: true # (2.5~) Hibernate 초기화 이후 data.sql 실행
h2:
console:
enabled: true
---
spring:
config:
activate:
on-profile: test
jpa:
hibernate:
ddl-auto: create
show-sql: true
properties:
hibernate:
format_sql: true
sql:
init:
mode: NEVERBusiness Layer
특징
비즈니스 로직을 구현하는 역할을 가진다
Persistence Layer와의 상호 작용을 통해 비즈니스 로직을 전개한다
트랜잭션을 보장해야 한다
테스트 코드 작성법
비즈니스 로직의 정상 동작을 테스트한다.
엣지 케이스 위주로 작성한다
테스트에 성공하는 특정 값의 범위가 있을 경우, 해당 범위의 극점을 사용함으로써 나머지 값들의 성공을 어느정도 보장할 수 있다
해피 케이스 외의 눈에 보이는 예외와 눈에 보이지 않는 예외 케이스에 대한 테스트에 집착해야 한다.
Persistence Layer와 함께 통합 테스트로 진행한다
tearDown() vs @Transactional
각 테스트는 독립성이 보장되어야 한다. 이를 위해 테스트를 수행할때마다 초기화 작업이 진행되어야 하며 이때 두가지 방식을 사용할 수 있다.
tearDown()
@AfterEach 메소드를 붙인 tearDown() 함수를 설정하여 직접 초기화 로직을 작성한다
tearDown이라는 명칭은 단순 관례에 불과하며, 이는 테스트 코드 수행 후 정리하는 함수라는 의미를 가진다
@Transactional
테스트 클래스 혹은 테스트 메소드에 어노테이션 형식으로 사용할 수 있다
테스트가 끝날때마다 트랜잭션의 롤백 기능을 활용하여 초기화 작업을 수행한다
실제 코드의 안정성을 보장할 수 없기 때문에 조심스럽게 활용해야 한다
실제 코드의 @Transactional 누락 여부와 관계 없이 @Transactional 어노테이션이 작동한다.
테스트가 실제 코드의 @Transactional 누락을 잡아낼 수 없다!
코드 예시
@ActiveProfiles("test")
//@Transactional
@SpringBootTest
class OrderServiceTest {
@Autowired
private ProductRepository productRepository;
@Autowired
private OrderRepository orderRepository;
@Autowired
private OrderProductRepository orderProductRepository;
@Autowired
private StockRepository stockRepository;
@Autowired
private OrderService orderService;
@AfterEach
void tearDown() {
orderProductRepository.deleteAllInBatch();
productRepository.deleteAllInBatch();
orderRepository.deleteAllInBatch();
stockRepository.deleteAllInBatch();
}
@DisplayName("주문번호 리스트를 받아 주문을 생성한다.")
@Test
void createOrder() {
// given
LocalDateTime registeredDateTime = LocalDateTime.now();
Product product1 = createProduct(HANDMADE, "001", 1000);
Product product2 = createProduct(HANDMADE, "002", 3000);
Product product3 = createProduct(HANDMADE, "003", 5000);
productRepository.saveAll(List.of(product1, product2, product3));
OrderCreateServiceRequest request = OrderCreateServiceRequest.builder()
.productNumbers(List.of("001", "002"))
.build();
// when
OrderResponse orderResponse = orderService.createOrder(request, registeredDateTime);
// then
assertThat(orderResponse.getId()).isNotNull();
assertThat(orderResponse)
.extracting("registeredDateTime", "totalPrice")
.contains(registeredDateTime, 4000);
assertThat(orderResponse.getProducts()).hasSize(2)
.extracting("productNumber", "price")
.containsExactlyInAnyOrder(
tuple("001", 1000),
tuple("002", 3000)
);
}
@DisplayName("재고가 부족한 상품으로 주문을 생성하려는 경우 예외가 발생한다.")
@Test
void createOrderWithNoStock() {
// given
LocalDateTime registeredDateTime = LocalDateTime.now();
Product product1 = createProduct(BOTTLE, "001", 1000);
Product product2 = createProduct(BAKERY, "002", 3000);
Product product3 = createProduct(HANDMADE, "003", 5000);
productRepository.saveAll(List.of(product1, product2, product3));
Stock stock1 = Stock.create("001", 1);
Stock stock2 = Stock.create("002", 2);
stockRepository.saveAll(List.of(stock1, stock2));
OrderCreateServiceRequest request = OrderCreateServiceRequest.builder()
.productNumbers(List.of("001", "001", "002", "003"))
.build();
// when // then
assertThatThrownBy(() -> orderService.createOrder(request, registeredDateTime))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("재고가 부족한 상품이 있습니다.");
}
private Product createProduct(ProductType type, String productNumber, int price) {
return Product.builder()
.type(type)
.productNumber(productNumber)
.price(price)
.sellingStatus(SELLING)
.name("메뉴 이름")
.build();
}
}Presentation Layer
특징
외부 세계의 요청을 가장 먼저 받는 계층이다
파라미터에 대한 최소한의 유효성 검사를 수행한다
테스트 코드 작성법
비즈니스 로직을 제외한, 외부 요청에 대한 유효성 검사와 응답에 대한 테스트를 진행한다
Business Layer 호출은 Mock 객체를 통해 이루어진다
MockMvc 인스턴스를 통한 모의 요청을 사용한다
이때 json 형식의 body가 필요할 경우 ObjectMapper를 활용한다.
코드 예시
@WebMvcTest(controllers = OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockitoBean
private OrderService orderService;
@DisplayName("신규 주문을 등록한다.")
@Test
void createOrder() throws Exception {
// given
OrderCreateRequest request = OrderCreateRequest.builder()
.productNumbers(List.of("001"))
.build();
// when // then
mockMvc.perform(
MockMvcRequestBuilders.post("/api/v1/orders/new")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(jsonPath("$$.code").value("200"))
.andExpect(jsonPath("$$.status").value("OK"))
.andExpect(jsonPath("$$.message").value("OK"));
}
@DisplayName("신규 주문을 등록할 때 상품번호는 1개 이상이어야 한다.")
@Test
void createOrderWithEmptyProductNumbers() throws Exception {
// given
OrderCreateRequest request = OrderCreateRequest.builder()
.productNumbers(List.of())
.build();
// when // then
mockMvc.perform(
MockMvcRequestBuilders.post("/api/v1/orders/new")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.status().isBadRequest())
.andExpect(jsonPath("$$.code").value("400"))
.andExpect(jsonPath("$$.status").value("BAD_REQUEST"))
.andExpect(jsonPath("$$.message").value("상품 번호 리스트는 필수입니다."));
}
} // $$ 는 실제 코드에서 $ 로 작성됩니다.
Day 18
미션 설명 1
@Mock, @MockBean, @Spy, @SpyBean, @InjectMocks 의 차이를 한번 정리해 봅시다.
나의 답
@Mock
행위에 대한 기대를 명세하고, 그에 따라 동작하는 객체를 생성합니다
테스트하기 어려운 외부 의존성을 대체하기 위해 사용된다
@MockBean
@Mock과 같은 기능을 하지만 스프링 컨테이너 아래에서 동작한다
대상 객체의 Mock 객체를 대신 컨테이너에 올린다
@Spy
Stub 이면서 호출된 내용을 기록하여 보여줄 수 있는 객체
실제 객체처럼 동작하고 일부만 Stubbing 할 수 있다
@SpyBean
@Spy와 같은 기능을 하지만 스프링 컨테이너 아래에서 동작한다
대상 객체의 Spy 객체를 대신 컨테이너에 올린다
@InjectMocks
@Mock 과 @Spy 로 생성한 Stub 객체를 의존성으로 주입한다
스프링이 자동으로 의존성을 주입해주는 @MockBean, @SpyBean 과는 달리 @Mock, @Spy를 사용할 경우 @InjectMocks 를 통해 의존성을 주입해줘야 한다
미션 설명 2
아래 3개의 테스트를 살펴보고, 각 항목을 @BeforeEach, given절, when절에 배치한다면 어떻게 배치하고 싶으신가요?
@BeforeEach
void setUp() {
❓
}
@DisplayName("사용자가 댓글을 작성할 수 있다.")
@Test
void writeComment() {
1-1. 사용자 생성에 필요한 내용 준비
1-2. 사용자 생성
1-3. 게시물 생성에 필요한 내용 준비
1-4. 게시물 생성
1-5. 댓글 생성에 필요한 내용 준비
1-6. 댓글 생성
// given
❓
// when
❓
// then
검증
}
@DisplayName("사용자가 댓글을 수정할 수 있다.")
@Test
void updateComment() {
2-1. 사용자 생성에 필요한 내용 준비
2-2. 사용자 생성
2-3. 게시물 생성에 필요한 내용 준비
2-4. 게시물 생성
2-5. 댓글 생성에 필요한 내용 준비
2-6. 댓글 생성
2-7. 댓글 수정
// given
❓
// when
❓
// then
검증
}
@DisplayName("자신이 작성한 댓글이 아니면 수정할 수 없다.")
@Test
void cannotUpdateCommentWhenUserIsNotWriter() {
3-1. 사용자1 생성에 필요한 내용 준비
3-2. 사용자1 생성
3-3. 사용자2 생성에 필요한 내용 준비
3-4. 사용자2 생성
3-5. 사용자1의 게시물 생성에 필요한 내용 준비
3-6. 사용자1의 게시물 생성
3-7. 사용자1의 댓글 생성에 필요한 내용 준비
3-8. 사용자1의 댓글 생성
3-9. 사용자2가 사용자1의 댓글 수정 시도
// given
❓
// when
❓
// then
검증
}나의 답
@BeforeEach
void setUp() {
}
@DisplayName("사용자가 댓글을 작성할 수 있다.")
@Test
void writeComment() {
// given
1-1. 사용자 생성에 필요한 내용 준비
1-2. 사용자 생성
1-3. 게시물 생성에 필요한 내용 준비
1-4. 게시물 생성
1-5. 댓글 생성에 필요한 내용 준비
// when
1-6. 댓글 생성
// then
검증
}
@DisplayName("사용자가 댓글을 수정할 수 있다.")
@Test
void updateComment() {
// given
2-1. 사용자 생성에 필요한 내용 준비
2-2. 사용자 생성
2-3. 게시물 생성에 필요한 내용 준비
2-4. 게시물 생성
2-5. 댓글 생성에 필요한 내용 준비
2-6. 댓글 생성
// when
2-7. 댓글 수정
// then
검증
}
@DisplayName("자신이 작성한 댓글이 아니면 수정할 수 없다.")
@Test
void cannotUpdateCommentWhenUserIsNotWriter() {
// given
3-1. 사용자1 생성에 필요한 내용 준비
3-2. 사용자1 생성
3-3. 사용자2 생성에 필요한 내용 준비
3-4. 사용자2 생성
3-5. 사용자1의 게시물 생성에 필요한 내용 준비
3-6. 사용자1의 게시물 생성
3-7. 사용자1의 댓글 생성에 필요한 내용 준비
3-8. 사용자1의 댓글 생성
// when
3-9. 사용자2가 사용자1의 댓글 수정 시도
// then
검증
}의도
테스트하고자 하는 행위 외의 모든 준비 로직을 given에 두었습니다
각 테스트의 독립성과 한 눈에 확인할 수 있는 가독성을 확보하기 위해 @BeforeEach는 활용하지 않았습니다
댓글을 작성해보세요.