Kotlin Test

fana·2022년 11월 12일
1
post-custom-banner

테스트

코프링 프로젝트의 테스트를 구성해보았다. 테스트를 하면서 사용한 패키지들은 아래와 같다.

  • Kotest
  • Mockk
  • Fixture(faker, fixtureMonkey, kotlinFixture)

fixture를 사용할 때 위 처럼 세가지 종류를 사용해봤는데 결론적으로 현재는 kotlinFixture를 사용하고 있다.
fixture들을 사용하면서 느낀 점들과 갈아탄 이유들을 정리하려고 한다.

Prerequisite

예시를 위해서 다음과 같은 엔티티가 있다고 하자.

// User.kt
package example

import example.domain.base.BaseEntity
import example.domain.user.UserStatus
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedDate
import java.time.LocalDateTime
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.Index
import javax.persistence.Table

@Table(name = "users")
@Entity
class User(
    @Column(name = "name", length = 16, nullable = false)
    val name: String,
    
	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0L,
    
    status: UserStatus,
) {
	@Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false)
    var status: UserStatus = status
        private set

    @CreatedDate
    @Column(name = "created_at", nullable = false)
    var createdAt: LocalDateTime = LocalDateTime.MIN
        private set

    @LastModifiedDate
    @Column(name = "updated_at", nullable = false)
    var updatedAt: LocalDateTime = LocalDateTime.MIN
        private set
}

Java Faker

faker는 예시이기는 하지만 아래와 같은 방식으로 사용하였다.

val faker: Faker = Faker.instance(Locale.US)

fun createUserFixture(
	id: Long = faker.number().numberBetween(0L, 999999L),
	name: String = faker.lorem().characters(),
    status: UserStatus = faker.options().option(UserStatus::class.java),
) = User(
	id = id,
    name = name,
    status = status,
)

이렇게 사용하다보니 몇 가지 불편한 점이 생겼는데 다음과 같다.

  • 프로젝트를 멀티 모듈로 관리하게 되면서 fixture 관련 함수들을 불러오기 위해 testImplementation을 하려고 했으나 어느 시점부터인지 안됐다.
    • jar를 어떻게 어떻게 작업해서 하면 된다는 글을 여러개 보고 시도해봤지만 실패했다.
    • 그래서 test패키지 말고 일반 패키지로 옮긴 후 어노테이션으로 @Profile(value = ["local", "test"]) 이런 방식으로 사용하는데 여간 거슬리는게 아니었다.
  • 도메인 엔티티에 relation이 있으면 테스트에 관계가 없더라도 관계 도메인 엔티티의 fixture를 일일히 faker를 통해 만들어줘야 했다.
  • 2022. 11. 30일 추가: 위에 언급된 내용과 관련해서 토스테크에서 잘 설명되어있는 글을 발견하였다. 굿!

Fixture Monkey

그래서 네이버에서 만든 fixtureMonkey라는 것을 사용해보려고 하였다. 사실 fixture monkey는 제대로 적용해보지 않고 아주 살짝 맛만보고 바로 버렸다.
그 이유는 다음과 같은데,

  • 도메인 엔티티에 다음과 같은 어노테이션이 덕지덕지 붙어야 한다는 것이 싫다.
    • fixtureMonkey
  • 문서를 더 찾아보니 다음과 같이 어노테이션을 붙이지 않고 사용하는 방법이 있는듯 한데, 실패하였다.
val sut = FixtureMonkey.create()
val userFixture = sut.giveMeBuilder(User::class.java)
					.set("name", "FANA") // 여기서 공식 문서에 있는 모든 것을 시도해봐도 무조건 null로 나온다.
                    .sample()

KotlinFixture

첫 인상으로는 상당히 마음에 들었다. 무엇이 가장 마음에 들었냐면 mockk 와 함께 사용하면서 아래와 같은 코드를 작성할 때가 많은데

every { SomeService.doSomething(customizedUserFixture) } returns something
  • 도메인 엔티티의 관계설정을 아무것도 건들지 않아도 알아서 타입에 맞게 랜덤으로 채워준다.
  • 팩토리를 통해 해당 타입에 어떤 값들을 넣을지 설정할 수 있고
  • 특정 프로퍼티를 직접 지정할 수도 있다.
  • 사용해보지는 않았지만 그 밖의 유용한 것들이 많다.

기본적인 사용법은 다음과 같다.

val fixture = kotlinFixture()
val userFixture = fixture<User>()

여기에, 기본적인 팩토리를 붙인다거나

val fixture = kotlinFixture {
	factory<String> { "FANA", "SOMETHING", "CANDIDATES" }
    factory<Int> { 1, 2, 3 }
}

특정 타입에도 쓸 수 있다.

// 참고
enum class UserStatus {
	CREATED,
    SOMETHING,
    ELSE,
    THIS,
    COULD,
    BE;
}

val fixture = kotlinFixture {
	// fixture<User>()를 통해 만들어지는 유저 픽스쳐는 CREATED, SOMETHING 두가지로만 만들어진다.
	factory<UserStatus> { UserStatus.CREATED, UserStatus.SOMETHING }
}

개별적으로 프로퍼티를 지정할 수도 있다.

