IntelliJ Platform Plugin開発にて、KotestやBasePlatformTestCaseを使ったテストコードを書いてみた

先日 Railroads という、Rails開発向けのIntelliJ Platform Pluginを作りました。
RubyMine 2023.3系から、rails routes を便利に扱える Railways プラグインが動かなくなったので、代替プラグイン Railroads を作りました - メモ的な思考的な

 
最初に作った段階では「動くものを作り切る」ことを優先し、

  • 動作が正しいことは実機で担保
  • テストコードは後で追加

という方針でプラグインを作成・リリースしました。

ただ、今後の継続的なメンテナンスのことを考えると、テストコードがあると色々安心できそうです。

 
そこで、今回テストコードを追加してみたことから、メモを残します。

 
目次

 

環境

 

事前調査

Kotlin + IntelliJ Platform Pluginの環境でテストコードを書くのは初めてだったので、事前にいくつか調査しました。

 

IntelliJ Platform Pluginでテストコードを書くには

公式ドキュメントにはテストに関する記載があります。
Testing Overview | IntelliJ Platform Plugin SDK

これによると、IntelliJ Platform Plugin SDKでは

  • プラットフォームの機能のモック
  • UIテストをするための機能

などのテスト向けの機能が提供されているようでした。

 
また、IntelliJ Platformのテストは特定のフレームワークに依存せずに書くこともできるようです。

When writing your tests, you have the choice between using a standard base class to perform the test set up for you and using a fixture class, which lets you perform the setup manually and does not tie you to a specific test framework.

 

With the former approach, you can use classes such as BasePlatformTestCase (LightPlatformCodeInsightFixtureTestCase before 2019.2).

 

With the latter approach, you use the IdeaTestFixtureFactory class to create instances of fixtures for the test environment. You need to call the fixture creation and setup methods from the test setup method used by your test framework.

 
Tests and Fixtures | IntelliJ Platform Plugin SDK

 

Kotestのテストフレームワークについて

RailroadsプラグインはKotlinで書いていることから、テストフレームワークもKotlinのものを使いたいと考えました。

そこでKotlinのテストフレームワークを調べたところ、 Kotest がありました。
Kotest | Kotest

 
Kotestのドキュメントを見ると

など、いろいろ良さそうな機能がありました。

 
また、Kotlinのテストコードでモックを使いたい場合を調べたところ、 Mockk を使うのが良さそうでした。
MockK | mocking library for Kotlin

 

テストコードを書くときの方針について

ここまでのドキュメントより、 Kotest + Mockk + IntelliJ Platform Pluginの BasePlatformTestCaseIdeaTestFixtureFactory などを使えば、Kotlinでテストコードを書けそうでした。

ただ、 IdeaTestFixtureFactory の使い方は公式ドキュメントに記載されておらず、使いこなすには他のプラグインのテストコードを読んだりする必要がありそうでした。

 
そこで、現時点では「プラグインに対してテストコードを書く」ことを目的として、以下の方針でテストコードを書くことにしました。

  • テストを書く上で IntelliJ Platform Plugin SDKの機能が不要な部分は、Kotest で書く
  • テストを書く上で IntelliJ Platform Plugin SDKの機能が必要な部分は、 JUnit5 + BasePlatformTestCase で書く

 

Kotestでテストを書く

まずはKotestで書いてみます。

ただ、Railroadsプラグインでは IntelliJ Platform Plugin Template を使っていますが、そのテンプレートには Kotest の設定がありません。
https://github.com/JetBrains/intellij-platform-plugin-template

 
そこで今回は、Kotest を追加してから、Kotestでテストを書いていきます。

 

KotestやMockkを追加する

テストコードで KotestMockk を使えるようにするため、 build.gradle.ktsdependencies へ追加します。

dependencies {
    implementation(libs.annotations)
    val kotestVersion = "5.8.1"
    testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion")
    testImplementation("io.kotest:kotest-assertions-core:$kotestVersion")

    val mockkVersion = "1.13.10"
    testImplementation("io.mockk:mockk:${mockkVersion}")
}

 

Kotest プラグインを追加する

Railroadsプラグインを書く IntelliJ IDEA Ultimateには、Kotestを容易に実行するための機能は提供されていません。

一方、Kotestでは IntelliJ IDEA向けのプラグインを提供しています。
IntelliJ Plugin | Kotest

そこで、開発用のIntelliJ IDEA UltimateにKotestプラグインを追加しておきます。
Kotest Plugin for JetBrains IDEs | JetBrains Marketplace

 

KotestのDescribe Specでテストコードを書く

