Kotlin 테스트 코드 자동화 Intellij 플러그인 개발기
개발을 하다 보면 코드의 신뢰성을 검증하거나 내용을 전달하는 목적으로 테스트 코드를 많이 작성하게 되는데요. 다른 테스트 코드를 작성하는 것보다 단위 테스트(unit test) 코드를 작성하는 데에 가장 많은 시간을 투여하게 됩니다. 단위 테스트는 하나의 기능만 테스트하는 것이기 때문에 영향을 받는 부분이 매우 적으며, 비용이 저렴하기 때문입니다.
단위 테스트에서 반복되는 보일러 플레이트 코드
팀 내에서 이러한 테스트 코드를 Kotlin 스타일로 더 쉽게 작성하기 위해 KoTest라는 테스트 프레임워크와 모킹(mocking)을 위한 mockk 라이브러리를 이용하고 있습니다.
테스트 코드 작성에 많이 알려진 Given-When-Then Pattern도 이용하고 있는데요.
대부분 아래와 같은 흐름으로 작성되게 됩니다.
given(“계약서에 00동의가 존재하지 않을 때”) {
val mockedContractRepository = mockk<ContractRepository>()
val signContractService = SignContractService(contractRepository = mockedContractRepository)
when(“계약을 체결하려 할 시”) {
val exception = assertThrow<XXException>() {
signContractService.sign()
}
then(“00 항목을 동의해주세요. 메시지가 담긴 예외가 발생한다.”) {
exception.message shouldBe “00 항목을 동의해주세요.”
}
}
}
위의 테스트 코드는 제가 이전까지 경험해 온 팀들도 대다수 사용하는 패턴으로 테스트 대상 시스템(system under test)를 이루는 의존성을 모킹(mocking)하여 격리시킨 뒤 온전하게 테스트 대상 시스템만 테스트하는 단위 테스트 방식입니다.
위처럼 테스트 코드를 작성하기 위해서는 given 절에서 수행되는 내용이 아래처럼 항상 반복되는 것을 알게 됩니다.
1.테스트 대상 시스템 (SUT)을 제외한 의존성을 모킹(mocking)한다.
2.SUT에 대상 의존성을 주입한다.
즉, 우리는 단위 테스트를 작성할 때 위 패턴을 반복해서 작성하게 됩니다. 따라서 이렇게 반복되는 패턴을 자동화할 필요성이 있다고 느껴 여러 Intellij 플러그인을 찾게 되었습니다.
그 중, TestMe라는 플러그인을 찾았으나 아쉽게도 Kotlin 그리고 Kotest 를 별도로 지원하지는 않았습니다. 그래서 별도로 개발을 진행해야 겠다는 생각을 가지게 되었고, 10월 초에 개발을 진행하게 되었습니다.
플러그인 개발기
Intellij 내부에서 우리가 코드를 작성하거나, 파일을 생성하기 위해 단축키인(Cmd + N)을 누르는 과정 모두 하나의 Action으로 취급됩니다. 기존에 우리가 테스트 파일을 쉽게 생성하기 위해 만드는 단축키인 (Cmd + Shift + T) 조차도 Create Test라는 하나의 행위(Action)으로 취급됩니다.
따라서, 기본적으로 제공하는 Action이 있다면 상속(extend)하여, 약간 수정하면 좀 더 수월하게 개발할 수 있는데요. Intellij에서는 이미 CreateTestAction이라는, 테스트를 생성할 때 발생하는 Action의 기본 동작들을 다루는 클래스를 제공하고 있습니다. 따라서, 저 또한 아래와 같이 CreateTestAction Class를 상속받아 개발을 진행하였습니다.
class CreateTestActionHandler : CreateTestAction() {
override fun invoke(project: Project, editor: Editor?, element: PsiElement) {
val testConfig = TestBuilderConfig()
TestUiDslDialog(project, editor, element, testConfig).showAndGet()
}
}
이제 테스트를 생성하는 단축키인 행위(action)에 제 커스텀 이벤트를 등록시키기 위해서는, Intellij 내장 클래스인 GoToTestOrCodeHandler()에 위에서 생성한 CustomHandler를 이용하여 코드를 실행할 수 있도록 등록해야 합니다.
class GotoTestBoilerPlateHandler(
private val createTestActionHandler: CreateTestActionHandler = CreateTestActionHandler()
) : GotoTestOrCodeHandler() {
override fun getSourceAndTargetElements(editor: Editor?, file: PsiFile?): GotoData? {
val sourceAndTargetElements = super.getSourceAndTargetElements(editor, file) ?: return null
sourceAndTargetElements.additionalActions.add(0, object : AdditionalAction {
override fun getText(): String {
return "Create KoTest BoilerTemplate"
}
override fun getIcon(): Icon {
return IconLoader.getIcon("/icons/kotest.png")
}
override fun execute() {
createTestActionHandler.invoke(
file!!.project,
editor,
file
)
}
})
return sourceAndTargetElements
}
}
이렇게 잘 등록하면, 제가 작성한 Handler가 테스트를 생성하는 단축키인 Cmd + Shift + T를 눌렀을 때 잘 나오는 것을 확인할 수 있습니다.
Kotlin 파일 분석하기
(들어가기에 앞서, 이 부분에서는 간단하게 문맥을 잘 이해할 수 있을 정도의 코드만 설명할 예정입니다.)
이제 Handler는 잘 등록을 했고, 우리가 원하는 테스트 파일을 만들기 위해서는 우리가 현재 마우스를 올리고 있는 파일에 대한 정보를 분석할 수 있어야 합니다. Kotlin 파일은 Intellij 내에서 KtFile 형태 (Type)으로 분류됩니다. KtFile Type에는 클래스 내부 정보를 쉽게 알 수 있도록 몇몇 메서드들을 지원합니다. 그래서, 해당 메서드를 이용하여 좀 더 수정하여 Kotlin 파일을 분석하는 저만의 Parser 클래스를 작성하였습니다.
class KotlinClassParserImpl(
private val ktFile: KtFile
) : KotlinClassParser {
fun getClass(className: String): PsiClass {
val clazz = ktFile.classes.filter { it.name == className }
require(clazz.isNotEmpty()) { "Not Exist Class($className)" }
return clazz.first()
}
fun getClass(): PsiClass {
if (ktFile.classes.isEmpty()) throw IllegalAccessException("")
return ktFile.classes.first()
}
fun getMethod(className: String, methodName: String): PsiMethod {
val clazz = getClass(className)
val methods = clazz.allMethods.filter { it.name == methodName }
require(methods.isNotEmpty()) { "Is not exist method in class" }
return methods.first()
}
override fun getProperties(): List<KotlinField> {
val clazz = getClass()
return clazz.allFields
.filter { it.type.isExcludeType() }
.map { KotlinField.of(it) }
}
override fun getDirectoryAndPackage(): String {
return ktFile.packageFqName.toString()
}
override fun getClassName(): String {
return getClass().name ?: throw NoSuchElementException("Not Exist Class in this File")
}
private fun PsiType.isExcludeType(): Boolean {
if (this.canonicalText.contains("Companion")) {
return false
}
return true
}
fun getMethods(): Array<PsiMethod> {
val clazz = getClass()
return clazz.methods
}
}
여기서는 클래스 이름 혹은 메서드와 같은 현재 클래스에 대한 정보를 해석할 수 있는 기능을 제공하게 됩니다. 우리는 여기서 getProperties() 함수를 통해 우리가 원하던 Class 내부 의존성을 파악할 수 있게 됩니다.
테스트 파일 생성
이제 분석한 정보를 앞으로 클래스 메타 데이터(ClassMetaData)라고 표현하겠습니다. 이 클래스 메타 데이터(ClassMetaData)를 기반으로 아까 위에서 작성했던, 테스트 대상 시스템(SUT)에 모킹한 의존성을 넣는 코드를 작성해 봅시다.
테스트 파일을 생성할 때 아무런 규격이 없이 단순 문자열로만 파일을 생성하게 되면, 이후 오픈소스로 기여(Contribution)받게 되면 유지보수가 힘들어지고 규칙성 있게 코드를 작성하기 힘들어질 수 있어 Builder 라이브러리를 찾아보게 되었습니다.
팀에서 추천받은 KotlinPoet이라는 FileBuilder를 이용했는데, 아래 코드와 같이 빌더 방식으로 Kotlin 파일을 생성할 수 있습니다.
val main = FunSpec.builder("main")
.addStatement("var total = 0")
.beginControlFlow("for (i in 0 until 10)")
.addStatement("total += i")
.endControlFlow()
.build()
위 라이브러리를 이용하여 아래와 같이 테스트 파일을 생성하는 코드를 작성했습니다. 코드를 모두 보여주기에는 너무 길어져서 테스트 코드의 검증부 부분만 보여드리겠습니다.
TestClassTest라는 클래스는 의존성으로 List Type의 ages 변수를 가지고 있는 경우 아래 then
절의 결과와 같이 테스트 파일이 생성됩니다.
given("given Class Name And Property") {
val mockPackageName = "com.woowa.kotestboilerplate"
val mockClassName = "TestClass"
val mockType = "Int"
val mockFieldName = "age"
val mockKotlinType = mockk<KotlinType>(relaxed = true) {
every { simpleName } returns mockType
every { fqName } returns ""
every { wrappedType } returns null
}
val mockKotlinField = mockk<KotlinField>(relaxed = true) {
every { name } returns mockFieldName
every { type } returns mockKotlinType
}
val kotlinClassMetaData = mockk<KotlinClassMetaData> {
every { className } returns mockClassName
every { packageName } returns mockPackageName
every { properties } returns listOf(mockKotlinField)
}
val testConfig = TestBuilderConfig()
val kotlinTestBuilder = KotlinPoetTestBuilder(
kotlinClassMetaData = kotlinClassMetaData,
testBuilderConfig = testConfig,
behaviourSpecGenerator
)
`when`("when execute buildClass() ") {
val classContent = kotlinTestBuilder.buildUnitTestClass()
then("make test code classContent is public class TestClass") {
classContent shouldBe "package ${mockPackageName}\n" +
"\n" +
"import io.kotest.core.spec.style.BehaviorSpec\n" +
"import io.mockk.mockk\n" +
"\n" +
"public class ${mockClassName}Test : " +
"BehaviorSpec({\nval $mockFieldName: $mockType = mockk(relaxed = true)\n" +
"})\n"
}
}
}
지금까지 우리가 위에서 언급했던 단위 테스트(unit test)에서의 반복되는 부분을 쉽게 생성할 수 있도록 코드로 간략하게 확인해 보았습니다. 이제 실질적으로 어떻게 사용하는지 알아봅시다
마켓에 배포하기
플러그인을 마켓에 배포하려면 빌드하여 플러그인을 .jar
또는 .zip
파일로 만들어야 합니다.
.jar, .zip 파일로 만든 후 아래와 같이 IntelliJ Marketplace로 이동해 줍시다.
Upload Plugin을 누르면 자신이 작성한 .jar
또는 .zip
파일의 플러그인을 업로드할 수 있습니다.
업로드하면 Intellij 측에서 코드리뷰를 하게 되는데요. 현재 코드가 최신 IntelliJ 버전에서 호환될 수 있는지, 혹은 잘못된 부분이 있는지 리뷰를 해 주게 됩니다. 리뷰를 통과하면 아래와 같이 다운로드가 가능한 상태가 됩니다.
Kotest-Boilerplate 사용하기
(아래 글은 Kotest와 Mockk가 설치되었다는 가정 하에 작성하겠습니다.)
Intellij Plugin 메뉴를 클릭한 뒤 “Kotest-boilerplate”를 검색합니다.
검색이 잘 되면 Install 버튼을 눌러 설치하면 됩니다.
설치 후 아래 사진과 같이 클래스 파일에 들어가서 테스트 파일 생성 단축키인 (Cmd + Shift + T)를 누르고, “Create Kotest Boilerplate”를 누르면 단위 테스트 파일이 잘 생성되는 것을 확인할 수 있습니다.
개발하며 느낀 점
Intellij Plugin 개발에 관한 글도 많지 않고, 공식 문서만 참고해서는 온전히 개발하기 힘들어서 다른 플러그인 코드를 분석하며 개발하다 보니, 5일 정도 걸릴 것으로 예상했으나, 실제로는 7~10일 정도 걸렸습니다.
하지만, 사람들이 잘 시도해 보지 않은 분야에 부딪히며 플러그인을 완성하는 재미도 있었고, 그 과정에서 단위 테스트에 대해 배우는 것또한 많았습니다. 무엇보다 많은 분들이 사용해 주고 계셔서 개발한 보람도 느꼈습니다.
막간 홍보
아래 Github 저장소로 이동하면 팀 내에서 자주 사용하는 패턴을 확인하거나, 추가하면 좋은 기능을 제보하실 수 있습니다.
https://github.com/tmdgusya/kotlin-test-boilerplate