Kotest를 활용해 Spring Boot에서 테스트코드 작성하기
Kotlin + Spring Boot 프로젝트에서 테스트코드를 Junit으로 작성하기는 쉽지 않습니다. Kotlin 컨벤션을 무시하고 Annotation 떡칠한 채 테스트코드를 작성한다면 분노의 찬 Jetbrains IntelliJ가 갑자기 컴파일을 하지 않을 수도 있습니다. 그래서 태생부터 Kotlin을 위해 개발된 테스트 프레임워크로 Kotlin 최신 문법을 발 빠르게 지원하고 있는 Kotest와 함께 테스트코드를 작성한다면 유지보수측면에서도 훨씬 더 효과적일 것입니다.
테스트 준비
Kotest와 Spring Boot Test를 사용하기 위해 의존성을 아래와 같이 추가합니다. (아래에 H2 DB를 이용해 테스트를 수행할 예정으로 h2 의존성도 추가합니다.)
testImplementation("com.h2database:h2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.kotest:kotest-runner-junit5:5.5.4")
testImplementation("io.kotest:kotest-assertions-core:5.5.4")
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.1.2")
그리고 현재 글의 모든 테스트코드는 Kotest의 FreeSpec을 사용하겠습니다. FreeSpec의 사용예제는 아래와 같습니다.
class CalculatorTest : FreeSpec({
"add" - {
"2개의 숫자를 더했을 때 결과가 반환된다." {
// given
val a = 10
val b = 20
val calculator = Calculator()
// when
val result = calculator.add(a, b)
// then
result shoudBe 30
}
"2개의 음수를 더했을 때 결과가 반환된다." {
// given
val a = -10
val b = -20
val calculator = Calculator()
// when
val result = calculator.add(a, b)
// then
result shoudBe -30
}
}
"subtract" - {
// subtract 테스트 코드 작성 ..
}
})
위처럼 메소드별로 Test Suite를 분리할 수 있고 한글을 백틱으로 감싸지 않아도 된다는 점에서 깔끔한 테스트 코드를 유지할 수 있습니다. 더 많은 테스트 스타일 방식이 궁금하시다면 Kotest 공식문서를 참고하시기 바랍니다.
예제 코드
우리는 Spring Boot + Kotlin + JPA를 이용해 간단한 조회, 수정 API의 테스트코드를 작성해 보겠습니다. 기본적으로 사용될 Entity는 아래와 같습니다.
// Example.kt
@Entity
class Example(
@Column(nullable = false)
var name: String,
@Column(nullable = false)
var age: Int,
@Enumerated(EnumType.STRING)
@Column(length = 10, nullable = false)
var type: ExampleType,
) : BaseEntity() {
fun update(name: String, age: Int, type: ExampleType) {
this.name = name
this.age = age
this.type = type
}
companion object {
fun create(name: String, age: Int): Example {
return Example(
name = name,
age = age,
type = ExampleType.NORMAL
)
}
}
}
// ExampleType.kt
enum class ExampleType {
NORMAL, ADMIN
}
위 Example Entity를 이용한 Spring Data JPA Repository를 생성합니다.
// ExampleRepository.kt
interface ExampleRepository : JpaRepository<Example, Long>
이제 간단한 Example 조회 API를 작성합니다.
// ExampleService.kt
@Service
class ExampleService(
private val exampleRepository: ExampleRepository,
private val exampleQueryRepository: ExampleQueryRepository,
) {
fun findById(id: Long): Example =
exampleRepository
.findById(id)
.orElseThrow {
throw EntityNotFoundException(
"id가 [$id]와 일치하는 Example을 찾을 수 없습니다."
)
}
}
// ExampleController.kt
@RequestMapping("/example")
@RestController
class ExampleController(
val exampleService: ExampleService,
) {
@GetMapping("/{id}")
fun findExample(
@PathVariable("id") id: Long,
): Example {
return exampleService.findById(id)
}
}
테스트 환경 구축
이제부터 위 예제 코드에 대해 테스트코드를 작성하겠습니다. 하지만 테스트를 작성하기 위해서 간단한 환경설정이 필요합니다. 예제코드는 JPA로 구현되었기 때문에 DB를 사용하게 됩니다. 테스트코드에서도 실제 Production DB를 사용하면 불필요한 데이터가 계속 저장되거나 기존 데이터가 삭제될 수 있기 때문에 테스트코드 환경에서는 H2 In-Memory DB를 사용할 예정입니다. 그러기 위해서는 테스트 환경에서 사용할 Application.yml 설정을 따로 분리해야 합니다.
// application.yml
spring:
datasource:
url: jdbc:h2:mem:testdb;MODE=MySQL
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
show-sql: true
hibernate:
ddl-auto: create
해당 위치에 새로 추가해야 test 모듈에서 실행된 테스트코드는 위에서 작성한 Application.yml 설정이 적용됩니다. 이제 본격적으로 테스트코드를 작성해 보겠습니다.
Example 조회 테스트
// ExampleRepositoryTest.kt
@SpringBootTest
class ExampleRepositoryTest(
private val exampleRepository: ExampleRepository,
) : FreeSpec({
"findById" - {
"id와 일치하는 Example을 반환한다." {
// given
val example = exampleRepository.createExample()
// when
val result = withContext(Dispatchers.IO) {
exampleRepository.findById(example.id)
}.get()
// then
result.id shouldBe example.id
}
"id가 일치하는 Example이 없을 때 NoSuchElementException을 반환한다." {
// given
// when
val exception = shouldThrow<NoSuchElementException> {
exampleRepository.findById(1).get()
}
// then
exception.message shouldBe "No value present"
}
}
})
실제로 빠른 개발을 위해 Spring Data JPA Repository 테스트 코드는 잘 작성하지 않습니다. 하지만 QueryDSL이나 JPQL로 작성 된 Repository의 코드는 테스트코드를 필수로 작성해야 합니다. 위 테스트코드는 일종의 예제로 한번 작성해 보았습니다.
// ExampleServiceTest.kt
@SpringBootTest
class ExampleServiceTest(
private val exampleRepository: ExampleRepository,
private val exampleService: ExampleService,
) : FreeSpec({
"findById" - {
"id와 일치하는 Example을 반환한다." {
// given
val example = exampleRepository.save(
Example.create(
"name",
10,
)
)
// when
val result = exampleService.findById(example.id)
// then
result.id shouldBe example.id
}
"id가 일치하는 Example이 없을경우 EntityNotFoundException이 발생한다." {
// given
val id = 0L
// when
val exception = shouldThrow<EntityNotFoundException> {
exampleService.findById(id)
}
// then
exception.message shouldBe "id가 [$id]와 일치하는 Example을 찾을 수 없습니다."
}
}
}
Service 레이어 테스트를 작성할 때 고려해야 할 점이 있습니다. 만약 10개의 각기 다른 example 엔티티가 given 절에서 필요한 상황이라면 테스트코드가 굉장히 길어질 것입니다. 그래서 아래와 같이 리펙토링 할 수 있습니다.
위와 같은 위치에 확장 함수를 선언합니다. test 모듈 안에 확장함수가 선언되어야 실제 비즈니스 로직에서 테스트를 위해 작성된 확잠함수가 보이지 않게 됩니다.
// RepositoryExtensions.kt
fun ExampleRepository.createExample(
name: String = "name",
age: Int = 10,
exampleType: ExampleType = ExampleType.NORMAL,
): Example = this.saveAndFlush(
Example(
name = name,
age = age,
type = exampleType,
)
)
테스트에서만 사용될 확장함수의 선언으로 아래와 같이 가독성이 좋은 테스트코드를 작성할 수 있습니다.
// As-is
"각기 다른 age를 가진 example 여러개가 필요한 상황" {
// given
val tenAgeExample = exampleRepository.save(
Example.create(
"name",
10,
)
)
exampleRepository.save(
Example.create(
"name",
1,
)
)
exampleRepository.save(
Example.create(
"name",
111,
)
)
exampleRepository.save(
Example.create(
"name",
222,
)
)
}
// To-be
"각기 다른 age를 가진 example 여러개가 필요한 상황" {
// given
val tenAgeExample = exampleRepository.createExample(age = 10)
exampleRepository.createExample(age = 1)
exampleRepository.createExample(age = 111)
exampleRepository.createExample(age = 222)
}
위와 같은 리펙토링은 Example 엔티티의 프로퍼티가 30개가 넘는 상황이라면 더욱 효과적일 것입니다. 또한 엔티티간 연관관계가 복잡한 경우 더 유용하게 사용될 수 있습니다.
이제 QueryDSL을 이용한 조회의 경우 테스트 코드를 확인하겠습니다.
// ExampleQueryRepository.kt
@Repository
class ExampleQueryRepository(
private val queryFactory: QueryFactory
) {
fun findAllByName(name: String): List<Example> {
return queryFactory.listQuery {
select(Example::class.java)
from(entity(Example::class))
where(column(Example::name).equal(name))
}
}
}
// ExampleService.kt
class ExampleService(
private val exampleQueryRepository: ExampleQueryRepository,
) {
fun findAllByName(name: String): List<Example> =
exampleQueryRepository.findAllByName(name)
}
// ExampleController.kt
@GetMapping
fun findAllByName(
@RequestParam name: String,
): List<Example> {
return exampleService.findAllByName(name)
}
QueryDSL로 작성된 Repository 테스트는 기존 조회 테스트와 거의 동일하게 아래와 같이 작성할 수 있습니다.
// ExampleQueryRepositoryTest.kt
@SpringBootTest
class ExampleQueryRepositoryTest(
private val exampleQueryRepository: ExampleQueryRepository,
private val exampleRepository: ExampleRepository,
) : FreeSpec({
"findAllByName" - {
"이름이 일치하는 Example이 존재할 때 조회된다." {
// given
val name = "hello"
val example = exampleRepository.createExample(name = name)
exampleRepository.createExample()
exampleRepository.createExample()
// when
val examples = exampleQueryRepository.findAllByName(name)
// then
examples.size shouldBe 1
examples[0].name shouldBe example.name
examples[0].age shouldBe example.age
examples[0].type shouldBe example.type
}
"이름이 일치하는 Example이 없을 때 빈 리스트로 조회된다." {
// given
exampleRepository.createExample()
exampleRepository.createExample()
exampleRepository.createExample()
// when
val examples = exampleQueryRepository.findAllByName("foo")
// then
examples.size shouldBe 0
}
}
})
Example 수정 테스트
먼저 간단하게 Example을 수정하는 비즈니스 로직을 작성하겠습니다.
// UpdateExampleRequest.kt
data class UpdateExampleRequest(
val name: String,
val age: Int,
val type: ExampleType
)
// ExampleService.kt
@Transactional
fun update(
id: Long,
request: UpdateExampleRequest,
): Example {
val example = this.findById(id)
example.update(name = request.name, age = request.age, type = request.type)
return example
}
// ExampleController.kt
@PutMapping("/{id}")
fun updateExample(
@PathVariable id: Long,
@RequestBody request: UpdateExampleRequest,
): Example {
return exampleService.update(
id = id,
request = request,
)
}
수정하는 로직의 테스트코드 작성 시 굉장히 주의해야 할 점이 있습니다. 테스트코드에서 @Transactional 어노테이션을 붙이면 안 된다는 점입니다. 비교를 위해 먼저 transaction 로그 설정을 수정하겠습니다.
// application.yml
logging:
level:
org:
hibernate:
SQL: DEBUG
type.descriptor.sql.BasicBinder: TRACE
engine:
transaction:
internal:
TransactionImpl: DEBUG
springframework:
transaction: DEBUG
이후 아래와 같이 수정 후 테스트코드를 작성합니다.
// ExampleService.kt
// @Transactional 확인을 위해 일부러 주석처리
fun update(
id: Long,
request: UpdateExampleRequest,
): Example {
val example = this.findById(id)
example.update(name = request.name, age = request.age, type = request.type)
return example
}
// ExampleServiceTest.kt
@SpringBootTest
@Transactional // 확인을 위해 일부러 추가
class ExampleServiceTest(
private val exampleRepository: ExampleRepository,
private val exampleService: ExampleService,
) : FreeSpec({
"update" - {
"Example이 정상적으로 업데이트된다." {
// given
val example = exampleRepository.createExample(
name = "beforeName"
)
val request = UpdateExampleRequest(name = "hello", 1000, ExampleType.ADMIN)
// when
println("update 실행")
exampleService.update(example.id, request)
println("update 끝")
// then
println("select 실행")
val result = exampleService.findById(example.id)
println("select 끝")
result.id shouldBe example.id
result.name shouldBe request.name
result.age shouldBe request.age
}
}
}
위와 같이 테스트를 작성했을 때 테스트가 정상적으로 통과하게 됩니다. 하지만 문제는 디버그 로그를 통해 찾을 수 있습니다.
테스트코드에 @Transactional 어노테이션이 붙었기 때문에 테스트가 실행됨과 동시에 트랜젝션이 시작되었고 테스트가 끝날 때까지 update 쿼리는 실행되지 않았습니다. 그럼에도 불구하고 위에서 작성한 테스트코드는 정상적으로 통과했습니다. 그 이유는 같은 트랜젝션일 경우 JPA 영속성 컨텍스트의 1차 캐시 된 값을 반환하기 때문입니다. 따라서 테스트코드에서 호출되는 DB I/O마다 새로운 트랜젝션으로 호출되어야 1차 캐시 된 값을 불러오지 않고 테스트코드를 신뢰할 수 있습니다.
따라서 아래와 같이 테스트코드에서 @Transactional을 제거하고 update 메소드에 @Transactional 어노테이션을 빠뜨린 상황이라면 아래와 같이 수행됩니다.
// ExampleService.kt
// @Transactional 실수로 빠뜨린 상황
fun update(
id: Long,
request: UpdateExampleRequest,
): Example {
val example = this.findById(id)
example.update(name = request.name, age = request.age, type = request.type)
return example
}
// ExampleServiceTest.kt
@SpringBootTest
class ExampleServiceTest(
private val exampleRepository: ExampleRepository,
private val exampleService: ExampleService,
) : FreeSpec({
"update" - {
"Example이 정상적으로 업데이트된다." {
// given
val example = exampleRepository.createExample(
name = "beforeName"
)
val request = UpdateExampleRequest(name = "hello", 1000, ExampleType.ADMIN)
// when
println("update 실행")
exampleService.update(example.id, request)
println("update 끝")
// then
println("select 실행")
val result = exampleService.findById(example.id)
println("select 끝")
result.id shouldBe example.id
result.name shouldBe request.name
result.age shouldBe request.age
}
}
}
각 DB I/O 마다 별도의 트랜젝션으로 호출되는 것을 확인할 수 있습니다. 이로써 얻게 되는 이점은 비즈니스 로직을 작성하다 ExampleService의 update 메소드에 @Transactional 어노테이션을 작성하지 않으면 작업자의 명백한 실수입니다. 이 같은 실수를 테스트코드로 잡아낼 수 있습니다.
트랜젝션 로그를 아래와 같이 확인할 수 있습니다.
@Transactional 어노테이션을 서비스 코드에 아래와 같이 붙이게 된다면 update 쿼리가 정상적으로 수행되는 것을 확인할 수 있습니다.
// ExampleService.kt
@Transactional // 제대로 작성된 어노테이션
fun update(
id: Long,
request: UpdateExampleRequest,
): Example {
val example = this.findById(id)
example.update(name = request.name, age = request.age, type = request.type)
return example
}
// ExampleServiceTest.kt
@SpringBootTest
class ExampleServiceTest(
private val exampleRepository: ExampleRepository,
private val exampleService: ExampleService,
) : FreeSpec({
"update" - {
"Example이 정상적으로 업데이트된다." {
// given
val example = exampleRepository.createExample(
name = "beforeName"
)
val request = UpdateExampleRequest(name = "hello", 1000, ExampleType.ADMIN)
// when
println("update 실행")
exampleService.update(example.id, request)
println("update 끝")
// then
println("select 실행")
val result = exampleService.findById(example.id)
println("select 끝")
result.id shouldBe example.id
result.name shouldBe request.name
result.age shouldBe request.age
}
}
}
'Spring boot' 카테고리의 다른 글
SpringBoot 모니터링 하기 (feat. Grafana, Prometheus) (1) | 2023.03.06 |
---|---|
Hikari Connection Pool 확인 (0) | 2022.12.09 |
Spring Boot + Kotlin 깔끔한 validation 처리 (feat. Jackson) (1) | 2022.10.18 |
SpringBoot ResponseBodyAdvice 특정 응답 값 암호화하기 (0) | 2022.09.25 |
댓글
이 글 공유하기
다른 글
-
SpringBoot 모니터링 하기 (feat. Grafana, Prometheus)
SpringBoot 모니터링 하기 (feat. Grafana, Prometheus)
2023.03.06 -
Hikari Connection Pool 확인
Hikari Connection Pool 확인
2022.12.09 -
Spring Boot + Kotlin 깔끔한 validation 처리 (feat. Jackson)
Spring Boot + Kotlin 깔끔한 validation 처리 (feat. Jackson)
2022.10.18 -
SpringBoot ResponseBodyAdvice 특정 응답 값 암호화하기
SpringBoot ResponseBodyAdvice 특정 응답 값 암호화하기
2022.09.25