公式ドキュメントや以下の記事にある通り、Kotestではテストコードのスタイルが複数用意されています。
Kotestの各種Specを比べてみた #Kotlin - Qiita

 
RailroadsはRails開発向けのプラグインであることから、Rails開発で見慣れている Describe Spec で書くことにします。

ちなみに、Describe Specは describecontextit キーワードを使って書けます。一方、検証は現在のRSpecのような expect記法ではなく、 shouldBe などを使うようです。

 
続いて、 IntelliJ Platform Plugin SDKの機能が不要な部分を探したところ、 models/routes ディレクトリのクラス群が該当しそうでした。

それらのクラスでは引数として module というIntelliJ Platformの機能を受け取っていますが、内部では使っていません。

 
そこで、

というのをKotestで実装したのが以下のテストコードです。

このテストコードを実行したところ、テストがパスしました。

package com.github.thinkami.railroads.models.routes

import com.github.thinkami.railroads.ui.RailroadIcon
import com.intellij.openapi.module.Module
import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.shouldBe
import io.mockk.mockk

class RedirectRouteTest: DescribeSpec({
    describe("RedirectRoute") {
        context ("set value at redirect route path") {
            // RedirectRoute does not use modules, so mock module
            val module = mockk<Module>()
            val actual = RedirectRoute(
                module,
                "GET",
                "/test",
                "redirect",
                "/test_redirect"
            )

            it("the title includes path") {
                actual.getActionIcon().shouldBe(RailroadIcon.NodeRedirect)
                actual.getActionTitle().shouldBe("/test_redirect")
                actual.getQualifiedActionTitle().shouldBe("redirect to /test_redirect")
            }
        }

        context ("set null at redirect route path") {
            // RedirectRoute does not use modules, so mock module
            val module = mockk<Module>()
            val actual = RedirectRoute(
                module,
                "GET",
                "/test",
                "redirect",
                null
            )

            it("the title is a fixed value") {
                actual.getActionIcon().shouldBe(RailroadIcon.NodeRedirect)
                actual.getActionTitle().shouldBe("[redirect]")
                actual.getQualifiedActionTitle().shouldBe("[runtime define redirect]")
            }
        }
    }
})

 

BasePlatformTestCaseでテストを書く

IntelliJ Platformの module などを使っている場合、 IdeaTestFixtureFactory を使えばテストコードは書けそうです。

ただ、前述の通り、 IdeaTestFixtureFactory に関する情報があまり得られないことから、現時点ではこの場合のテストコードをKotestで書くのが難しいと考えています。

 
そこで、この場合は、

  • BasePlatformTestCase を継承したテストクラスを使う
  • テストランナーとしてJUnit5を使う
    • KotestのテストランナーとしてJUnit5を使っているため、同じようにJUnit5を使いたい

という方針で進めます。

 

JUnit5を追加する

先ほどKotest向けのJUnit5の設定は追加しましたが、JUnit5向けの設定は追加していませんでした。

そこで、 build.gradle.ktsdependencies に対し、JUnit5を使うために必要な設定を追加します。

dependencies {
    // ...
    val junitVersion = "5.10.2"
    testImplementation("org.junit.jupiter:junit-jupiter-api:${junitVersion}")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${junitVersion}")
    testRuntimeOnly("org.junit.vintage:junit-vintage-engine:${junitVersion}")
}

 

BasePlatformTestCaseクラスを継承してテストコードを書く

BasePlatformTestCase クラスを継承する例として、ここでは RailsRoutesParser の parse メソッドに対するテストコードを取り上げます。

parse メソッドでは rails routes の出力結果を、Railroadsで扱いやすいクラスの配列へと変換します。

そのため、テストでは配列の要素数が想定通りになっているかを検証すれば良さそうです。

 
ところで、JUnit5では rails routes を実行することができません。そこで

  • 事前に rails routes の実行結果をファイルへ保存する
  • テストコードではファイルを読み込む

という形にします。

今回は、以下の内容を src/test/testData/RailsRoutesParserTest.data.txt として保存します。

                                  Prefix Verb     URI Pattern                                                                                       Controller#Action
                          multiple_match GET|POST /multiple_match(.:format)                                                                         multiple#call
                      blog_post_comments GET      /blogs/:blog_id/posts/:post_id/comments(.:format)                                                 blogs/posts/comments#index
                                         POST     /blogs/:blog_id/posts/:post_id/comments(.:format)                                                 blogs/posts/comments#create
                   new_blog_post_comment GET      /blogs/:blog_id/posts/:post_id/comments/new(.:format)                                             blogs/posts/comments#new
                  edit_blog_post_comment GET      /blogs/:blog_id/posts/:post_id/comments/:id/edit(.:format)                                        blogs/posts/comments#edit
                       blog_post_comment GET      /blogs/:blog_id/posts/:post_id/comments/:id(.:format)                                             blogs/posts/comments#show
                                         PATCH    /blogs/:blog_id/posts/:post_id/comments/:id(.:format)                                             blogs/posts/comments#update
                                         PUT      /blogs/:blog_id/posts/:post_id/comments/:id(.:format)                                             blogs/posts/comments#update
                                         DELETE   /blogs/:blog_id/posts/:post_id/comments/:id(.:format)                                             blogs/posts/comments#destroy

 
