🔥현재 NEXT STEP 에서 진행하는 ATDD 강의를 수강하고 있습니다.
강의에서 예전부터 고민하던 통합테스트 환경에서 데이터베이스를 격리하는 방법에 대해 알게되어 기록을 남겨보고자 합니다.
[예전에 시도해봤던 방법]
통합테스트 환경에서 시도한건 아니지만 단위테스트에서 JPA 에 의존하는 Repository 를 온전히 격리해 어떤상황에서든 성공하는 단위테스 환경을 구축하고자 했던적이 있습니다. 👉 service 단위 테스트 하기 - DB 와 독립된 테스트 환경 구축 (service unit test)
하지만
1. 단위테스트 환경에서는 사실상 Mocking 을 하는것보다 큰 장점이 없었고
2. 필요로하는 Repsitory 기능을 매번 정의해주어야해서 많이 번거로웠습니다.
👏🏻 그치만 통합테스트 환경에서는 실제 DataBase에 의존하지 않을 수 있으니 어느정도 의미가 있는 행위가 되겠죠?
(그치만 여전히 복잡하고 번거로운 것은 사실입니다)
[그 외에 테스트 격리 방법]
이번글에서 정리해볼 내용이고 "테스트환경에서의 H2 메모리 데이터베이스를 사용" 한다는걸 가정으로 합니다.
- @SpringBootTest 시 각 테스트마다 컨텍스트 새로올리기
- 각 테스트가 시작할때마다 데이터 지우기
[목차]
- 테스트를 격리해야하는 이유
- @SpringBootTest 시 각 테스트마다 컨텍스트 새로올리기
- 테스트가 시작할 때 마다 데이터 지우기 (with. Truncate)
→ 요약 : EntityManager 를 사용한 DB Truncate 추천
레츠고 !
1. 🫡 테스트를 격리해야하는 이유
먼저 테스트환경에서 각각의 테스트를 왜 번거롭게 격리해야하는지 살펴봅니다
넥스트스텝의 ATDD 과정은 인수테스트(어떤 관점에서는 통합테스트라고 볼 수도 있음)를 E2E 테스트로 진행합니다.
그렇기때문에 @SpringBootTest를 사용하여 컨텍스트를 로드하고 1개의 메모리 디비 (H2)를 컨텍스트에서 공유하기 때문에 각 테스트가 서로 다른 테스트에 영향을 주게되어
아래처럼 통합테스트 (인수테스트)가 다른 테스트에 의해 실패하게됩니다!
- ex) 삭제 테스트 A 에서 생성 → 삭제 → 조회 시 0개의 데이터가 나와야하는데
- 다른 생성테스트 B에서 생성했던 데이터가 있다면 디비를 공유하기 때문에 1개의 데이터가 조회됩니다.
✔️ E2E 테스트에서 @Transactional 을 사용하면 안되는 이유
이게 쫌 야무진데, "간단하게 @Transactional을 사용해서 데이터를 롤백시키면 되는거 아닌가?" 라는 생각을 했었지만 결과적으로 아니였습니다.
@Transactional 이 안되는 이유를 찾아보니
- @SpringBootTest 에서 실행되는 각 메소드의 스레드와 → RestAssured 로 호출하는 프로덕션 Controller 메소드의 스레드는 별개의 스레드를 사용하기 때문이었습니다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@Transactional
class TransactionalTest {
@DisplayName("지하철 노선을 생성한다.")
@Test
void createLine() {
System.out.println(" !!!!!!! 테스트 스레드 : " + Thread.currentThread());
//given
Map<String, Object> params = 어떤어떤 파라미터;
//when
ExtractableResponse<Response> 노선등록응답값 = RestAssured
.given().log().all()
.body(params)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.when().post("/lines")
.then().log().all()
.extract();
//then
검증코드
}
}
//Controller
@PostMapping("/lines")
ResponseEntity<LineResponse> createStationLine(@RequestBody LineRequest request) {
System.out.println(" !!!!!!! 프러덕션 스레드 : " + Thread.currentThread());
LineResponse response = lineFacade.lineCreate(request);
return ResponseEntity
.status(HttpStatus.CREATED)
.body(response);
}
위와같이 각각의 메소드가 실행될 때의 스레드를 확인해보니
- 테스트 메소드는 Test worker, 5, main 이라는 스레드에서 동작
- 프로덕션 Controller 메소드의 스레드는 http-nio-8080-exec-3, 5, main 이라는 별도의 스레드에서 동작하는걸 확인할 수 있었습니다.
👏🏻 따라서 @Transactinal 을 적용한 End-to-End 테스트 환경에서의 롤백과정은 다음과 같습니다,
- 테스트 메서드가 스레드 A 에서 실행된다.
- 테스트 메서드 내의 코드에서 컨트롤러의 Post 메서드를 호출한다.
- 호출을 받은 컨트롤러 메서드는 스레드 B에서 실행된다.
- 테스트 메서드가 완료되면 롤백을 수행한다.
- 하지만 트랜잭션의 범위는 스레드 A 내로 한정되므로 스레드 B에는 아무런 영향을 끼치지 못한다.
@Transacnal 의 공식문서에서는 트랜잭션이 내부에서 시작된 스레드로 전파되지 않는다고 나와있습니다.
그렇기때문에 @Transacnal을 사용해서는 통합테스트환경을 격리시킬 수 없습니다.
2. @SpringBootTest 시 각 테스트마다 컨텍스트 새로올리기
어차피 테스트 환경을 격리시켜야한다면, 매번 테스트가 실행될 때 컨텍스트를 새롭게 올려 온전히 새로운 환경에서 돌리는 것도 하나의 방법입니다.
✔️ @DirtiesContext
- @DirtiesContext 어노테이션은 SpringBootTest 로 올라가는 컨텍스트 다시 로드시켜 캐시정보를 아예 삭제시킵니다.
@DirtesContext 원리
- @SpringBootTest 를 돌릴 때 처음에만 빈을 띄우고 캐싱해서 재사용하여 다음 테스트 케이스는 빠르게 실행이 가능하도록 합니다
- 이러한 빈을 재사용하는 조건 중 하나가 컨텍스트가 오염되지 않았을 때 (상태가 변경되지 않았을 떄) 인데
- @DirtiesContext 는 빈을 오염시켜 캐시기능을 사용하지 않도록하는 설정하는 역할을 수행합니다.
@DirtiesContext 단점
- @DirtiesContext 를 사용하면 편리하지만 매번 새로운 Context를 구성하다보니 시간이 많이 걸리게 됩니다.
테스트 9개에 7초정도 걸리는데, 기존의 진행하던 사이드 프로젝트는 단위테스트만 작성하여 104개에 3초가 걸리지 않으니 굉장히 느린 테스트 시간임을 확인할 수 있습니다.
✨ 3. 테스트가 시작할 때 마다 데이터 지우기 (with. Truncate)
어차피 계속 문제가되는건 DB! DB를 격리시키자
@DirtiesContext는 편리하지만 굳이 매번 테스트마다 컨텍스트를 리로드할 필요가 없습니다. 중요한건 데이터의 격리이니까요
👏🏻 자 데이터의 격리 방법은 3가지가 있습니다.
- @Sql 혹은 쿼리 수행 (Truncate)
- EntityManager 를 사용 (Truncate)
- 테스트 컨테이너의 사용 (이건 안다룰거임 다음 블로그 참고 → [사내 TestContainer 적용] Spring boot 통합테스트 도입기 )
1. @Sql 혹은 쿼리 수행 (Truncate)
- @Sql 어노테이션을 사용해 미리정의해 놓은 쿼리문을 테스트 실행전에 날리는 방법입니다.
- @쿼리를 날려 디비를 클린한 상태로 만들어주기 때문에 각 데이터베이스는 서로 데이터를 침범하지 못합니다.
- @DirtiesContext보다 낮은 비용을 가지지만, 테이블이 늘어날 때 마다 관리해주어야하는 귀찮음이 존재합니다.
2. EntityManager 를 사용 (Truncate)
- 이게 야무집니다.
- 1번 방법의 관리포인트를 EntityManager 를 사용하여 알아서 찾도록 바꿨습니다.
AcceptanceTestConfig.class
- 전체 E2E 테스트에서 공통적인 부분을 추출했습니다.
- DataBaseCleanUp이라는 클래스의 excute 메소드를 매번 테스트 실행전에 수행합니다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
public abstract class AcceptanceTestConfig {
@Autowired
private DatabaseCleanup databaseCleanup;
@BeforeEach
public void setUp() {
databaseCleanup.execute();
}
}
DatabaseCleanUp.class
- EntityManager 를 사용해 테이블 이름을 추출한 후 trucate 시킵니다.
- truncate 쿼리 실행 후 auto-increament 되었던 id 값도 다시 시작점을 초기화 시킵니다.
@TestComponent
public class DatabaseCleanup implements InitializingBean {
@PersistenceContext
private EntityManager entityManager;
private List<String> tableNames;
@Override
public void afterPropertiesSet() {
tableNames = entityManager.getMetamodel().getEntities().stream()
.filter(entity -> entity.getJavaType().getAnnotation(Entity.class) != null)
.map(entity -> entity.getName())
.collect(Collectors.toList());
}
@Transactional
public void execute() {
entityManager.flush();
entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate();
for (String tableName : tableNames) {
entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
entityManager.createNativeQuery("ALTER TABLE " + tableName + " ALTER COLUMN ID RESTART WITH 1").executeUpdate();
}
entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate();
}
}
이렇게 수행했을 때 16개의 인수테스트가 3초대로 끊어졌습니다.
- 테스트 환경을 분리하였고
- 테이블이 늘어나도 관리포인트가 생기지 않고
- @DirtiesContext 보다 빠르다
통합테스트 데이터베이스 분리 환경 구성 끝!
'Spring > Test-Driven Develop' 카테고리의 다른 글
Spring Boot WebClient Mocking 하기 (4) | 2025.01.14 |
---|---|
[Spring] Embedded MongoDB! 통합테스트를 위한 인메모리 몽고디비 설정하기 (0) | 2023.11.06 |
[Spring Security] @AuthenticationPrincipal 유닛 테스트 - Custom Mock User 삽입하기 (0) | 2023.06.15 |
[Java] LocalDate.now() mocking - 정적 메소드 테스트 하기 (0) | 2023.01.27 |
[Spring] service 단위 테스트 하기 - DB 와 독립된 테스트 환경 구축 (service unit test) (0) | 2023.01.13 |