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 공식문서를 참고하시기 바랍니다.

 

Testing Styles | Kotest

Kotest offers 10 different styles of test layout. Some are inspired from other popular test frameworks to make you feel right at home.

kotest.io

 

예제 코드

우리는 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 어노테이션이 없어 실패한 테스트

트랜젝션 로그를 아래와 같이 확인할 수 있습니다.

@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
        }
    }
}