先日 Railroads という、Rails開発向けのIntelliJ Platform Pluginを作りました。
RubyMine 2023.3系から、rails routes を便利に扱える Railways プラグインが動かなくなったので、代替プラグイン Railroads を作りました - メモ的な思考的な
最初に作った段階では「動くものを作り切る」ことを優先し、
- 動作が正しいことは実機で担保
- テストコードは後で追加
という方針でプラグインを作成・リリースしました。
ただ、今後の継続的なメンテナンスのことを考えると、テストコードがあると色々安心できそうです。
そこで、今回テストコードを追加してみたことから、メモを残します。
目次
環境
- テストコードを書く対象のプラグインはRailroads
- IntelliJ IDEA Ultimate 2023.3.6 + Ruby plugin
- Kotest 5.8.1
- Mockk 1.13.10
- JUnit5 5.10.2
事前調査
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.
Kotestのテストフレームワークについて
RailroadsプラグインはKotlinで書いていることから、テストフレームワークもKotlinのものを使いたいと考えました。
そこでKotlinのテストフレームワークを調べたところ、 Kotest
がありました。
Kotest | Kotest
Kotestのドキュメントを見ると
など、いろいろ良さそうな機能がありました。
また、Kotlinのテストコードでモックを使いたい場合を調べたところ、 Mockk
を使うのが良さそうでした。
MockK | mocking library for Kotlin
テストコードを書くときの方針について
ここまでのドキュメントより、 Kotest
+ Mockk
+ IntelliJ Platform Pluginの BasePlatformTestCase
や IdeaTestFixtureFactory
などを使えば、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を追加する
テストコードで Kotest
や Mockk
を使えるようにするため、 build.gradle.kts
の dependencies
へ追加します。
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は describe
や context
・ it
キーワードを使って書けます。一方、検証は現在のRSpecのような expect記法ではなく、 shouldBe
などを使うようです。
続いて、 IntelliJ Platform Plugin SDKの機能が不要な部分を探したところ、 models/routes
ディレクトリのクラス群が該当しそうでした。
それらのクラスでは引数として module
というIntelliJ Platformの機能を受け取っていますが、内部では使っていません。
そこで、
module
をMockkでモックにする- Railwaysのテストコードをベースする
というのを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.kts
の dependencies
に対し、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
を使ったテストコードは
- Railwaysのテストコードをベースにする
- ワーキングディレクトリを取得するため、
System.getProperty("user.dir")
を使う module
は、BasePlatformTestCase
クラスの属性から取得する- アサーションは
BasePlatformTestCase
のTestCase.assertEquals
を使う
という形で実装したのが以下のテストコードです。
このテストコードを実行したところ、テストがパスしました。
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.kts
の dependencies
に対して追加します。
dependencies { // ... val junitVersion = "5.10.2" // ... // 追加 testImplementation("org.junit.jupiter:junit-jupiter-params:${junitVersion}") // ... }
ParameterizedTest を書く
ParameterizedTest を行うために以下を定義します。
- テストメソッドに以下のアノテーションを追加する
ParameterizedTest
アノテーションValueSource
でテストデータを直接指定するか、テストデータが複雑であればMethodSource
を使ってメソッドの戻り値をテストデータにする- 今回はそこそこ複雑なので
MethodSource
を使う
- 今回はそこそこ複雑なので
MethodSource
の場合、メソッドは以下のような形で作るcompanion object
にMethodSource
で指定したメソッドを用意する- メソッドには
JvmStatic
アノテーションを付ける - 今回の場合、戻り値の型は
Stream<Arguments>
にする
また、BasePlatformTestCase は JUnit5対応されていないのか、JUnit5の ParameterizedTest と組み合わせて使おうとするといくつか問題が発生します。
そこで、以下の対応も追加で行います。
※ JUnit5や IntelliJ Platform のバージョンによっては発生しないかもしれません。
- テストメソッドの中で
setUp
を呼ぶ - ダミーのテストメソッドを定義する
- 背景
- 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