testImplementation("io.kotest:kotest-runner-junit5:${Versions.KOTEST}")
testImplementation("io.kotest:kotest-assertions-core:${Versions.KOTEST}")
위의 두 의존성을 추가해준다. 나는 5.5.4 버전을 택해 사용했다.
kotest-runner-junit5는 다양한 방식의 테스트 코드 작성을 쉽게 하기 위해,
kotest-assertions-core는 DSL 스타일에 맞는 검증을 하기 위해 추가하였다.
간단한 단위 테스트를 보며 비교해보려 한다. 아래와 같은 테스트를 기존의 Junit과 AssertJ를 사용해 작성하였다.
internal class AESEncryptBOTest {
@Test
@DisplayName("encrypt된 패스워드를 decrypt하면 원본이 나온다.")
fun decryptTest() {
// given
val password = "test-my-password"
// when
val aesEncryptBO = AESEncryptBO.encrypt(password.toByteArray(Charsets.UTF_8))
// then
Assertions.assertThat(AESEncryptBO.decrypt(aesEncryptBO)).isEqualTo(password)
}
}
위의 코드를 kotest를 사용하여 다시 작성해본다면 다음과 같이 개선할 수 있다.
internal class AESEncryptBOTest : StringSpec({
"encrypt된 패스워드를 decrypt하면 원본이 나온다." {
// given
val password = "test-my-password"
// when
val aesEncryptBO = AESEncryptBO.encrypt(password.toByteArray(Charsets.UTF_8))
// then
AESEncryptBO.decrypt(aesEncryptBO) shouldBe password
}
})
가장 큰 차이점은 @Test
, @DisplayName
을 사용하지 않고 깔끔하게 테스트를 작성할 수 있고, 무엇보다도 객체의 값을 검증할 때에 DSL 스타일인 shouldBe
로 동등성 검증을 했다는 점이다.
kotest를 작성할 대에는 StringSpec
말고도 다양한 방법이 있다.
AnnotationSepc
: 기존의 Junit 테스트 방식과 유사하게, 애너테이션을 기반으로 테스트를 작성StringSpec
: 간단하게 테스트의 DisplayName을 명시하여 테스트를 할 때 사용BehaviorSpec
: Given, When, Then 방식으로 테스트를 할 때 사용DescribeSpec
: Describe, Context, It 방식으로 테스트를 할 때 사용검증 방법도 shouldBe
말고도 다양하게 있다.
shouldBe
shouldNotBe
shouldStartWith
shouldContain
shouldContainAll
shouldBeEqualIgnoringCase
shouldBeGreaterThanOrEqualTo
ApplicationService
를 테스트하는 예제 코드다.ApplicationService
는 외부 서비스 혹은 DB 쿼리를 날리는 계층이므로 적절하게 mocking을 해서 사용해야 한다.BehaviorSpec
기반에서 MockK
와 함께 사용하였다.@ExtendWith(MockKExtension::class)
class AccountCommandServiceTest : BehaviorSpec() {
@InjectMockKs private lateinit var accountCommandService: AccountCommandService
@MockK private lateinit var userRepository: UserRepository
@MockK private lateinit var userUpdateHistoryRepository: UserUpdateHistoryRepository
...
init {
isolationMode = IsolationMode.InstacePerLeaf // 아래에서 설명
MockKAnnotations.init(this) // 필드에 선언한 @MockK를 초기화하기 위함
Given("회원탈퇴시") {
val mockCommand = mockk<UserDeleteCommand>(relaxed = true)
When("사용자가 존재하고, 회원 탈퇴 이력이 없다면") {
every { userRepository.existsByNo(mockCommand.userNo) } returns true
every { userDeleteHistoryRepository.existsByUserId(mockCommand.userNo) } returns false
every { userDeleteHistoryRepository.save(any<UserDeleteHistory>()) } returns mockk()
Then("정상적으로 탈퇴한다.") {
accountCommandService.withdrawalUser(command)
assertSoftly {
verify { userRepository.existsByNo(mockCommand.userNo) }
verify { userDeleteHistoryRepository.existsByUserId(mockCommand.userNo) }
verify { userDeleteHistoryRepository.save(any<UserDeleteHistory>()) }
}
}
}
When("사용자가 존재하지 않으면 ") {
every { userRepository.existsByNo(mockCommand.userNo) } returns false
Then("예외가 발생한다.") {
assertSoftly {
assertThrows<UserNotFoundException> {
accountCommandService.withdrawalUser(mockCommand)
}
verify { userRepository.existsByNo(mockCommand.userNo) }
verify { userDeleteHistoryRepository wasNot Called }
}
}
}
When("이미 회원 탈퇴 요청을 했었다면 ") {
every { userRepository.existsByNo(mockCommand.userNo) } returns true
every { userDeleteHistoryRepository.existsByUserId(mockCommand.userNo) } returns true
Then("예외가 발생한다.") {
assertSoftly {
assertThrows<DeleteDuplicatedException> {
accountCommandService.withdrawalUser(mockCommand)
}
verify { userRepository.existsByNo(mockCommand.userNo) }
verify { userDeleteHistoryRepository.existsByUserId(mockCommand.userNo) }
verify { userDeleteHistoryRepository.save(any<UserDeleteHistory>()) wasNot Called }
}
}
}
}
}
}
BehaviorSpec
을 사용해, BDD 스타일로 테스트를 가독성있게 작성할 수 있다.BehaviorSpec
덕분에 기존의 중첩 클래스와 @BeforeEach
애너테이션 사용을 줄일 수 있게되었다.참고
Then 절에서 여러 값을 검증해야 한다면 assertSoftly { ... }를 사용하자.
assertSoftly를 사용하지 않는다면 여러 개가 실패했더라도 하나만 에러 로그가 남는다.
assertSoftly를 사용하면 여러 개가 실패하더라도 어떤 항목이 실패했는지 에러 로그가 자세하게 남는다.
Given("IdReservedKeywordsValidator") {
val validator = ReservedKewordValidator()
listOf("reserved", "admin", "test", "master").forEach { keyword ->
Then("실패 : $keyword") {
assertThrows<ReservedKeywordException> {
validator.validate(keyword)
}
}
}
listOf("hyunseung", "gustmd", "goodday").forEach { keyword ->
Then("성공 : $keyword") {
assertDoesNotThrows {
validator.validate(keyword)
}
}
}
}
data class KeywordTestData(val keyword: String, val shouldThrow: Boolean)
class ReservedKeywordValidatorTest : FunSpec({
val validator = ReservedKeywordValidator()
withData(
KeywordTestData("reserved", true),
KeywordTestData("admin", true),
KeywordTestData("test", true),
KeywordTestData("master", true),
KeywordTestData("hyunseung", false),
KeywordTestData("gustmd", false),
KeywordTestData("goodday", false)
) { (keyword, shouldThrow) ->
if (shouldThrow) {
shouldThrow<ReservedKeywordException> {
validator.validate(keyword)
}
} else {
shouldNotThrowAny {
validator.validate(keyword)
}
}
}
})
userDeleteHistory
호출부를 모킹했는데, 이 동작이 두 번째 When
절에도 영향을 미쳐 해당 호출부가 호출되지 않음을 검증했지만 예상과는 달리 호출된 상황이 발생한다. 이는 테스트의 격리 레벨이 의도와 다르게 동작함을 알 수 있다.IsolationMode.SingleInstance (기본값)
: Spec 클래스 내에서 단 하나의 인스턴스만 생성해 테스트 진행IsolationMode.InstancePerTest
: 매 테스트마다 새로운 인스턴스를 생성해 테스트 진행IsolationMode.InstancePerLeaf
: 최하위 테스트마다 인스턴스를 생성해 테스트 진행InstancePerTest
보단 InstancePerLeaf
로 설정해주는 것이 좋을 것이다.class Test : BehaviorSpec({
Given("given") {
val id = UUID.randomUUID()
println(id)
When("when") {
println(id)
Then("then") {
println(id)
}
Then("then") {
println(id)
}
}
}
})
/**
* 732bd49c-d73a-4ae5-9a20-ff1a3e790c84
* 732bd49c-d73a-4ae5-9a20-ff1a3e790c84
* 732bd49c-d73a-4ae5-9a20-ff1a3e790c84
*
* 732bd49c-d73a-4ae5-9a20-ff1a3e790c84
* 732bd49c-d73a-4ae5-9a20-ff1a3e790c84
* 732bd49c-d73a-4ae5-9a20-ff1a3e790c84
**/
class Test : BehaviorSpec({
isolationMode = IsolationMode.InstancePerTest
Given("given") {
val id = UUID.randomUUID()
println(id)
When("when") {
println(id)
Then("then") {
println(id)
}
}
}
})
/**
* 6c9958a4-d456-4ca0-8905-098bfebaf464 // given
*
* bd751482-7d51-4f99-b688-879521eee273 // given
* bd751482-7d51-4f99-b688-879521eee273 // when
*
* 39b9a979-eef4-4e06-8e0d-f14bf1a719e8 // given
* 39b9a979-eef4-4e06-8e0d-f14bf1a719e8 // when
* 39b9a979-eef4-4e06-8e0d-f14bf1a719e8 // then
**/
class Test : BehaviorSpec({
isolationMode = IsolationMode.InstancePerLeaf
Given("given") {
val id = UUID.randomUUID()
println(id)
When("when") {
println(id)
Then("then") {
println(id)
}
Then("then") {
println(id)
}
}
}
})
/**
* 39b9a979-eef4-4e06-8e0d-f14bf1a719e8
* 39b9a979-eef4-4e06-8e0d-f14bf1a719e8
* 39b9a979-eef4-4e06-8e0d-f14bf1a719e8
*
* bd751482-7d51-4f99-b688-879521eee273
* bd751482-7d51-4f99-b688-879521eee273
* bd751482-7d51-4f99-b688-879521eee273
**/