続いてテストコードを書きます。

BasePlatformTestCase を使ったテストコードは

という形で実装したのが以下のテストコードです。

このテストコードを実行したところ、テストがパスしました。

package com.github.thinkami.railroads.parser

import com.intellij.testFramework.fixtures.BasePlatformTestCase
import junit.framework.TestCase
import java.io.File
import java.io.FileInputStream

class RailsRoutesParserParseTest: BasePlatformTestCase() {
    fun testParse() {
        val basePath = System.getProperty("user.dir")
        val inputStream = FileInputStream(File(basePath, "src/test/testData/RailsRoutesParserTest.data.txt"))
        val parser = RailsRoutesParser(module)
        val actual = parser.parse(inputStream)

        // 8 routes and 1 multiple route
        TestCase.assertEquals(10, actual.size)
    }
}

 

BasePlatformTestCase で ParameterizedTest を使う

ここでは RailsRoutesParser の parseLine メソッドに対するテストコードを取り上げます。

 
parseLine メソッドでは、 rails routes の出力結果をRailroadsで扱いやすいクラスへと変換します。

そのため、 rails routes の種類ごとにテストメソッドを用意すればよさそうです。ただ、この書き方だとテストメソッドが増えてしまい、メンテナンスが大変そうです。

 
そこで今回は、以下のJUnit5のドキュメントにある ParameterizedTest を使って検証とテストデータを分離します。
https://junit.org/junit5/docs/current/user-guide/#writing-tests-parameterized-tests

 

ParameterizedTest を使うために必要なパッケージを追加する

build.gradle.ktsdependencies に対して追加します。

dependencies {
    // ...
    val junitVersion = "5.10.2"
    // ...
    // 追加
    testImplementation("org.junit.jupiter:junit-jupiter-params:${junitVersion}")
    // ...
}

 

ParameterizedTest を書く

ParameterizedTest を行うために以下を定義します。

  • テストメソッドに以下のアノテーションを追加する
    • ParameterizedTest アノテーション
    • ValueSource でテストデータを直接指定するか、テストデータが複雑であれば MethodSource を使ってメソッドの戻り値をテストデータにする
      • 今回はそこそこ複雑なので MethodSource を使う
  • MethodSource の場合、メソッドは以下のような形で作る
    • companion objectMethodSource で指定したメソッドを用意する
    • メソッドには JvmStatic アノテーションを付ける
    • 今回の場合、戻り値の型は Stream<Arguments> にする

 
また、BasePlatformTestCase は JUnit5対応されていないのか、JUnit5の ParameterizedTest と組み合わせて使おうとするといくつか問題が発生します。

そこで、以下の対応も追加で行います。
※ JUnit5や IntelliJ Platform のバージョンによっては発生しないかもしれません。

  • テストメソッドの中で setUp を呼ぶ
    • 背景
      • 何もしないと BasePlatformTestCasesetUp が呼ばれないようで、module などののIntelliJ Platform Plugin SDKで必要な値が BasePlatformTestCase に設定されない
      • そこで、ややトリッキーではあるがテストメソッドの中で setUp を呼び、 module などを設定する
  • ダミーのテストメソッドを定義する
    • 背景
      • ParameterizedTest だけを BasePlatformTestCase に定義した場合、テストメソッドがないと誤認されてしまう
      • そこで、常にパスするダミーのテストメソッドを定義することで、ParameterizedTest も認識してもらえるようにする

 
また、ここではRailwaysの以下のテストコードをベースにします。
https://github.com/basgren/railways/blob/master/test/net/bitpot/railways/parser/RailsRoutesParseLineTest.java

 
上記を踏まえて実装したのが以下のテストコードです。

このテストコードを実行したところ、テストがパスしました。

package com.github.thinkami.railroads.parser

import com.github.thinkami.railroads.models.routes.RedirectRoute
import com.github.thinkami.railroads.models.routes.SimpleRoute
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import junit.framework.TestCase
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource
import java.util.stream.Stream

