Kotlin テスティングフレームワーク - Kotest

Kotlinのテスティングフレームワークについて紹介する。

Kotestとは?

Javaでは以前からJUnitがテストでよく使用されてきた。KotlinでもJUnitを使用してテストでき、実際によく使われている。JUnitはJavaで実装されたテスティングフレームワークだが、Kotestは純粋なKotlinで実装されたテスティングフレームワークである。Kotestというテストフレームワークを使用すると、JUnitに比べてKotlinのSyntaxを利用できるため、コード量を減らせる。また、テストケースをネストして書ける利点がある。

詳細は公式サイトに整理されているが、ここで簡単に紹介すると、大きな特徴として10種類のSpecと呼ばれるクラスが用意されており、好きなSpecを選んでテストを書ける点がある。Specはそれぞれさまざまな言語やテスティングフレームワークの影響を受けて作られており、他の言語からKotlinを始めた人は、自分の母語に近いテストSpecを選択できる。

Kotest公式サイト

そのほかにも、実験的な機能を含む多くの機能やAssert、Extensionが用意されている。

ここではKotestの基本構文について整理し、Kotestをサポートするライブラリについても説明する。

もともとKotestとは?

Kotlinで使用できるテスティングフレームワークである。以前はKotlinTestという名前だったが、リリース4.0からJetBrains提供パッケージとの混同を避けるためにKotestへ名前が変更された。

まずサンプルコードを見てみる。次のコードは公式サンプルテストコードである。

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

依存関係の追加

まず、Kotestを使用するにはdependencyを追加する必要がある。

dependencies {
    testImplementation("io.kotest:kotest-runner-junit5-jvm:5.6.2")
    testImplementation("io.kotest:kotest-framework-datatest:5.6.2")
}

Kotestを使用するだけなら上の依存関係だけでもよいが、データ駆動テストを書く場合は別モジュールとして用意されているため、上の2つを追加する。

IntelliJ IDEAでKotest環境を作る

IntelliJ IDEAでKotestを動かすには、Kotestプラグインをインストールする必要がある。

[Setting]-[Plugins]に移動し、Kotestを検索してインストールする。

Kotest Plugin

基本的なKotestの使い方

まず簡単に動作を確認してみる。

テストクラス - StringSpecを使ったテスト

ここでは、テストコードにStringSpecを使用するスタイルを説明する。

Kotestでは、テストクラスがどのSpecを継承するかによって、テストの書き方も異なる。そのほかにもWordSpecFunSpecShouldSpecなどがある。各Specの違いは公式ページが参考になるため、説明は省略する。

前述のとおり、Specによって書き方は異なるが、StringSpecには次の2つの方法がある。

StringSpecのコンストラクタにラムダで渡す方法

class CalcTest : StringSpec({
    // ここにテストを書く
})

次のコードは、非常に簡単に書けるStringSpecによるテストの書き方である。

package com.devkuma.kotest

import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe

class CalcTest : StringSpec({
    "test" {
        1 + 1 shouldBe 2
    }
})

テストクラスで目的のSpecクラスを継承し、コンストラクタ引数に処理ブロックを渡すことでテストを書いていく。

initブロックを使用する書き方

class CalcTest : StringSpec(){
    init {
      // ここにテストを書く
    }
}

上のテストコードを次のようにinitブロックで囲んで書くこともできる。

class CalcTest : StringSpec() {
    init {
        "test" {
            1 + 1 shouldBe 2
        }
    }
}

どちらの方法でもよいと思うが、コードのネストが深くなると可読性が落ちる可能性があるため、前者を推奨する。

ここでは、コンストラクタにラムダを渡す書き方を使用する。

AssertにはKotestで提供されているshouldBeを使用しているが、JUnitでいうとassertThatとほぼ同じだと考えてよい。おおむねこれをよく使用する。

マッチ関数

Kotestでマッチ関数をテストするためのテスト対象クラスは次のように書く。

class Calc {
    fun plus(a: Int, b: Int): Int {
        return a + b
    }

    fun divide(a: Int, b: Int): Int {
        return a / b
    }
}

では、実際にテストを書いてみる。

基本アサーション - shouldBe

次のテストのように、shouldBeを使用して値の正当性を確認できる。

