레벨 2가 되어 스프링 프레임워크로 미션을 진행하게 되었습니다.
DB 사용하는 테스트를 작성해야 했는데요, 이런 리뷰를 받게 되었습니다.
@DirtiesContext를 사용하셨군요.
프로젝트가 커지면 어떤 문제가 있을 수 있나요?
테스트 격리를 위해 @DirtiestContext를 사용하는 것이 어떤 문제가 있을까요?
문제점을 알아보고, 더 좋은 방안은 없는지 알아보겠습니다.
@DirtiestContext
우선, @DirtiestContext이 어떤 역할을 하는지 알아보겠습니다.
Test annotation which indicates that the ApplicationContext associated with a test is
dirty and should therefore be closed and removed from the context cache.
공식 문서에 의하면,
테스트에서 더럽혀진 ApplicationContext를 컨텍스트 캐시에서 닫고 제거함을 나타내는 애너테이션입니다.
Use this annotation if a test has modified the context — for example, by modifying the state of a singleton bean, modifying the state of an embedded database, etc. Subsequent tests that request the same context will be supplied a new context.
여기서 더럽혀졌다는 것은 싱글톤 빈이나, 임베디드 DB의 상태가 변경되어 컨텍스트가 수정된 경우를 의미합니다.
즉, 매번 새로운 컨텍스트를 새롭게 생성하여 dirty 상태를 제거해 줍니다.
테스트 케이스마다 새롭게 스프링을 띄워주는 것을 확인할 수 있습니다.
Context caching?
여기서 우리는 한 가지를 눈치챌 수 있습니다. 아하 테스트에서 스프링을 매번 띄우지 않구나!
Once the TestContext framework loads an ApplicationContext (or WebApplicationContext) for a test, that context is cached and reused for all subsequent tests that declare the same unique context configuration within the same test suite.
공식 문서에 의하면,
테스트에서 ApplicationContext를 로드하면 컨텍스트가 캐싱되고, 같은 test suite 내에서 컨텍스트를 재사용할 수 있습니다.
덕분에 스프링 생성 비용을 줄이고 실행 속도가 빨라지는 것입니다.
@DirtiestContext의 단점
단, Context caching으로 인해 같은 컨텍스트 내 DB를 공유하게 된다면, DB를 사용하는 테스트는 독립적으로 실행될 수 없을 것입니다.
따라서, 테스트 격리 문제를 위해 @DirtiestContext를 사용했던 것입니다.
다시 원래의 질문으로 돌아가봅시다.
@DirtiestContext 애너테이션에 어떤 문제가 있을까요?
바로, 실행 속도가 느리다는 점입니다.
대체재
@DirtiesContext를 사용하여 Service layer를 테스트한 코드입니다.
우리는 DB 상태를 변경하지 않고 테스트하고 싶었을 뿐입니다.
이를 위해 스프링까지 다시 띄워야 할까요?
매번 컨텍스트를 띄우지 않고, 테스트를 격리할 수 있는 방법은 없을까요?
Test Double
테스트 목적으로, 프로덕션 객체를 교체하는 모든 경우를 통칭하는 용어입니다. (어려운 촬영을 대신해주는 스턴트 더블 stunt double에서 파생되었다고 합니다.)
Test Double 5가지 방법
Test Double 방법에 대해 개념만 간단히 짚고 넘어가겠습니다.
- Dummy
코드가 컴파일될 수 있도록, 파라미터를 채우기 위해 더미 객체를 만드는 방법입니다.
실제 동작하지 않아도 테스트에 영향이 없는 경우 사용될 수 있습니다. - Fake
실제와 동일한 동작을 구현하고 있지만, 지름길(short-cut)을 통해 훨씬 간단하게 구현하는 방법입니다. 단, 프로덕션 코드에 대체될 수는 없습니다.
테스트에는 in-memory DB를 사용하고 프로덕션 코드에는 실제 DB를 사용하는 방법이 대표적입니다.
실제 객체를 아직 사용할 수 없거나, 동작이 복잡하고 느린 경우 fake 객체를 사용할 수 있습니다. - Stub
객체를 하드코딩하여 특정 메서드가 호출될 때마다 미리 준비된 응답을 제공하는 방법입니다.
Mockito 프레임워크에서 when/thenReturn, doReturn/when 구문에서 사용하는 객체가 stub입니다. - Spy
stub과 유사하지만, 일부 정보를 저장할 수 있는 방법입니다.
자주 인용되는 예로, 메일 서비스가 있습니다. 객체에서 전송한 메시지 수를 기록하여 관련 메서드 호출 시 응답할 수 있습니다. - Mock
미리 프로그래밍되어 예상된 응답을 전달하는 방법입니다. stub과 유사한 방식이지만, 검증 목적에 차이가 있습니다. stub은 상태 검증 시, mock은 행위 검증 시 사용됩니다.
여러 구현 방법 중, Mock으로 테스트를 격리해 보겠습니다.
Service layer를 테스트하기 위해 DAO 객체를 mocking 했습니다.
BDDMockito 프레임워크를 사용하여 reservationDao.findAll() 메서드가 호출될 때 반환 값으로 예상된 List를 반환하도록 했습니다.
Mock을 사용한 테스트는 약 1초가 걸렸고 DirtiesContext를 사용한 테스트는 약 2초가 걸렸습니다.
테스트 실행 속도에 큰 차이가 있음을 알 수 있습니다.
그러나 mock을 사용한 테스트는 스프링을 띄우지 않습니다. 따라서 실제 DB 동작을 검증할 수 없습니다.
mock 테스트는 하나의 레이어만 독립적으로 검증하기 위한 슬라이스 테스트입니다.
리뷰어님의 말을 인용하자면,
실제 Bean을 활용한 통합테스트가 아니라 위에서 작성하신 것처럼 Mocking테스트를 진행한다면 서비스를 테스트하는 것이 별로 의미가 없을 것 같아요.
Mock은 잘 활용하면 테스트하기 어려운 부분에 대한 테스트를 원활하게 만들어주지만, 잘못 사용하면 테스트에서 반드시 검증되어야 할 것들을 놓치게 만들기도 하고, 의존성이나 도메인 규칙이 잘 분리된 것 같다는 잘못된 환상을 심어주기도 해요.
우리의 목적은 실제 컨텍스트를 띄워 DB를 테스트하기 위한 DirtiesContext 애너테이션의 대체재를 찾는 것입니다.
DB에 데이터를 저장하지 않게 유지할 수 있는 방법이 필요합니다.
deleteAll()
구현된 deleteAll() 메서드를 호출하여 저장된 데이터를 제거하는 방법입니다.
JUnit의 @BeforeEach, @AfterEach 애너테이션과 함께 사용하여 매 테스트 실행 전과 후에 데이터를 초기화할 수 있습니다.
단, 테이블 간 연관 관계가 설정된 경우 데이터가 삭제되지 않을 수 있습니다.
또한 auto-increment로 설정된 경우 해당 칼럼이 초기화되지 않습니다.
@Sql
구현된 메서드가 아닌 SQL 문으로 데이터를 삭제하는 방법입니다.
@Sql 애너테이션으로 SQL 스크립트를 실행시켜, 매번 테이블 데이터를 삭제할 수 있습니다.
class와 method 레벨에 애너테이션을 붙일 수 있고, 각 테스트 수행 전 실행되는 것이 기본값입니다.
테이블을 drop 하고 create 하는 스크립트를 작성했습니다.
테이블 자체를 완전히 삭제하고, 다시 생성합니다. auto-increment 설정한 칼럼도 초기화되어 1부터 시작하게 됩니다.
drop 대신 truncate 쿼리를 사용할 수도 있습니다.
단, truncate 사용 시 테이블 간 외래키(FK)가 걸려 있다면 제약 조건을 해제했다가 재설정해주어야 합니다.
또한, auto-increment 칼럼은 초기화되지 않으므로 해당 칼럼도 초기화해 주는 쿼리를 추가했습니다.
테스트마다 컨텍스트가 생성되지 않았고, 테스트 실행에는 650ms가 걸리는 것을 확인할 수 있습니다.
단, 테이블을 삭제하는 방법은 실제 DB와 테스트 DB를 분리하는 작업이 선행되어야 합니다.
@Transactional
그러나, @Transactional 애너테이션을 사용하면 안 될까? 하는 의문이 생깁니다.
테스트 코드에서 @Transactional 애너테이션을 사용하면 DB를 분리하지 않고도 테스트 격리가 가능합니다.
그러나, SpringBootTest에서 port를 RANDOM_PORT, DEFINED_PORT로 지정하게 되면 DB rollback이 되지 않습니다. 서로 다른 스레드에서 동작하기 때문입니다.
따라서, port를 지정하는 경우 해당 방법을 사용하지 못합니다.
이밖에도 @Transactional 애너테이션은 여러 단점이 존재합니다.
- 실제 서버의 동작 방식과 다르다
- public 메서드에만 한정되어 동작한다
- auto-increment 시에 index가 증가한다
실제로도, 리뷰어 님께서 실무에서 해당 애너테이션 방식을 테스트 코드에 사용하지 않는다고 합니다.
Transactional에 대해서도 더 공부해 보고 글로 남겨보겠습니다.
정리
SpringBootTest에서 테스트를 격리하는 방법
- @DirtiesContext : 매번 새로운 컨텍스트를 실행. 속도가 매우 느려 되도록 사용을 지양하자
- Test Double : 대체 객체를 만드는 방식. 통합 테스트, 인수 테스트의 목적과는 부합하지 않는다
- deleteAll() : 구현된 deleteAll 메서드를 호출하여 데이터 초기화 가능
- @Sql : drop 혹은 truncate 쿼리문을 통해 데이터 초기화 가능
- @Transactional : port를 RANDOM_PORT, DEFINED_PORT로 지정한 경우 사용 불가
deleteAll() 혹은 @Sql 애너테이션을 사용한 방식을 사용하는 방식을 주로 사용하게 될 것 같다
참고
- https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/test/annotation/DirtiesContext.html
- https://docs.spring.io/spring-framework/reference/testing/testcontext-framework/ctx-management/caching.html
- https://tecoble.techcourse.co.kr/post/2020-09-15-test-isolation/
- http://xunitpatterns.com/Test Double.html
- https://martinfowler.com/bliki/TestDouble.html
- https://medium.com/@kashwin95kumar/what-do-you-mean-by-test-doubles-b57a2a792973
-
'Spring' 카테고리의 다른 글
👣 로그 수집과 모니터링 구축기 (2) | 2024.11.20 |
---|---|
코드로 더미 데이터를 추가해보자 (21) | 2024.05.27 |
Controller는 어떻게 테스트 할까?(feat. RestAssured vs MockMvc) (1) | 2024.05.20 |
@Component와 @Repository는 무엇이 다른가요? (49) | 2024.04.22 |