😺

【1日1zenn - day19】Kotestのドキュメントを読み進める

2025/01/18に公開

ちょっと企画ロールのタスクが増えそうな感じなので、もうちょいボリュームを落としつつ、自分が開発してて一番しんどさを感じる技術負債系についてコツコツ勉強していく感じにします。
一旦バックエンドで今使っているKotestについてちゃんと勉強しようかと。

直近、セッションのある値がnullになっている異常系のテストでめちゃくちゃ詰まりました。
そういうテストで沼る時が一番しんどい。。。
結局それは違うテストのやり方をすることで解消したのですが、一旦基礎から積んでいこうかなと。

一周回ってテスト駆動開発をやるようになったらいいのかもしれない。広告運用から一番遠いスキルに点を打てそう。

ドキュメントを読み進める

https://kotest.io/docs/framework/framework.html

導入

Kotestはさまざまなスタイルでテストを作成できるとのこと
ドキュメントには以下のようなスタイルが最初に載っています

class MyTests : StringSpec({
   "length should return size of string" {
      "hello".length shouldBe 5
   }
   "startsWith should test for a prefix" {
      "world" should startWith("wor")
   }
})

ですが、contextとかitとかを使う書き方で書くこともできて、フロントで使っているテストライブラリに寄せた書き方にすることもできそう。

また入力パラメータの組み合わせとして、以下のような書き方もできるとのこと。

class StringSpecExample : StringSpec({
   "maximum of two numbers" {
      forAll(
         row(1, 5, 5),
         row(1, 0, 1),
         row(0, 0, 0)
      ) { a, b, max ->
         Math.max(a, b) shouldBe max
      }
   }
})

jestのtest.eachと同じ感じですかね。
上記の書き方のそれぞれの意味とかは後で出てくることでしょう。

また.config()を利用することで、テストの呼び出し回数、並列処理、タイムアウトなど、実行環境を指定できるとのこと。

class MySpec : StringSpec({
   "should use config".config(timeout = 2.seconds, invocations = 10, threads = 2, tags = setOf(Database, Linux)) {
      // test here
   }
})

無限ループを防いだり、複数回実行することで偶発的な失敗を防いだり、並列実行することでパフォーマンスを上げたりできるし、
タグで管理することによってCI/CDでテストを実行するなど効率的なテスト運用が可能、みたいなことをAIが言ってました。

設定

https://kotest.io/docs/framework/project-setup.html
これはKotestを使い始めるための設定の話っぽい。
Gradle + Kotlin を使用している場合は以下のようにランナーを追加するとのこと。

tasks.withType<Test>().configureEach {
   useJUnitPlatform()
}

そしてdependenciesに以下を追加する。

testImplementation 'io.kotest:kotest-runner-junit5:$version'

build.gradle.ktsに上記を入れると導入できるという感じ。
実際にこの前作ったKotlinとSpring Bootのリポジトリに追加してみたので、読み進めたのちに動かそうと思う。

テストを書く

https://kotest.io/docs/framework/writing-tests.html

Kotest では、テストは基本的にTestContext -> Unitテスト ロジックを含む関数です。この関数で呼び出され、例外をスローするすべてのアサート ステートメント ( Kotest の命名法ではmatchers ) はフレームワークによってインターセプトされ、そのテストを失敗または成功としてマークするために使用されます。

テストは関数で、例外を投げるとフレームワークが補足して成功や失敗を投げる

テスト関数は手動で定義されるのではなく、Kotest DSL を使用して定義されます。Kotest DSL は、これらの関数を作成およびネストするためのいくつかの方法を提供します。DSL には、特定の テスト スタイルを実装するクラスから拡張するクラスを作成することによってアクセスします。

Kotest特有の言語(DSL)で定義する。
いくつかの書き方があるとのこと。

Fun Specというスタイルだと以下みたいに、キーワード、名前、実際のテスト関数を作る