StringSpecを継承すると、文字列でテスト内容を表現し、{}で囲んだ部分に実際のテストを書く方式である。

class CalcTest : StringSpec({
    "1 + 1は2である" {
        val calc = Calc()
        calc.plus(1, 1) shouldBe 2
    }

    "10 + 3は13である" {
        val calc = Calc()
        calc.plus(10, 3) shouldBe 13
    }
})

また、shouldBeと同じように「値がそうなっているべき」の否定を表すshouldNotBeもある。

    "1 + 4は4ではない。" {
        val calc = Calc()
        calc.plus(1,4) shouldNotBe 4
    }

ここでは関数名どおり、引数に渡された値になっていないためテストは成功する。

例外テスト - shouldThrow

例外をテストするにはshouldThrowを使用する。

    "0で割った場合Exceptionが発生する。" {
        val calc = Calc()
        shouldThrow<ArithmeticException> {
            calc.divide(10, 0)
        }
    }

また、shouldThrowは発生した例外オブジェクトを戻り値にするため、次のように例外オブジェクトに対するテストを書くこともできる。

        val exception = shouldThrow<ArithmeticException> {
            calc.divide(10, 0)
        }
        exception.message shouldBe "/ by zero"

ただし、shouldThrowは指定した例外またはその下位例外が発生した場合にアサーションが成功する点に注意する必要がある。 特定の例外が正確に発生したかを確認するには、shouldThrowExactlyを使用する。

データ駆動テスト(Data Driven Testing)

データ駆動テストは、入力値と出力結果値のパターンを作成してテストを実行する方式である。データ駆動なので、このデータパターンを先に作成することが主な作業であり、アサーション部分は関数を呼び出すだけでよい。

データ駆動テストの動作確認

例を見る方が理解しやすいため、簡単なデータ駆動テストを書くと次のようになる。

package com.devkuma.kotest

import io.kotest.core.spec.style.FunSpec
import io.kotest.datatest.withData
import io.kotest.matchers.shouldBe

enum class Operator {
    ADD, SUBTRACTION, MULTIPLICATION, DIVIDE
}

fun calculate(num1: Int, num2: Int, operator: Operator): Int {
    return when (operator) {
        Operator.ADD -> num1 + num2
        Operator.SUBTRACTION -> num1 - num2
        Operator.MULTIPLICATION -> num1 * num2
        else -> num1 / num2
    }
}

internal class DataDrivenTest : FunSpec({
    context("test calculate") {
        data class TestPattern(val num1: Int, val num2: Int, val operator: Operator, val result: Int)
        withData(
            TestPattern(1, 1, Operator.ADD, 2),
            TestPattern(3, 1, Operator.SUBTRACTION, 2),
            TestPattern(2, 3, Operator.MULTIPLICATION, 6),
            TestPattern(10, 5, Operator.DIVIDE, 2),
        ) { (num1, num2, operator, result) ->
            calculate(num1, num2, operator) shouldBe result
        }
    }
})

引数として指定された数値と演算子で計算した結果を返す関数をテストしている。まず、データ駆動テストをKotestで書く場合、ネストできるSpecである必要があるため、StringSpec以外のSpecを選択する必要がある。何でも構わないが、ここではFunSpecを選択した。

FunSpecの場合、contextブロック内に入力と期待する出力を含むデータテーブルをデータクラスで表現する。ここではcalculateメソッドの3つの引数と期待される戻り値を保存するように設定する。

withData関数の引数に定義したデータクラスを使用してデータパターンを提供する。テストしたいパターンをすべて書けたら、最後にデータクラスのすべての引数を引数とするラムダの中で処理を実行する。

このようにすると、次のような結果を得られる。

Test Results
  com.devkuma.kotest.DataDrivenTest
    test calculate
      V TestPattern(num1=1, num2=1, operator=ADD, result=2)
      V TestPattern(num1=3, num2=1, operator=SUBTRACTION, result=2)
      V TestPattern(num1=2, num2=3, operator=MULTIPLICATION, result=6)
      V TestPattern(num1=10, num2=5, operator=DIVIDE, result=2)

それぞれテストメソッドを作るより、はるかに簡潔に書ける。実際のプロジェクトでは、このように単純な入力と出力が定義された関数ではない場合もあるが、このような入力と出力のパターンが複数あると思われるときは、データ駆動テストを使うとよい。

