어느날, 회사에서 테스트코드를 만들고 있었다. 테스트에서는 DB, Kafka 등의 모든 외부 연결은 mocking하여 사용하고 있었다. 그러나 DB에서 json 타입의 필드를 저장한다던가, converter를 활용하여 DB에 저장되는 값을 변경하는 등의 작업이 많아지면서 외부연동, 특히 DB까지의 테스트가 필요하다고 느끼고 있었다. 그래서 DB까지 연동하여 테스트하는 방법과 연동하는 방법을 찾아보았고 이를 정리해보았다.
H2 Database
DB 테스트를 한다고 했을 때, spring에서는 가장 쉽게 도입할 수 있는 것이 h2 database이다.
- h2 database 설정하기
- 테스트 어플리케이션 설정
application-test.yaml 설정에서 datasource에 h2로 설정하고, driver를 h2.Driver 로 변경하면 h2 database를 사용할 수 있다.
spring: datasource: url: jdbc:h2:mem:mata;MODE=MySQL;DB_CLOSE_DELAY=-1 driver-class-name: org.h2.Driver ... - flyway 데이터 / 초기 데이터
spring boot는 기본적으로 CLASSPATH 에 존재하는 schema.sql , data.sql 파일을 읽어서 초기 데이터를 세팅한다.
위와 같은 설정이 필요하지만, h2에서는 기본값이 always 다.spring.sql.init.mode=always
물론 flyway 설정을 추가해서 초기 데이터 작업을 할 수 있다. 초기 데이터를 넣어서 테스트하는 방법은 여러가지가 있으니 [관련자료] 에서 확인할 수 있다.DROP TABLE IF EXISTS `user`; CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT, ... )
- 테스트 어플리케이션 설정
장점
- 설정하기 쉽다. 초기 데이터 설정도 하지 않을것이라면 test용 spring 설정 파일에 datasource만 h2로 넣으면 된다.
- in-memory database라서 추가로 고려해야할 점이 많지 않다. (docker 컨테이너를 띄우려면 docker engine이 실행되어 있어야하고, port도 설정해야하고..)
- 또한 h2 는 다양한 데이터베이스와의 호환성을 제공한다. [공식문서]
단점
- in-memory라서 많은 데이터를 넣으면 성능에 큰 영향을 미칠 수 있다. 테스트 환경에 따라 테스트가 정상 실행되지 않을 수도 있는 것이다.(경험상 이런적은 없긴 하다.)
- mysql과 100% 호환되지 않는다.
- 🚨 실제로 mysql과 collation이 달라서, 데이터 검색 시 대소문자 구분으로 인해 테스트는 실패하고 로컬서버에서는 성공하는 문제가 있었다.
In MySQL text columns are case insensitive by default, while in H2 they are case sensitive. However H2 supports case insensitive columns as well. To create the tables with case insensitive texts, append IGNORECASE=TRUE to the database URL (example: jdbc:h2:~/test;IGNORECASE=TRUE).
[공식문서]
TestContainter
H2 DB와 같이 테스트에서의 환경이 달라 발생하는 문제를 해결하기 위해 나온 오픈소스 프레임워크로, 테스트 환경에서 독립적으로 실행되는 컨테이너를 구축할 수 있다.
장점
- mysql 서버를 띄우는 것이기 때문에 운영 환경과 동일한 테스트 환경을 구축할 수 있다. mysql 버전 및 변수까지 동일하게 설정 가능하다.
- container를 띄우는 것이기 때문에 테스트별로 컨테이너를 실행하여 테스트 독립성을 높힐 수 있고, 여러 datasource 환경에서도 적용이 가능하다.
- database 뿐만 아니라 MQ 등 다양한 기능들이 존재한다. (컨테이너만 띄우면 됨)
- h2 보다는 추가 설정이 필요하지만, 단순한 설정으로도 실행이 가능하다.
- mysql 서버를 띄우면서 초기 데이터를 삽입하여 테스트를 더 편하게 할 수 있다.
단점
- 실행하는 머신에서 docker engine이 존재해야한다.
- github action 시 self-hosted-runner 를 사용하면, 해당 머신에 docker engine이 설치돼 있어야 한다.
- 로컬에서 실행하려면 docker engine을 켜야한다.
사용 방법
1. build.gradle에 의존성 추가
testImplementation platform('org.testcontainers:testcontainers-bom:1.19.1') // 선언하면 testcontainer 모듈들의 버전을 통합 관리해줌
testImplementation "org.testcontainers:testcontainers"
testImplementation "org.testcontainers:junit-jupiter"
testImplementation "org.testcontainers:mysql"
2. 테스트 어플리케이션 설정 추가
datasource와 driver를 원하는 database에 맞게 변경해준다.
mysql 외에 다른 DB는 [공식문서] 를 참고하면 된다.
spring:
datasource:
url: jdbc:tc:mysql:8.0.26:///user&TC_DAEMON=true&autoReconnect=true&serverTimezone=UTC&characterEncoding=utf8&useSSL=false
driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
...
3. 컨테이서 실행 시 초기 데이터 설정 추가
TC_INITSRIPT 에 contianer가 실행될 때 실행할 sql 파일을 넣어주면, MySQL 컨테이너가 실행되면서 해당 SQL 파일이 실행된다.
(mysql에서는 spring.sql.init.mode 설정 기본값이 never 이다. 따라서 schema.sql 파일로 인해 초기 데이터가 세팅되는 일은 설정을 추가하지 않는 한 없다. 따라서 schema.sql로 초기 세팅 SQL 파일을 만들었다.)
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user`
(
`id` int(11) NOT NULL AUTO_INCREMENT,
...
)
spring:
datasource:
url: jdbc:tc:mysql:8.0.26:///incident?TC_INITSCRIPT=file:src/test/resources/schema.sql&TC_DAEMON=true&autoReconnect=true&serverTimezone=UTC&characterEncoding=utf8&useSSL=false
driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
...
flyway 설정을 추가해서 테이블을 생성하는 것도 가능하다. 다만 테스트마다 flyway 파일들을 하나씩 실행해야 하므로 테스트 시간이 더 길어진다.
참고
- 테스트마다 새로운 컨테이너를 띄우는 것도 가능하다. [관련 자료]
- mysql 외에 redis, kafka 등 다양한 컨테이너 설정 예시들이 있으니, 공식문서에서 확인해보면 친절하게 설명해준다. [공식 문서]
여러 테스트 설정
이렇게 끝내려고 했지만, 내가 테스트할 때 설정하는 방법들을 공유하면 좋을 것 같아서, 여러 테스트를 위해 내가 부리는 기교들을 공유하려고 한다.
Integration 테스트하기
integration 테스트라고 하니 논란이 있을 수 있지만, 여기서 얘기하는 것은 spring application을 실행하고 이를 테스트하는 것을 뜻한다. 모든 테스트마다 @ActiveProfiles("test") , @SpringBootTest 를 붙히는건 귀찮으니, @IntegrationTest 라는 어노테이션을 만들고, spring을 띄워서 테스트할거야 라는 의미를 줄 수 있도록 만들었다.
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@ActiveProfiles("test")
@SpringBootTest
annotation class IntegrationTest
Redis 설정하기
RDB 뿐만 아니라, Redis 또한 많이 사용할 것이다. 마찬가지로 나도 session 방식 로그인을 위해 사용하고 있다. 이를 테스트하기 위해서는 redis pod를 띄워야하는데, mysql 처럼 application 설정 만으로 띄우기는 어렵다.
따라서 redis container를 정적 변수로 만들고, application context에 추가하여 redis.port 와 같은 설정값에 잘 적용되도록 설정하였다.
또한 redis 설정을 @IntegrationTest 어노테이션에 추가하여 해당 어노테이션 사용 시 자동으로 redis가 실행되도록 하였다.
class TestRedisConfiguration : ApplicationContextInitializer<ConfigurableApplicationContext> {
companion object {
val redisContainer = GenericContainer<Nothing>("redis:alpine")
.apply {
withExposedPorts(6379)
withReuse(true)
}
}
override fun initialize(applicationContext: ConfigurableApplicationContext) {
redisContainer.start()
TestPropertyValues.of(
"spring.redis.host=${redisContainer.host}",
"spring.redis.port=${redisContainer.firstMappedPort}"
).applyTo(applicationContext.environment)
}
}
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@ActiveProfiles("test")
@SpringBootTest
@ContextConfiguration(initializers = [TestRedisConfiguration::class])
annotation class IntegrationTest
Mock으로 객체 만들어서 Bean에 넣기
테스트를 하다보면 mock의 필요성을 많이 느끼게 될 것이다. 예를 들면, 외부 API 연동까지 테스트를 해야하나? 이걸로 인해 테스트까지 영향 받는 것이 맞나? 싶은 생각이 들 것이다. 이 때 mocking을 통해 쉽게 해결할 수 있다.
(개인적으로는 외부 연동까지 테스트코드를 통해 테스트 하는 것은 과하다고 생각한다.)
- mockkBean 사용하기
Mockito 환경에서 테스트 코드를 짜 본 사람이라면 @MockBean을 사용하면 되는데? 라고 생각할 것이다. 그러나 kotest와 mockk를 사용하는 코틀린 테스트 환경에서는 MockBean을 사용하면 정상 동작하지 않는다. mockk와 mockito가 충돌이 발생하여 함수가 잘 mocking 되지 않는다. 정확히는 mockito의 mockBean을 사용하면, 해당 객체의 설정은 mockito 기능을 사용해야한다.
두 라이브러리 모두 every 라는 함수를 통해 객체의 함수를 mocking하기 때문에, 각 객체가 어떤 라이브러리에 의해 mocking 되었는지를 찾아서 해당하는 라이브러리의 함수를 사용해야 된다는 의미이다.
그러나 이를 하나하나 비교하며 다른 기능을 쓰기에는 무리가 있다. 그래서 나는 어떤 사람이 만들어 놓은 mockk를 위한 mockbean을 사용한다.
mockk에서 정식으로 지원하는 라이브러리는 아니지만, 481 stars 를 받을 정도로 인기있는(?) 라이브러리이다. [Github]- gradle 의존성 추가
testImplementation("com.ninja-squad:springmockk:3.1.1") - @MockkBean 사용
@IntegrationTest @DisplayName("User Service Test") class UserServiceTest( private val userService: UserService, @MockkBean(relaxed = true) private val userRepository: UserRepository, ) : BehaviorSpec({ ... - mock 데이터 추가
every { userRepository.save(user) } returns user
- gradle 의존성 추가
- @Primary 사용하기
만약 위 방법이 마음에 들지 않는다면, test 환경에서 사용하기 위한 객체를 만들고 bean에 등록하면 된다. 그리고 @Primary 어노테이션을 활용하여, 해당 객체를 테스트 코드에서 주입받을 수 있도록 설정한다.
- 예시 코드
S3에 upload하는 클래스를 mocking하는 코드이다. 클래스의 각 함수들이 원하는 방식대로 동작하도록 mocking 해 두고 이를 Bean에 등록한다.
@Configuration @Profile("test") class MockUploaderConfiguration { @Bean @Primary fun mockPrivateUploader(): PrivateUploader { val s3Object = mockk<S3Object>(relaxed = true) val uploader = mockk<PrivateUploader>(relaxed = true) every { uploader.getS3Object(any()) } returns s3Object return uploader } } - 예시 코드
Multi module에서 테스트 관련 설정 한곳에 몰아넣기
multi module 환경에서는 코드들이 분리되어 있기 때문에 테스트 관련 설정을 각 모듈별로 가지고 있어야 한다. 이런 불편함을 없애기 위해, 나는 해당 설정을 한 곳에서 모아 관리하는 방법을 사용한다.
- core 모듈의 build.gradle.kts에서 testJar task 추가
tasks.register<Jar>("testJar") { from(sourceSets["test"].output) archiveClassifier.set("test") } - testArchives 추가 및 testJar task 추가
configurations { create("testArchives") { extendsFrom(configurations["testImplementation"]) } } artifacts { add("testArchives", tasks.named("testJar")) } - API 모듈(사용하는쪽) 에서 test 의존성 추가
dependencies { testImplementation(project(":core", configuration = "testArchives")) }
Controller 테스트하기
controller 테스트는 mockMVC를 사용하여 테스트하고, api 요청과 동일한 것처럼 보이지만 filter까지 테스트하지는 않는다. filter까지 테스트하는 방법은 여러 블로그에 잘 나와 있으니 참고하면 좋을 것이다. [관련 블로그]
controller 테스트를 하는 이유는 api로 들어오는 값의 type 혹은 형식검사 등의 기능을 검사하기 위함이라고 생각하기 때문에, service 클래스를 mocking해서 bean에 등록하기도 한다.
@ActiveProfiles("test")
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class UserControllerTest(
val mockMvc: MockMvc,
) : StringSpec({
val objectMapper = ObjectMapper()
"User 생성 controller 테스트" {
val request = CreateUserRequest()
val result = mockMvc.perform(
post("/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(
objectMapper.writeValueAsString(request)
)
)
result.andExpect(status().isOk)
}
})
번외
kotest에서 @Trasnactional이 동작하지 않는 버그를 만났다. 테스트가 완료되어도 데이터가 삭제되지 않은 것인데, 이는 위에서 얘기한 mockbean과 비슷한 이유이다.
@SpringBootTest에 존재하는 SpringExtension 설정은 jupiter의 SpringExtension이기 때문에 kotest를 지원하지 않는 것이다. 따라서 해결 방법은 KoTest의 SpringExtension을 설정하는 것이다. 아래 두 코드 중 원하는 방법을 선택하면 된다.
@ExtendWith(SpringExtension::class)
@Transactional
@SpringBootTest
class Test(): DescribeSpec ({
extension(SpringExtension)
...
})
마무리
테스트와 관련된 내용을 찾아보다가 재미있는 논쟁이 있어서 공유하면서 마무리하려고 한다.
Spring 테스트에서 @Transactional 을 사용해도 되느냐에 대한 논쟁인데, spring 쪽 거물들이 나와서 논쟁하는 것이 매우 흥미롭다. 한번씩 읽어보면 좋을 것 같다. [향로님 블로그]
개인적인 생각으로는... 테스트의 목적이 시스템 안정화와 버그찾기 라는 관점을 가지고 있어서, 간편한 테스트 설정으로 인해 잡지 못한 버그가 운영까지 이어지는 것이 더 위험하다고 생각된다. 결국 트레이드 오프가 있는 것이고, 어떤 것이 더 크리티컬하냐 라고 봤을때는 향로님 관점이 좀 더 공감된다. 일개 주니어의 생각.. 끝
'Spring' 카테고리의 다른 글
| FilterChain을 2번 호출한다고? (1) | 2024.09.14 |
|---|---|
| PermitAll의 함정 (0) | 2024.08.16 |
| Spring @RequestParam 주의할 점 (0) | 2024.08.16 |