class RailsRoutesParserParseLineTest: BasePlatformTestCase() {
    // The fact that BasePlatformTestCase predates JUnit5 may have an impact.
    //
    // If there is no test method and only JUnit5's parameterized test is used,
    // the test will fail with the following error.
    // junit.framework.AssertionFailedError: No tests found in com.github.thinkami.railroads.parser.RailsRoutesParserParseLineTest
    fun testDummy() {
        TestCase.assertEquals(1, 1)
    }

    @ParameterizedTest
    @MethodSource("variousRoute")
    fun testParseVariousLine(line: String, routeClass: String, routeName: String, method: String, path: String, actionTitle: String) {
        // The parameterized test is written in JUnit5, but the BasePlatformTestCase is implemented in a format earlier than JUnit5.
        // If nothing is done, the setUp method of BasePlatformTestCase is not executed and the module is not set in the fixture.
        // Therefore, by calling the setUp method, the module is set.
        setUp()

        val parser = RailsRoutesParser(module)
        val parsedLine = parser.parseLine(line)

        TestCase.assertNotNull(parsedLine)
        TestCase.assertEquals(1, parsedLine.size)

        val actual = parsedLine.first()
        TestCase.assertEquals(routeName, actual.routeName)
        TestCase.assertEquals(routeClass, actual::class.simpleName)
        TestCase.assertEquals(method, actual.requestMethod)
        TestCase.assertEquals(path, actual.routePath)
        TestCase.assertEquals(actionTitle, actual.getActionTitle())
    }

    companion object {
        @JvmStatic
        fun variousRoute(): Stream<Arguments> {
            return Stream.of(
                Arguments.arguments(
                    "    blog_post_comments GET      /blogs/:blog_id/posts/:post_id/comments(.:format)   blogs/posts/comments#index",
                    SimpleRoute::class.simpleName,
                    "blog_post_comments",
                    "GET",
                    "/blogs/:blog_id/posts/:post_id/comments(.:format)",
                    "blogs/posts/comments#index"),
                Arguments.arguments(
                    "  PUT      /blogs/:blog_id/posts/:post_id/comments/:id(.:format)   blogs/posts/comments#update",
                    SimpleRoute::class.simpleName,
                    "",
                    "PUT",
                    "/blogs/:blog_id/posts/:post_id/comments/:id(.:format)",
                    "blogs/posts/comments#update"),
                Arguments.arguments(
                    "  PATCH    /blogs/:blog_id/posts/:post_id/comments/:id(.:format)    blogs/posts/comments#update",
                    SimpleRoute::class.simpleName,
                    "",
                    "PATCH",
                    "/blogs/:blog_id/posts/:post_id/comments/:id(.:format)",
                    "blogs/posts/comments#update"),
                Arguments.arguments(
                    "   DELETE   /blogs/:blog_id/posts/:post_id/comments/:id(.:format)   blogs/posts/comments#destroy",
                    SimpleRoute::class.simpleName,
                    "",
                    "DELETE",
                    "/blogs/:blog_id/posts/:post_id/comments/:id(.:format)",
                    "blogs/posts/comments#destroy"),

                // route for Rack application
                Arguments.arguments(
                    "         rack_app    /rack_app(.:format)      #<HelloRackApp:0x000001988fad1b40>",
                    SimpleRoute::class.simpleName,
                    "rack_app",
                    "",
                    "/rack_app(.:format)",
                    "<HelloRackApp:0x000001988fad1b40>"),

                // inline handler
                Arguments.arguments(
                    "        inline GET      /inline(.:format)       Inline handler (Proc/Lambda)",
                    SimpleRoute::class.simpleName,
                    "inline",
                    "GET",
                    "/inline(.:format)",
                    ""),

                // route with additional requirements
                Arguments.arguments(
                    "  GET      /photos/:id(.:format)      photos#show {:id=>/[A-Z]\\d{5}/}",
                    SimpleRoute::class.simpleName,
                    "",
                    "GET",
                    "/photos/:id(.:format)",
                    "photos#show"),

                // redirect route
                Arguments.arguments(
                    "  redirect GET      /redirect(.:format)         redirect(301, /blogs)",
                    RedirectRoute::class.simpleName,
                    "redirect",
                    "GET",
                    "/redirect(.:format)",
                    "/blogs"),
            )
        }
    }
}

 

ソースコード

Railroadsのリポジトリにはテストコードを追加済です。
https://github.com/thinkAmi/railroads

テストコードを追加したときのプルリクはこちら。
https://github.com/thinkAmi/railroads/pull/10