では、上のcalculate関数にテストパターンを1つ追加してみる。

internal class DataDrivenTest : FunSpec({
    context("test calculate") {
        data class TestPattern(val num1: Int, val num2: Int, val operator: Operator, val result: Number)
        withData(
            TestPattern(1, 1, Operator.ADD, 2),
            TestPattern(3, 1, Operator.SUBTRACTION, 2),
            TestPattern(2, 3, Operator.MULTIPLICATION, 6),
            TestPattern(10, 5, Operator.DIVIDE, 2),
+           TestPattern(5, 0, Operator.DIVIDE, 0) // by zero
        ) { (num1, num2, operator, result) ->
            calculate(num1, num2, operator) shouldBe result
        }
    }
})

このように実行すると0で割ることになるため、ArithmeticException例外が発生する。

/ by zero
java.lang.ArithmeticException: / by zero
	at com.devkuma.kotest.DataDrivenTestKt.calculate(DataDrivenTest.kt:16)
	at com.devkuma.kotest.DataDrivenTest$1$1$1.invokeSuspend(DataDrivenTest.kt:30)

テストケースの命名

基本的にデータクラスのtoString()がテスト名として表示される。この表示を変えたい場合はいくつか方法があるが、まずWithDataTestNameをデータクラスに継承させることで表示を変更できる。

internal class DataDrivenTest : FunSpec({
    context("test calculate") {
        data class TestPattern(val num1: Int, val num2: Int, val operator: Operator, val result: Int) : WithDataTestName {
            override fun dataTestName(): String =
                "when num1: $num1 num2: $num2 operator: $operator, result is $result"
        }
        withData(
            TestPattern(1, 1, Operator.ADD, 2),
            TestPattern(3, 1, Operator.SUBTRACTION, 2),
            TestPattern(2, 3, Operator.MULTIPLICATION, 6),
            TestPattern(10, 5, Operator.DIVIDE, 2),
        ) { (num1, num2, operator, result) ->
            calculate(num1, num2, operator) shouldBe result
        }
    }
})

実行すると次のように表示される。

Test Results
  com.devkuma.kotest.DataDrivenTest
    test calculate
      V when num1: 1 num2: 1 operator: ADD, result is 2
      V when num1: 3 num2: 1 operator: SUBTRACTION, result is 2
      V when num1: 2 num2: 3 operator: MULTIPLICATION, result is 6
      V when num1: 10 num2: 5 operator: DIVIDE, result is 2

これは次のように書くこともできる。

internal class DataDrivenTest : FunSpec({
    context("test calculate") {
        data class TestPattern(val num1: Int, val num2: Int, val operator: Operator, val result: Int)
        withData(
            nameFn = { "when num1: ${it.num1} num2: ${it.num2} operator: ${it.operator}, result is ${it.result}" },
            TestPattern(1, 1, Operator.ADD, 2),
            TestPattern(3, 1, Operator.SUBTRACTION, 2),
            TestPattern(2, 3, Operator.MULTIPLICATION, 6),
            TestPattern(10, 5, Operator.DIVIDE, 2),
        ) { (num1, num2, operator, result) ->
            calculate(num1, num2, operator) shouldBe result
        }
    }
})

また、mapを使用してkeyにテスト名を指定できる。

internal class DataDrivenTest : FunSpec({
    context("test calculate") {
        data class TestPattern(val num1: Int, val num2: Int, val operator: Operator, val result: Int)
        withData(
            mapOf(
                "1 + 1 = 2" to TestPattern(1, 1, Operator.ADD, 2),
                "3 - 1 = 2" to TestPattern(3, 1, Operator.SUBTRACTION, 2),
                "2 x 3 = 6" to TestPattern(2, 3, Operator.MULTIPLICATION, 6),
                "10 / 5 = 2" to TestPattern(10, 5, Operator.DIVIDE, 2)
            )
        ) { (num1, num2, operator, result) ->
            calculate(num1, num2, operator) shouldBe result
        }
    }
})

実行すると次のように表示される。

Test Results
  com.devkuma.kotest.DataDrivenTest2
    test calculate
    V 1 + 1 = 2
    V 3 - 1 = 2
    V 2 x 3 = 6
    V 10 / 5 = 2

参考