val fixture = kotlinFixture()
val userFixture = fixture<User> {
	property(User::name) { "FANA" }
    property(User::status) { UserStatus.COMPLETED }
}

당연하게도 constructor에 없는 프로퍼티는 지정할 수 없다.

암튼, 그래서 결과적으로 만들어진 테스트 코드를 보면 다음과 같다.

package example

import com.appmattus.kotlinfixture.kotlinFixture
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk

internal class SignUpServiceTest : BehaviorSpec({
    isolationMode = IsolationMode.InstancePerLeaf

    val getUserService = mockk<GetUserService>()
    val createUserService = mockk<CreateUserService>()
    val cryptService = mockk<CryptService>()

    val signUpService = SignUpService(
        getUserService = getUserService,
        createUserService = createUserService,
        cryptService = cryptService,
    )
    val nickName = "FANA"
    val plainPassword = "PLAIN_PASSWORD"
    val hashedPassword = "HASHED_PASSWORD"
    val fixture = kotlinFixture()
    val loginMethodFixture = fixture<LoginMethod> {
        property(LoginMethod::loginType) { LoginType.PASSWORD }
        property(LoginMethod::version) { 1 }
        property(LoginMethod::hashedValue) { hashedPassword }
    }
    val userFixture = fixture<User> {
        property(User::loginMethods) { mutableListOf(loginMethodFixture) }
    }

    given("닉네임과 패스워드가 주어졌을 때") {
        every { getUserService.findUserByNickName(any()) } returns null
        every { createUserService.createUser(nickName) } returns userFixture
        every { cryptService.encrypt(any(), any()) } returns hashedPassword

        `when`("회원가입을 시도하면") {
            then("회원가입이 된다") {
                val user = signUpService.signUp(nickName, plainPassword)
                val loginMethod = user.loginMethods.find { it.loginType === LoginType.PASSWORD && it.version == 1 }

                user.nickName shouldBe userFixture.nickName
                loginMethod!!.hashedValue shouldBe hashedPassword
            }
        }

        // validations
        `when`("닉네임이 이미 존재하면") {
            every { getUserService.findUserByNickName(nickName) } returns userFixture
            then("예외가 발생한다") {
                shouldThrow<DuplicatedUserNickNameException> {
                    signUpService.signUp(nickName, plainPassword)
                }
            }
        }

        `when`("닉네임 길이가 유효하지 않으면") {
            then("예외가 발생한다") {
                shouldThrow<InvalidUserNickNameException> {
                    signUpService.signUp(
                        nickName = "thisIsTooooooooooooooooooooooooooooooooooooooooooooooooooooooooLongForNickName",
                        plainPassword = plainPassword
                    )
                }
            }
        }

        `when`("닉네임 패턴이 유효하지 않으면") {
            then("예외가 발생한다") {
                shouldThrow<InvalidUserNickNameException> {
                    signUpService.signUp(
                        nickName = "!@#%@$$%&$%&$%&$%&$&%$&$%&",
                        plainPassword = plainPassword
                    )
                }
            }
        }

        `when`("패스워드 길이가 유효하지 않으면") {
            then("예외가 발생한다") {
                shouldThrow<InvalidPasswordException> {
                    signUpService.signUp(
                        nickName = nickName,
                        plainPassword = "thisIsTooooooooooooooooooooooooooooooooooooooooooooooooooooooooLongForPassword"
                    )
                }
            }
        }
    }
})

기타 삽질

  • 별개로 kotest와 mockk를 같이 사용함에 있어서 약간의 이슈가 있다고 하는데,
    다음과 같이하면 잘 된다.
// build.gradle.kts

// 생략
allprojects {
    group = "example"
    version = "0.0.1"

    extra["kotlin-coroutines.version"] = Versions.coroutines // 이것 추가
	
    // 생략
}

// 생략
  • kotest의 given > when > then을 사용할 때, 중첩 순서에 따라 테스트가 먹통이 된다.
    • 이유는 모르겠지만 when을 두번 이상 연속으로 중첩하면 먹통이 됨(when > when)
    • 따라서 given > when > given > when 이런식으로 사용하거나
    • given > given > when 이런식으로 사용하면 잘 된다.
post-custom-banner

2개의 댓글

comment-user-thumbnail
2022년 11월 30일

안녕하세요 지나가다가 보고 남깁니다.
Kotlin 에서 FixtureMonkey 를 사용할 경우 KotlinPlugin 을 셋팅해주어야합니다.
아마 KotlinPlugin 을 주입하지 않아서 의도한 값이 셋팅되지 않고 null 이 들어간거로 보입니다.
https://naver.github.io/fixture-monkey/kr/docs/v0.4/third-party-modules/fixture-monkey-kotlin/

속성 변경시 Kotlin Exp 를 사용할 수 있는 것도 참고하시면 될거 같습니다.
https://naver.github.io/fixture-monkey/kr/docs/v0.4/examples/fieldset/#2-%EA%B0%92-%EC%84%A4%EC%A0%95

클래스에서 Validation 애노테이션을 붙이는건 필수사항이 아닌 필요한 경우 선택할 수 있는 옵션으로 보시면 될거 같습니다.

1개의 답글