이전 페이지에서 계층형 아키텍처에 대해 살펴보았다. 앞으로는 3계층 아키텍처의 계층들을 어떻게 테스트 하는지 알아보자.
첫 번째로 퍼시스턴스 레이어를 대상으로 한 Repository 테스트 방법에 대해 살펴본다. 그리고 퍼시스턴스 계층이 데이터 접근이라는 관심사에 대해 분리가 되어 있는지, 다른 계층에 대해 결합도가 낮은지, 테스트가 용이한지 같이 살펴보자.
(참고: Builder Pattern, Intellij 의 live template 설정 방법)
3.1. 기본적인 Repository Test
상품 도메인, 상품 판매 상태 Enum, 그리고 상품을 조회하는 repository를 작성하였고, 그에 대한 테스트 코드이다.
3.1.1. 원하는 판매상태를 가진 상품들을 조회한다
3.1.2. 상품번호 리스트를 가진 상품들을 조회한다
3.1.3. 가장 마지막으로 저장한 상품의 상품번호를 조회한다
package simple.testcode.product.domain;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "product")
public class ProductEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String productNumber;
@Enumerated(EnumType.STRING)
private ProductSellingStatus sellingStatus;
private String name;
private int price;
public ProductEntity(String productNumber, ProductSellingStatus sellingStatus, String name, int price) {
this.productNumber = productNumber;
this.sellingStatus = sellingStatus;
this.name = name;
this.price = price;
}
}
상품 판매 상태는 (판매중 | 판매보류 | 판매중지) 3가지 값을 사용한다.
package simple.testcode.product.domain;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.List;
@Getter
@RequiredArgsConstructor
public enum ProductSellingStatus {
SELLING("판매중"),
HOLD("판매보류"),
STOP("판매중지");
private final String text;
}
상품 조회는 (판매상태에 따라 | 상품 번호에 따라 ) 조회한다.
추후 신규 상품 생성 등과 같은 로직을 위해 마지막 상품번호 조회 질의도 작성하였다.
package simple.testcode.product.dao;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import simple.testcode.product.domain.ProductEntity;
import simple.testcode.product.domain.ProductSellingStatus;
import java.util.List;
@Repository
public interface ProductRepository extends JpaRepository<ProductEntity, Long> {
/**
* select *
* from product
* where selling_status in ('SELLING', 'HOLD');
*/
List<ProductEntity> findAllBySellingStatusIn(List<ProductSellingStatus> sellingStatus);
List<ProductEntity> findAllByProductNumberIn(List<String> productNumbers);
@Query(nativeQuery = true, value = "select p.product_number from product p order by p.id desc limit 1")
String findLatestProductNumber();
}
repository 에 작성된 질의 조건에 따라 테스트를 작성하였다.
package simple.testcode.product.dao;
import org.assertj.core.groups.Tuple;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import simple.testcode.product.domain.ProductEntity;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static simple.testcode.product.domain.ProductSellingStatus.*;
@SpringBootTest
class ProductRepositoryTest {
@Autowired
ProductRepository productRepository;
@DisplayName("3_1_1_원하는 판매상태를 가진 상품들을 조회한다.")
@Test
void selectQueryCheck() {
// given
ProductEntity productEntityA = new ProductEntity("001", SELLING, "상품-A", 1000);
ProductEntity productEntityB = new ProductEntity("002", HOLD, "상품-B", 2000);
ProductEntity productEntityC = new ProductEntity("003", STOP, "상품-C", 5500);
productRepository.saveAll(List.of(productEntityA, productEntityB, productEntityC));
// when
List<ProductEntity> productEntityList = productRepository.findAllBySellingStatusIn(List.of(SELLING, HOLD));
// then (AssertJ import)
assertThat(productEntityList).hasSize(2)
.extracting("productNumber", "sellingStatus", "name", "price")
.containsExactlyInAnyOrder(
Tuple.tuple("001", SELLING, "상품-A", 1000),
Tuple.tuple("002", HOLD, "상품-B", 2000)
);
}
@DisplayName("3_1_2_상품번호 리스트를 가진 상품들을 조회한다.")
@Test
void findAllByProductNumberIn() {
// given
ProductEntity product1 = new ProductEntity("021", SELLING, "아메리카노", 4000);
ProductEntity product2 = new ProductEntity("302", SELLING, "라뗴", 1000);
ProductEntity product3 = new ProductEntity("003", SELLING, "BB", 3500);
productRepository.saveAll(List.of(product1, product2, product3));
// when
List<ProductEntity> products = productRepository.findAllByProductNumberIn(List.of("302", "003"));
// then
assertThat(products).hasSize(2)
.extracting("productNumber", "name", "sellingStatus")
.containsExactlyInAnyOrder(
tuple("003", "BB", SELLING),
tuple("302", "라뗴", SELLING)
);
}
@DisplayName("3_1_3_가장 마지막으로 저장한 상품의 상품번호를 조회한다.")
@Test
void findLatestProductNumber() {
// given
String targetProductNumber = "003";
ProductEntity product1 = new ProductEntity("000", SELLING, "아메리카노", 4000);
ProductEntity product2 = new ProductEntity("001", SELLING, "라뗴", 1000);
ProductEntity product3 = new ProductEntity(targetProductNumber, SELLING, "BB", 3500);
// 순차적으로 저장 될 것
productRepository.saveAll(List.of(product1, product2, product3));
// when
String latestProductNumber = productRepository.findLatestProductNumber();
// then
assertThat(latestProductNumber).isEqualTo(targetProductNumber);
}
}
각 테스트 별로 테스트 실행하였고 테스트별 로그가 IDE 콘솔화면에 출력이 된다. 앞으로는 프로젝트의 ‘test-log’ 디렉토리에 하위에 테스트 이름별, 상황별로 로그를 직접 저장해두었다.
3.1.4. 로그 확인
테스트 코드는 내가 현재 어떤 로직을 구현하고 있고 어떻게 작동되고 결과를 출력하는지 확인할 수 있게 도와주어 빠른 피드백을 받을 수 있다. 테스트 코드의 피드백을 더욱 효과적으로 활용하기 위해 로그를 확인하는 습관을 기르자.
@DisplayName("3_1_1_원하는 판매상태를 가진 상품들을 조회한다.")
@Test
void selectQueryCheck() {
// given
ProductEntity productEntityA = new ProductEntity("001", SELLING, "상품-A", 1000);
ProductEntity productEntityB = new ProductEntity("002", HOLD, "상품-B", 2000);
ProductEntity productEntityC = new ProductEntity("003", STOP, "상품-C", 5500);
productRepository.saveAll(List.of(productEntityA, productEntityB, productEntityC));
// when
List<ProductEntity> productEntityList = productRepository.findAllBySellingStatusIn(List.of(SELLING, HOLD));
// then (AssertJ import)
assertThat(productEntityList).hasSize(2)
.extracting("productNumber", "sellingStatus", "name", "price")
.containsExactlyInAnyOrder(
Tuple.tuple("001", SELLING, "상품-A", 1000),
Tuple.tuple("002", HOLD, "상품-B", 2000)
);
}
.....
.....
2023-09-10 18:06:42.455 DEBUG 1738 --- [ main] org.hibernate.SQL :
insert
into
product
(id, name, price, product_number, selling_status)
values
(default, ?, ?, ?, ?)
2023-09-10 18:06:42.459 TRACE 1738 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [VARCHAR] - [상품-A]
2023-09-10 18:06:42.459 TRACE 1738 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [2] as [INTEGER] - [1000]
2023-09-10 18:06:42.459 TRACE 1738 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [3] as [VARCHAR] - [001]
2023-09-10 18:06:42.459 TRACE 1738 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [4] as [VARCHAR] - [SELLING]
2023-09-10 18:06:42.471 DEBUG 1738 --- [ main] org.hibernate.SQL :
insert
into
product
(id, name, price, product_number, selling_status)
values
(default, ?, ?, ?, ?)
2023-09-10 18:06:42.471 TRACE 1738 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [VARCHAR] - [상품-B]
2023-09-10 18:06:42.471 TRACE 1738 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [2] as [INTEGER] - [2000]
2023-09-10 18:06:42.471 TRACE 1738 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [3] as [VARCHAR] - [002]
2023-09-10 18:06:42.471 TRACE 1738 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [4] as [VARCHAR] - [HOLD]
2023-09-10 18:06:42.472 DEBUG 1738 --- [ main] org.hibernate.SQL :
insert
into
product
(id, name, price, product_number, selling_status)
values
(default, ?, ?, ?, ?)
2023-09-10 18:06:42.472 TRACE 1738 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [VARCHAR] - [상품-C]
2023-09-10 18:06:42.472 TRACE 1738 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [2] as [INTEGER] - [5500]
2023-09-10 18:06:42.472 TRACE 1738 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [3] as [VARCHAR] - [003]
2023-09-10 18:06:42.472 TRACE 1738 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [4] as [VARCHAR] - [STOP]
2023-09-10 18:06:42.543 DEBUG 1738 --- [ main] org.hibernate.SQL :
select
productent0_.id as id1_0_,
productent0_.name as name2_0_,
productent0_.price as price3_0_,
productent0_.product_number as product_4_0_,
productent0_.selling_status as selling_5_0_
from
product productent0_
where
productent0_.selling_status in (
? , ?
)
2023-09-10 18:06:42.544 TRACE 1738 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [VARCHAR] - [SELLING]
2023-09-10 18:06:42.544 TRACE 1738 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [2] as [VARCHAR] - [HOLD]
2023-09-10 18:06:42.546 TRACE 1738 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([id1_0_] : [BIGINT]) - [1]
2023-09-10 18:06:42.548 TRACE 1738 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([name2_0_] : [VARCHAR]) - [상품-A]
2023-09-10 18:06:42.548 TRACE 1738 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([price3_0_] : [INTEGER]) - [1000]
2023-09-10 18:06:42.548 TRACE 1738 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([product_4_0_] : [VARCHAR]) - [001]
2023-09-10 18:06:42.548 TRACE 1738 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([selling_5_0_] : [VARCHAR]) - [SELLING]
2023-09-10 18:06:42.548 TRACE 1738 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([id1_0_] : [BIGINT]) - [2]
2023-09-10 18:06:42.548 TRACE 1738 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([name2_0_] : [VARCHAR]) - [상품-B]
2023-09-10 18:06:42.549 TRACE 1738 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([price3_0_] : [INTEGER]) - [2000]
2023-09-10 18:06:42.549 TRACE 1738 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([product_4_0_] : [VARCHAR]) - [002]
2023-09-10 18:06:42.549 TRACE 1738 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([selling_5_0_] : [VARCHAR]) - [HOLD]
테스트 코드와 테스트의 로그이다.
작성한 코드에서 어떤 쿼리가 몇 번 질의를 하였는지 확인 할 수 있다.
Spring Data JPA 등 많은 프레임워크와 라이브러리를 사용하면서 쿼리가 어떻게 작성되는지,질의가 어떻게 몇 번이 나가는지 파악하기 힘들 때가 많다고 생각한다. 이러한 부분을 Persistence Layer 테스트 방식을 활용한다면, 작성한 코드의 Query 를 정확히 파악하는 데에 도움이 될거라 보인다.
샘플 테스트 코드에서는 insert 3 회와 select 1회로, 총 4번 DB에 접근 및 질의하였다.
3.2. 전체 테스트 진행시 오류와 테스트 Annotation 분리 사용
위에서는 단위 테스트 별로 JUnit 테스트를 실행하였다. 테스트 클래스에 있는 다중 단위 테스트를 실행하면 어떻게 될까? 코드와 데이터에 따라서는 필연적으로 실패할 수 있다. 개별적인 다테스트는 통과하고 통합적인 테스트에서는 왜 실패하는 걸까?
3.2.1. 다중 단위 테스트 실행시 주의점
먼저 실행된 테스트에 의해 데이터가 테스트 본인이 when 에서 지정한 데이터보다 더 많이(혹은 예상과 다른 형태로) 있는 상황에서 테스트가 진행 될수 있기 때문이다. 즉, 테스트 간 데이터가 롤백되지 않아, 서로 간의 데이터에 영향을 주어서 테스트 결과가 보장되지 않는다.
java.lang.AssertionError:
Expected size: 2 but was: 3 in:
test-log/3_2_1_ProductRepositoryTest 전체 테스트 결과_실패 파일의 일부 내용이다. 115, 116 줄에 내용을 보면 기대한 2개 결과와는 달리 3개의 데이터가 조회되었다고 기록되어 있다.
(참고: 클래스 내의 테스트를 실행할 때, 어떤 순서로 테스트가 실행될지 기본적으로 보장되어 있지않고, 내부 로직에 따라 순서가 결정되어 수행된다. 순서관련 설정을 추가해주면 개발자가 지정한 순서가 보장된다.)
테스트간 독립적인 데이터를 사용하기 위해서 2 가지 해결안이 있다.
3.2.2. @Transactional 이용한 롤백
1aedf64d 3. Persistence Layer 테스트 작성 - 2.2. 전체 테스트 진행 그리고 테스트 성공
@Transactional애노테이션을 사용하여 테스트에서의 Transaction을 강제로 롤백하도록 설정할 수 있다. 애노테이션 설정 후 테스트를 실행하면, 트랜잭션이 시작됨을 알 수 있는 로그를 확인 할 수 있다.
o.s.t.c.transaction.TransactionContext : Began transaction (1) for test context …
다중 테스트 진행 중 각 테스트가 끝날 때 마다 테스트를 위해 롤백이 진행되었음을 확인 할 수 있다.
o.s.t.c.transaction.TransactionContext : Rolled back transaction for test: [DefaultTestContext…
3.2.3. 수동으로 데이터 롤백하기
테스트 클래스에서 사용한 repository 들의 데이터를 개발자가 직접적으로 삭제 등을 통해 롤백 해준다.
...
@AfterEach
void tearDown() {
// repo 삭제 순서 유의
productRepository.deleteAllInBatch();
}
...
@Transactional애노테이션을 사용할 때는 EntityManager, TransactionContext 등 내부적으로 관련 내용들이 많고, 영향받는 로직의 범위가 크므로, 무분별하게 사용하면 안된다. 또한 수동으로 데이터 롤백시에는 사용자가 제어해야 하므로 데이터 누락, 데이터 제어 순서 등 실수를 주의해야 한다.
3.2.4. @DataJpaTest 와 단위 테스트를 위한 Spring Boot Test 의 Annotation
Spring Boot Test에서 제공하는 애노테이션 중 SpringExtension 확장하여 테스트 클래스에서 사용하는 3가지 애노테이션을 소개한다.
Annotation 종류설명사용 설명사용 계층
@SpringBootTest | ApplicationContext를 만들 때, 모든 Bean 들을 스캔하여 등록 Spring Boot가 제공해주는 기능을 제공 |
통합 테스트에서 사용 2개 이상의 계층을 넘나들며 테스트를 진행하는 통합 테스트 등에 적합 |
전체 계층 |
@WebMvcTest | Controller와 연관된 Bean들만 찾아서 제한적으로 등록(좀 더 가볍게 테스트 가능) 내장된 서블릿 컨테이너가 랜덤 포트로 실행 |
컨트롤러가 예상대로 동작하는지 테스트하는데 사용 Controller Test 주로 Mockito library 와 함께 단위테스트로 사용 |
Presentation 계층 단위(부분) 테스트 |
@DataJpaTest | JPA와 연관된 Bean들만 찾아서 제한적으로 등록 (좀 더 가볍게 테스트 가능) @Transactional애노테이션을 포함 |
JPA Repository 와 관련된 테스트를 하는데 사용 |
Persistence 계층 JPA Repository Test 단위(부분) 테스트 |
주의할 점은 @DataJpaTest 에는 @Transactional 이 포함되어 있어 테스트를 할 때 기본적으로 롤백이 된다.
앞서 Persistence Layer 전체 테스트 진행시 발생 할 수 있는 오류 상황은 @DataJpaTest 를 사용해서 해결 할 수 있다.
앞으론 Persistence, Service, Presentation 단위 테스트, 통합테스트,E2E 테스트 등 테스트를 진행할 때, 상황에 맞는 Spring Boot 의 Test 애노테이션을 선택해서 사용하는걸 추천한다.
예로 Persistence 계층의 단위 테스트인 경우 @DataJpaTest 로 가볍게 테스트 할 수 있으며, 뒤편에서 나올 Service Layer + Persistence Layer 를 테스트하는 통합테스트, 전 계층을 통과하는 E2E 테스트에서는 @SpringBootTest 애노테이션이 적합할 것이다.
(참고: entity 에 생성자를 활성화 하여 사용하는 방식은 Anti Pattern (대표적으로 단일 책임 원칙 위배) 이다. 이를 해결하기 위해 Builder Pattern 을 활용한다.)
https://dzone.com/articles/fixing-the-constructor-anti-pattern
ProductEntity.java
public ProductEntity(String productNumber, ProductSellingStatus sellingStatus, String name, int price) {
this.productNumber = productNumber;
this.sellingStatus = sellingStatus;
this.name = name;
this.price = price;
}
위와 같은 생성자를 아래와 같은 Builder 패턴으로 수정하였다.
@Builder // lombok.Builder
private ProductEntity(String productNumber, ProductSellingStatus sellingStatus, String name, int price) {
this.productNumber = productNumber;
this.sellingStatus = sellingStatus;
this.name = name;
this.price = price;
}
ProductRepository.java 에서 Builder 패턴을 사용하였다.
private static ProductEntity createProduct(String productNumber, ProductSellingStatus sellingStatus, String name, int price) {
return ProductEntity.builder()
.productNumber(productNumber)
.sellingStatus(sellingStatus)
.name(name)
.price(price)
.build();
}
참조
- 테스트 케이스 작성 고수 되는 마법: Given, When, Then (http://martinfowler.com/bliki/GivenWhenThen.html ) 이 방법이 필수는 아니지만 이 방법을 기본으로 해서 다양하게 응용하는 것을 권장한다.
'테스트코드 > 작성방법' 카테고리의 다른 글
6. Presentation Layer Test (1) | 2024.03.26 |
---|---|
5. Business Layer Test (1) | 2024.03.26 |
4. 외부 시스템과의 연계 테스트 (1) | 2024.03.26 |
2. Layered Architecture (0) | 2024.03.26 |
1. Basic Unit Test (0) | 2024.03.26 |