실무에서 사용하기 위한 테스트코드의 기본기를 학습합니다.
Before
테스트 코드를 작성하지 않던 지난 프로젝트를 거치며
불어나가는 함수와 메소드들을 보며 점점더 유지보수가 어렵다고 많이 느꼈다.
내가 만든 코드를 다른 사람이 수정하기 어려워질뿐아니라
요구사항이 바뀔때마다 이전 기능이 동작하지 않는 경우를 겪으며 변화의 필요성을 느꼈다.
그러던 과정에 인프런에 강의를 추천받았고 나에게 꼭 필요하다고 느꼈고 이번 기회에 수강을 했다.
테스트코드가 무엇인지는 알았으나 어떤식으로 작성해야할지 몰랐고,
작은 프로젝트를 거치며 JPA기반 테스트를 라이브 코딩하며 배우는것을 목표로 했다.

테스트코드가 왜 필요할까?
테스트 코드를 작성하는게 귀찮아서 프로덕션 코드만 작성했다.
그 과정에서 릴리즈를 앞둔 개발 과정에 테스트는 불가피하다.
수십, 수백 가지의 기능이 있는 기능들을 1차, 2차, 3차로 검증하게 될 텐데
점점더 확장해나가는 기능들을 모두 사람들이 검증하는데는 한계가 있다.
소프트웨어가 커지는 속도를 사람이 커버하는데 한계가 있을것이고,
이를 컴퓨터가 확인하도록 우리가 기대하는 동작을 검증시키는데 목적이 있다.
테스트코드를 작성하는데 귀찮고 시간이 소요되지만, 어플리케이션 주기 전체를 놓고 봤을 때는
가장 빠른길이라고 할 수 있다.
@RepositoryTest
1. ProductRepository

판매가능한 상품을 조회하는 API를 만들어야해서
SpringData JPA를 활용하여 상품이 판매 가능한 타입인지 조회하는 쿼리메소드를 만들었다.
이를 검증하기 위한 테스트코드를 만든다.
ProductRepositoryTest