class MyFirstTestClass : FunSpec({

   test("my first test") {
      1 + 2 shouldBe 3
   }

})

どのスタイルでもネストした書き方ができる。
describeで統合して、内側のテストはit関数で書く以下みたいなやり方。

class NestedTestExamples : DescribeSpec({

   describe("an outer test") {

      it("an inner test") {
        1 + 2 shouldBe 3
      }

      it("an inner test too!") {
        3 + 4 shouldBe 7
      }
   }

})

Jestとかと一緒だからわかりやすいよねという。

動的

テストは単なる関数なので、実行時に評価されます。
このアプローチには大きな利点があり、テストを動的に作成できます。テストが常にメソッドであるためコンパイル時に宣言される従来の JVM テスト フレームワークとは異なり、Kotest は実行時に条件付きでテストを追加できます。

コンパイル時ではなく実行時に評価されるから、動的なテストを書けるとのこと。

class DynamicTests : FunSpec({

    listOf(
      "sam",
      "pam",
      "tim",
    ).forEach {
       test("$it should be a three letter name") {
           it.shouldHaveLength(3)
       }
    }
})

上記は、静的に書くと以下のようになる。

class DynamicTests : FunSpec({

   test("sam should be a three letter name") {
      "sam".shouldHaveLength(3)
   }

   test("pam should be a three letter name") {
      "pam".shouldHaveLength(3)
   }

   test("tim should be a three letter name") {
     "tim".shouldHaveLength(3)
   }
})

listとforEachみたいな書き方とかで、いくつかのパターンのテストをまとめて書くことができることを「動的」と表現してる感じなのかな。

ライフサイクル

Kotest は、テストのライフサイクル中のさまざまな時点で呼び出されるいくつかのコールバックを提供します。これらのコールバックは、状態のリセット、テストで使用される可能性のあるリソースの設定と破棄などに役立ちます。
前述のように、Kotest のテスト関数は、テスト コンテナまたはテスト ケースのいずれかのラベルが付けられ、それを含むクラスはスペックとしてラベル付けされます。テスト関数、コンテナ、テスト ケース、またはスペック自体の前または後に呼び出されるコールバックを登録できます。

複数のテストケースのそれぞれ実行前に状態をリセットするとかを書ける感じかな。
たとえば以下のようにするとのこと。

class Callbacks : FunSpec({

   beforeEach {
      println("Hello from $it")
   }

   test("sam should be a three letter name") {
      "sam".shouldHaveLength(3)
   }

   afterEach {
      println("Goodbye from $it")
   }
})

するとテストケースごとにまずHello from $itとかが出力され、テストが終わるとGoodbye from $itが出力されるようになる、と。
AIによると、上記の場合のitはテストケースの名前が入ってくるとのことで、"sam should be a three letter name"とかが入るっぽい。
どのテストの実行中にエラーが出たかを見るために重要そう。
そしてafterEachは最初に書いたりしてもいいらしい。

使い方は色々ありそう。

共通コードを抽出したい場合は、名前付き関数を作成し、複数のファイルで再利用することができます。たとえば、複数のファイルで各テストの前にデータベースをリセットしたい場合は、次のようにします。

val resetDatabase: BeforeTest = {
  // truncate all tables here
}

class ReusableCallbacks : FunSpec({

   beforeTest(resetDatabase)

   test("this test will have a sparkling clean database!") {
       // test logic here
   }
})

こうするとテスト前に毎回DBをリセットしてテストできるらしい。
beforeTestとbeforeEachの違いはAIによると、beforeTestの場合はグローバルに適用されるとかがありそう。またbeforeEachはシンプルな処理しかできないっぽい。関数を受け取ったりできない感じなのかな。
まあ読み進めると書いてありそうなので、一旦ペンド。

一旦終える

ミニマムにやるので一旦終える。次回はテストスタイル以降を読んでいく。

Discussion