given / when / then 구문으로 분리를 하고
테스트 하고자 하는 쿼리메소드를 수행하기 위해 productRepository 의존성을 주입받아 사용했다.
1. given
데이터베이스에 꺼내어 조회를 해야하기 때문에 given 절에 필요한 데이터들과 타입들을 분리하여 저장시킨다.
2. when
판매 타입을 명시하고 테스트하고자 하는 쿼리메소드를 호출한다.
3. then
assertj라이브러리를 사용하여 조회가 되야하는 크기, 포함되어있는 상품 정보들을 검증하는 함수를 수행시킨다.
2. OrderRepository
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("select o " +
"from Order o " +
"where o.registeredDateTime >= :startedDate " +
"and o.registeredDateTime < :endDate " +
"and o.orderStatus = :orderStatus")
List<Order> findOrdersBy(LocalDateTime startedDate, LocalDateTime endDate, OrderStatus orderStatus);
}
주문 중 오늘 결제된 주문 상품 조회를 위한 쿼리 메소드를 만들었다.
이를 검증하기 위한 테스트코드를 만든다.
OrderRepositoryTest
@SpringBootTest
class OrderRepositoryTest {
@Autowired
OrderRepository orderRepository;
@Autowired
ProductRepository productRepository;
@DisplayName("오늘 결제된 주문 중 결제 완료 주문을 조회한다.")
@Test
void findOrdersBy() {
// given
LocalDateTime now = LocalDateTime.now();
LocalDate nowDate = now.toLocalDate();
Product product1 = createProduct("001");
Product product2 = createProduct("002");
Product product3 = createProduct("003");
productRepository.saveAll(List.of(product1, product2, product3));
Order order = Order.create(List.of(product1, product2, product3), now);
orderRepository.save(order);
// when
OrderStatus expect = OrderStatus.INIT;
List<Order> orderResult = orderRepository.findOrdersBy(
nowDate.atStartOfDay(),
nowDate.plusDays(1).atStartOfDay(),
expect
);
// then
assertThat(orderResult).hasSize(1)
.extracting("registeredDateTime", "totalPrice")
.containsExactlyInAnyOrder(
tuple(now, 9000)
)
;
}
private Product createProduct(String productNo) {
return Product.builder()
.productNo(productNo)
.productType(ProductType.BAKERY)
.sellingType(ProductSellingType.SELLING)
.price(3000)
.name("아메리카노")
.build();
}
*테스트의 DisplayName은 명사의 나열보다 문장으로, 어떤 행위를 했을 때 어떤 결과가 도출되는지를 나열하는게 좋음.
ex) 상품 추가 테스트 x -> 상품을 추가하면 장바구니에 담긴다. O
@ServiceTest
OrderService
public OrderCreateResponse createdOrders(OrderCreateServiceRequest request, LocalDateTime registeredDateTime) {
List<String> orderNumbers = request.getOrderProductNumbers();
List<Product> collectsResult = findOrderProductNumbers(orderNumbers);
List<String> stockTypeProductList = collectsResult.stream()
.filter(items -> ProductType.containsStockType(items.getProductType()))
.map(Product::getProductNo)
.collect(Collectors.toList());
deductStockQuantity(stockTypeProductList);
Order order = Order.create(collectsResult, registeredDateTime);
Order savedOrder = orderRepository.save(order);
return OrderCreateResponse.of(savedOrder);
}
주문이 들어왔을 때 시간을 체크하고, 상품의 재고를 차감시키는 함수를 포함한 주문 생성 메소드를 만들었다.
파라미터로 주문하려는 상품 리스트를 받고, 현재 시간을 받는다.
*시간을 파라미터로 받지 않아도 된다고 생각할 수 있으나,
createOrders메소드 내에서 LocalDateTime.now()로 시간을 받아서 쓰면,
테스트 시 테스트하는 시간에 따라 영향을 받을 수 있다.
위 이유로 컨트롤러에서 시간을 찍고, 이를 서비스단에 파라미터로 넘겨준다.
OrderServiceTest
@Autowired
OrderService orderService;
@Autowired
ProductRepository productRepository;
@Autowired
OrderRepository orderRepository;
@Autowired
OrderProductRepository orderProductRepository;
@Autowired
StockRepository stockRepository;
@AfterEach
void tearDown() {
// productRepository.deleteAll();
orderProductRepository.deleteAllInBatch();
productRepository.deleteAllInBatch();
orderRepository.deleteAllInBatch();
stockRepository.deleteAllInBatch();
}
orderService를 when절에 넣고 결과를 테스트하기 위해 OrderService, OrderRepository 의존성을 주입받았고,
AfterEach 구문에 각 테스트간 데이터 클렌징을 위해 deleteAllInBatch를 사용했다.
*deleteAll을 사용할 수 있으나,
deleteAll을 사용할경우 연관관계에 매핑되어 있는 데이터들이 포함된 경우 레코드 하나씩 delete 하며 처리되기 때문에
쿼리 성능이 저하될 수 있다.
1.
@DisplayName("상품 번호를 받아서 주문을 생성한다.")
@Test
void createOrderTest() {
// given
LocalDateTime now = LocalDateTime.now();
Product product1 = createProduct("아메리카노", "001", SELLING, HANDMADE,2000);
Product product2 = createProduct("라떼", "002", STOP_SELLING, HANDMADE, 4000);
Product product3 = createProduct("팥빙수", "003", HOLD, BAKERY, 6000);
productRepository.saveAll(List.of(product1, product2, product3));
OrderCreateServiceRequest orderRequest = OrderCreateServiceRequest
.builder()
.orderProductNumbers(List.of("001", "003"))
.build();
Stock stock1 = Stock.create("001", 4);
Stock stock2 = Stock.create("002", 4);
Stock stock3 = Stock.create("003", 4);
stockRepository.saveAll(List.of(stock1, stock2, stock3));
// when
OrderCreateResponse orderResponse = orderService.createdOrders(orderRequest, now);
// then
assertThat(orderResponse.getId()).isNotNull();
assertThat(orderResponse)
.extracting("totalPrice", "registeredDateTime")
.contains(now, 8000);
assertThat(orderResponse.getProductResponses()).hasSize(2)
.extracting("name", "price", "sellingType")
.containsExactlyInAnyOrder(
tuple("아메리카노", 2000, SELLING),
tuple("팥빙수", 6000, HOLD)
);
}
1. given
주문할 상품들을 추가하여 저장하고, 각 상품들의 재고 또한 만들어 삽입한다.
2. when
주문할 상품 정보를 포함한 OrderRequest를 만들어 createOrders 함수를 호출하고 결과값을 가지고 온다.
3. then
정상 주문된 경우 조회가 되어야 하기 때문에,
3.1. isNotNull 테스트가 통과되어야 하고
3.2 조회된 결과값에 주문된 상품 정보가 정확하게 포함되어야 테스트가 통과된다.
2.
@DisplayName("재고와 관련된 상품이 포함되어 있는 주문번호를 받아서 주문으로 생성한다.")
@Test
void createOrderStockTest() {
// given
LocalDateTime now = LocalDateTime.now();
Product product1 = createProduct("아메리카노", "001", SELLING, BOTTLE,2000);
Product product2 = createProduct("라떼", "002", SELLING, BAKERY, 4000);
Product product3 = createProduct("팥빙수", "003", HOLD, HANDMADE, 6000);
productRepository.saveAll(List.of(product1, product2, product3));
Stock stock1 = Stock.create("001", 2);
Stock stock2 = Stock.create("002", 2);
stockRepository.saveAll(List.of(stock1, stock2));
OrderCreateServiceRequest orderRequest = OrderCreateServiceRequest
.builder()
.orderProductNumbers(List.of("001", "001", "002", "003"))
.build();
// when
OrderCreateResponse orderResponse = orderService.createdOrders(orderRequest, now);
// then
assertThat(orderResponse.getId()).isNotNull();
assertThat(orderResponse)
.extracting("totalPrice", "registeredDateTime")
.contains(now, 14000);
assertThat(orderResponse.getProductResponses()).hasSize(4)
.extracting("name", "price", "sellingType")
.containsExactlyInAnyOrder(
tuple("아메리카노", 2000, SELLING),
tuple("아메리카노", 2000, SELLING),
tuple("라떼", 4000, SELLING),
tuple("팥빙수", 6000, HOLD)
);
List<Stock> stocks = stockRepository.findAll();
assertThat(stocks).hasSize(2)
.extracting("productNo","quantity")
.containsExactlyInAnyOrder(
tuple("001",2),
tuple("002",2)
);
}
* 1번 테스트와 유사하나 재고가 정상 차감되었는지 확인한다.
3.
@DisplayName("재고가 없는 상품을 주문하면 예외가 발생한다.")
@Test
void createOrderNoStockTest() {
// given
LocalDateTime now = LocalDateTime.now();
Product product1 = createProduct("아메리카노", "001", SELLING, BOTTLE,2000);
Product product2 = createProduct("라떼", "002", SELLING, BAKERY, 4000);
Product product3 = createProduct("팥빙수", "003", HOLD, HANDMADE, 6000);
productRepository.saveAll(List.of(product1, product2, product3));
Stock stock1 = Stock.create("001", 0);
Stock stock2 = Stock.create("002", 0);
stockRepository.saveAll(List.of(stock1, stock2));
OrderCreateServiceRequest orderRequest = OrderCreateServiceRequest
.builder()
.orderProductNumbers(List.of("001", "001", "002", "003"))
.build();
// when // then
// OrderCreateResponse orderResponse = orderService.createdOrders(orderRequest, now);
assertThatThrownBy(() ->orderService.createdOrders(orderRequest, now))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("재고가 부족한 상품이 있습니다. 관리자에게 문의하세요.");
}
* 2번 테스트와 유사하나 재고가 없는경우 시스템에서 지정해둔 예외와 에러 메세지가 포함되어야 테스트가 통과된다.
@ControllerTest
OrderController
@RestController
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
@PostMapping("/api/v1/orders/new")
public ApiResponse<OrderCreateResponse> createOrders(@Valid @RequestBody OrderCreateRequest orderCreateRequest) {
LocalDateTime now = LocalDateTime.now();
return ApiResponse.ok(orderService.createdOrders(orderCreateRequest.toService(), now));
}
}
주문을 생성하기 위한 컨트롤러이다.
현재시간을 생성하여 서비스단에 파라미터로 넘겨준다.
OrderControllerTest
@WebMvcTest(controllers = {OrderController.class})
class OrderControllerTest{
@Autowired
MockMvc mockMvc;
@MockBean
OrderService orderService;
@Autowired
ObjectMapper om;
*MockMvc를 사용하기 위해 @WebMvcTest를 추가한다.
*톰캣 전체를 띄우지 않고 컨트롤러 관련 빈들만 띄울 수 있는 어노테이션이다.
** 사용할 컨트롤러를 정의하고, 컨트롤러 내에서 의존성을 받고 있는 클래스가 있다면 MockBean에 추가한다.
ex)
@RestController
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
OrderController는 OrderService 의존성을 주입받고 있기 때문에 MockBean에 등록함.
1.
@DisplayName("주문을 생성합니다.")
@Test
void createOrders() throws Exception {
// given
OrderCreateRequest request = OrderCreateRequest.builder()
.orderProductNumbers(List.of("001", "002"))
.build();
// when // then
mockMvc.perform(MockMvcRequestBuilders.post("/api/v1/orders/new")
.content(om.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.httpStatus").value("OK"))
.andExpect(jsonPath("$.statusCode").value("200"))
.andExpect(jsonPath("$.message").value("OK"))
;
}
1. given
요청될 바디값을 request객체로 만든다.
2. when, then
요청하고자 하는 path값과 http 메소드를 각각 기입하고,
바디값이 있을경우 content에 objectMapper로 추가한다.
요청 내용들 모니터링을 위한 print()와
통과해야할 기대값들을 각각 기입한다.
2.
@DisplayName("주문 생성 시 상품 번호는 필수입니다.")
@Test
void createOrdersWithoutProductNumbers() throws Exception {
// given
OrderCreateRequest request = OrderCreateRequest.builder()
.orderProductNumbers(List.of())
.build();
// when // then
mockMvc.perform(MockMvcRequestBuilders.post("/api/v1/orders/new")
.content(om.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.httpStatus").value("BAD_REQUEST"))
.andExpect(jsonPath("$.statusCode").value("400"))
.andExpect(jsonPath("$.message").value("상품 번호 리스트는 필수입니다."))
.andExpect(jsonPath("$.data").isEmpty())
;
}
1번과 유사하나 예측가능한 예외를 처리하여 결과를 기대값에 넣는다.
더 나은 테스트를 위한 조언
1. 완벽하게 제어하기
주문을 받는 구문에서 현재시간을 컨트롤러에서 서비서단에 파라미로 LocalDateTime 값을 넘겨주었다.

서비스단에서 LocalDateTime값을 부여해도 관계가 없으나,
현재 시간에 따라 영향을 받는 테스트를 할 경우,
서비스단에서 직접 시간을 꺼내서 입력하는 경우 시간대에 따라서 테스트가 통과하거나 실패할 수 있다.
즉 개발자가 테스트를 제어하지 못하는 것이다.
이런 경우때문에 LocalDateTime값을 컨트롤러 단에서 받도록 하여, 테스트 시

개발자의 제어 하에 경계값 테스트가 가능해진다.
2. 테스트 성능 개선

강의를 진행하며 전체 테스트를 돌렸을 때 스프링부트 톰캣이 총 7번 실행된것을 볼 수 있다.
7번은 실무와 비교했을 때 많은 테스트 케이스가 아니기 때문에 큰 성능 차이가 없을 수 있으나,
테스트가 100건 1000건이 넘었을 경우 각 건마다 톰캣이 실행된다면 전체 테스트를 하는데 시간이 많이 소요된다.
그래서 톰캣을 실행시켜야 하는 테스트들을 같은 환경으로 묶어서 한번에 테스트를 하도록 설정할 수 있다.

테스트 프로필과 @SpringBootTest 어노테이션을 포함하고 있는 추상클래스를 만들고
이를 상속시킨다.
class OrderStatisticsServiceTest extends IntegrationTestSupport

기존 7번 실행됐던게 2번으로 줄어든 것으로 확인된다.
총평
- 테스트코드 초심자가 봐도 이해하기 쉽고 중간중간 필요한 내용이나 키워드들을 공유해주는게 좋았다.
- 기본적인 틀만 알고 있던 테스트코드들을 레이어별로 분류를 하여 이해하기 쉬웠다.
- 라이브코딩을 하며 테스트코드를 작성했지만 역시 직접 적용해봐야 늘 것 같다.
- 프로덕션 코드에 입력하는 것보다 테스트 코드를 입력하는게 훨씬 피드백이 빠르고 개선에 유리하다고 체감했다.
- 강의 내용이 어렵지않아 매일 2시간 정도 본것같다.
'공부 > 강의 수강' 카테고리의 다른 글
| 도커 이미지와 컨테이너 (쉬운 도커2) (0) | 2025.04.03 |
|---|---|
| 가상화 기술을 사용하는 이유 (쉬운 도커1) (0) | 2025.03.15 |
| 실습으로 배우는 선착순 이벤트 시스템 강의를 마치며 (6) | 2024.10.01 |