RAKUS Developers Blog | ラクス エンジニアブログ

株式会社ラクスのITエンジニアによる技術ブログです。

Kotestで使われる不思議な記法の裏側について調べてみた

はじめに

皆さん!初めまして! 楽楽請求新卒エンジニアの kaihatsuda です。

本記事では、Kotlin のテストフレームワーク Kotest に使われている Kotlin の特徴的な記法や技術を紐解いていきます! (本記事は Kotest の 使い方解説 ではなく、その背後にある Kotlin の技術を理解することに焦点を当てていますので、ご了承ください。)

私たちが開発する 楽楽請求 では、サーバーサイドの実装に Kotlin を採用しています。テストコードの記述も開発サイクルに欠かせない重要な工程の一部として位置づけられており、品質を担保するための必須要素になっています。特に、テストコードの 簡潔さ可読性の高さ は、効率的な開発において非常に重要です。

楽楽請求では、単体テストを記述するために Kotest を採用しています。私自身、学生時代に単体テストを書く経験がほとんどなく不安でしたが、配属後に初めて触れた Kotest で以下のようなテストコードに触れたとき、その直感的な書き方に感動しました。

初めて触れた Kotest のコードのイメージ

class MySpec : FunSpec({
    context("四則演算の正常系") {
        test("正しく加算できる") {
            (2 + 2) shouldBe 4
        }
        test("正しく減算できる") {
            (9 - 2) shouldBe 7
        }
    }
    context("四則演算の異常系") {
        test("0で除算すると算術エラーが投げられる") {
            shouldThrow<ArithmeticException> {
                val result = 1 / 0
            }
        }
    }
})

このコードを見たとき、私はこう思いました。

「なんだこの shouldBe とか context とかいう書き方...
でも、めちゃくちゃ可読性が高いし直感的に書けてすごい!」

一見すると、まるで普通の日本語の 箇条書き のように見えませんか?

  • context("四則演算の正常系") の中に、test("正しく加算できる") がある
  • (2 + 2) shouldBe 4 は「2+2は4であるべき」という意味のテストである
  • 正常系と異常系を文脈で分けて記述している

Kotlin を知らなくても、何をテストしているのか直感的に理解できそうです。

でも疑問も浮かびます

  • shouldBe はまるで演算子みたいにスペース区切りで呼べるのは何故?」
  • 「なんで波括弧 {} の中に contexttest が並んでるの? なぜ呼び出せるの?」
  • shouldThrow<ArithmeticException> はどうやって型をチェックしているの?」

こうした Kotest の "魔法のような記法" を支えているのは、Kotlin の infix拡張関数レシーバ付きラムダ式inline & reified といった特徴的な機能です。(その他にも様々な技術が使われていますが、今回はここまでに留めます)

本記事のゴール

本記事では、Kotest の「魔法のような記法」に着目し、その裏側で活用されている Kotlin の特有機能を解説します。具体的には、

  • infix によるスペース区切りのメソッド呼び出し
  • 拡張関数による既存クラスへの振る舞い追加
  • レシーバ付きラムダ式を使ったスコープ風の記述
  • inline & reified を活用したジェネリクスの型情報保持

といった機能が、Kotest の柔軟で読みやすい記法を支えている仕掛けです。

そこで本記事では、Kotest を参考に作成した簡易ライブラリを例に取り、これらの機能が実際にどのように使われているかをコード例を示しながらひとつひとつを紐解きます。

本記事の最終的なゴールは、以下の2点です。

  1. Kotlin の各機能(infix, 拡張関数, レシーバ付きラムダ式, inline & reified)の役割と使い方を理解する
    • それぞれの機能が「なぜ必要か」「どんな恩恵があるか」を知り、自分のコードでも応用できるようにする。
  2. Kotest の DSL が「不思議」に見える仕組みを納得する
    • Kotest のソースコードに実際にどう活かされているかをイメージできるようになり、より深いレベルで Kotest を使いこなせるようになる。

これにより 「Kotest の使い方」ではなく、「Kotest が成立する背景の Kotlin 技術」 を学びます。もしご自身のプロジェクトでテスト DSL(Domain-Specific Language) を拡張したり、別の場面で同様の記法を取り入れたくなったときに、本記事の内容がヒントになれば幸いです。

本記事で読み解くコード例

本記事では、解説のためKotestライクな簡易版のテストライブラリを用意しました。シンプルですが、使用例の通り Kotest っぽいテストの記述を実現しています。今後本記事では、以下のライブラリを 簡易ライブラリ と呼びます。

// レシーバ付きラムダ式で DSL を定義
fun miniSpec(block: MiniSpec.() -> Unit): MiniSpec {
    return MiniSpec().apply {
        block()
    }
}

class MiniSpec {
    fun context(description: String, block: MiniSpec.() -> Unit) {
        println("Context: $description")
        block()
    }

    fun test(description: String, block: () -> Unit) {
        println("Test: $description")
        block()
    }
}

// infix & 拡張関数によるアサーション (型安全化)
infix fun <T, U : T> T.shouldBe(expected: U?) {
    if (this != expected) {
        throw AssertionError("Expected $expected but got $this")
    }
}

// inline & reified を用いた例外検証
inline fun <reified E : Throwable> shouldThrow(block: () -> Any?): E {
    val thrown = try {
        block()
        null
    } catch (e: Throwable) {
        e
    }

    return when {
        thrown == null -> throw AssertionError("Expected exception ${E::class.simpleName} but none was thrown.")
        thrown is E -> thrown
        else -> throw AssertionError("Expected exception ${E::class.simpleName} but ${thrown::class.simpleName} was thrown.")
    }
}

// 使用例
fun main() {
    miniSpec {
        context("四則演算の正常系") {
            test("正しく加算できる") {
                (2 + 2) shouldBe 4
            }
            test("正しく減算できる") {
                (9 - 2) shouldBe 7
            }
        }
        context("四則演算の異常系") {
            test("0で除算すると算術エラーが投げられる") {
                shouldThrow<ArithmeticException> {
                    val result = 1 / 0
                }
            }
        }
    }
}

それぞれの記法の解説

1. infix

簡易ライブラリのmain関数には以下のような記述があります。

test("正しく加算できる") {
    (2 + 2) shouldBe 4
}

(2 + 2) shouldBe 4」と書かれている部分は、Kotest ユーザーにはおなじみの書き方ですが、初めて見ると違和感があります。まるで shouldBe演算子であるかのように見えますよね。 これは、infix 関数 と呼ばれる Kotlin の機能を使うことで「.() を省略」しているだけです。

infixの概要

Kotlin には、特定の条件を満たした拡張関数やメンバー関数に infix キーワードを付けると、

obj.methodName(arg)

という呼び方を

obj methodName arg

のように 演算子 に書ける仕組みがあります。 公式ドキュメントでも「infix notation (omitting the dot and the parentheses)」と紹介されており、次の3つの要件を満たせば使用できます。

  1. メンバー関数または拡張関数であること
  2. パラメータが1つだけであること
  3. 可変長引数やデフォルト引数を持たないこと

簡易ライブラリでは、infixは以下のように利用されていました。

infix fun <T, U : T> T.shouldBe(expected: U?) {
    if (this != expected) {
        throw AssertionError("Expected $expected but got $this")
    }
}

簡易ライブラリの通り、不思議に感じていたshouldBeは単なる関数であることが分かります(厳密には後述する拡張関数が利用されています)。そのため、(2 + 2).shouldBe(4)として実行することも可能です。スペースを使った関数呼び出しが出来るinfixによって、英語のように直感的に読めるテストコードが記述出来ていた、というわけですね。

2. 拡張関数

先ほど紹介したshouldBeinfix関数であると同時に、拡張関数 として実装されています。拡張関数を利用することで、既存のクラスに対して新しい振る舞いを追加できます。

拡張関数を定義するには、レシーバ型(拡張したい型)を関数名の前に付け加えます。以下は、MutableList<Int>に要素を入れ替えるためのswap関数を追加する例です。

fun MutableList<Int>.swap(index1: Int, index2: Int) {
    val tmp = this[index1] // 'this' corresponds to the list
    this[index1] = this[index2]
    this[index2] = tmp
}

ここで、拡張関数内の thisは、関数が呼び出された対象のインスタンスを指します。上記の swap 関数は、MutableList<Int>に対して次のように呼び出すことができます。

val list = mutableListOf(1, 2, 3)
list.swap(0, 2) 
println(list)    // 出力: [3, 2, 1]

以上が拡張関数の簡単な紹介です。但し「クラス内部に実際に新たに関数を追加しているのではなく、あくまで振る舞いを拡張するのみ」という点に注意して下さい[参考]。

Extensions do not actually modify the classes they extend. By defining an extension, you are not inserting new members into a class, only making new functions callable with the dot-notation on variables of this type.

では、改めて簡易ライブラリにおける拡張関数の利用例を見てみます。

infix fun <T, U : T> T.shouldBe(expected: U?) {
    if (this != expected) {
        throw AssertionError("Expected $expected but got $this")
    }
}

shouldBe 関数は、ジェネリクスTを拡張する形で定義しています。そのため、Kotlin のすべての型に対して呼び出し可能です。さらに型制約 U : T とすることで、expected 引数が T と互換性のある型であることを保証しています。これにより、型安全性を保ちながら直感的なアサーションを実現しています。

3. レシーバー付きラムダ式

次は「miniSpec { ... }context { ... } の波括弧がどうなっているのか?」という部分です。 以下のような階層的なテスト構造が Kotlin 特有の機能を活用して実現されています。

miniSpec {
    context("四則演算の正常系") {
        test("正しく加算できる") {
            (2 + 2) shouldBe 4
        }
        test("正しく減算できる") {
            (9 - 2) shouldBe 7
        }
    }
    ....
}

見た目としては「波括弧 {} を多重に使った単純な入れ子構造」のように見えますが、実際には Kotlin 特有の仕組みである レシーバ付きラムダ式 を活用しています。ラムダ式を引数に取る関数で「(レシーバ型).() -> R の形」を受け取った場合、そのラムダ式の中ではレシーバを this(省略可)で扱うことができます。(先ほど説明した拡張関数においては、拡張したい既存クラスがレシーバー型に相当します)

言葉だけでは分かりにくいので、通常のラムダ式と、レシーバ付きラムダ式を具体例から比較してみます。イメージのしやすさのため、スコープ関数の alsoapply を比較します。

通常のラムダ式(例: also)

public inline fun <T> T.also(block: (T) -> Unit): T {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    block(this)
    return this
}

// `also`: 通常のラムダ式を使用
val user2 = User("Alice", 25).also {
    it.name = "Bob"  // レシーバではなく、`it` で明示的に参照
    it.age = 30
}

レシーバ付きラムダ式(例: apply)

public inline fun <T> T.apply(block: T.() -> Unit): T {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    block()
    return this
}

val user1 = User("Alice", 25).apply {
    name = "Bob"  // レシーバとしての `this` が省略可能
    age = 30
}

alsoapplyを比較すると、次のような違いが分かります。

  • also: 通常のラムダ式を使用し、it で対象オブジェクトを明示的に参照。
  • apply: レシーバ付きラムダ式を使用するため、this が暗黙的に参照され、プロパティを簡潔に操作可能。

このように、レシーバ付きラムダ式によって、渡す側のラムダ式this を暗黙的に使う(省略する) ことが出来ます。

簡易ライブラリにおけるレシーバ付きラムダ式の使い方を見てみます。

fun miniSpec(block: MiniSpec.() -> Unit): MiniSpec {
    return MiniSpec().apply {
        block()
    }
}

class MiniSpec {
    fun context(description: String, block: MiniSpec.() -> Unit) {
        println("Context: $description")
        block()
    }

    fun test(description: String, block: () -> Unit) {
        println("Test: $description")
        block()
    }
}

レシーバ付きラムダ式の観点から解説すると以下のようになります。

  • miniSpec(block: MiniSpec.() -> Unit):

    • MiniSpec をレシーバとするラムダ式(MiniSpec.() -> Unit)を引数として受け取り、そのスコープ内でテストを定義可能にする。
    • apply を使ってラムダ式を実行し、MiniSpec のメソッド(context(), test())を直接呼び出せるようにする。
  • context(description: String, block: MiniSpec.() -> Unit):

    • context() で、ラムダ式MiniSpec.() -> Unit を受け取ることで、さらにネストされた test("...") を同じレシーバ(= MiniSpec)で呼び出せる

このような仕掛けのおかげで、「context の中でさらに test、その中でアサーション」など、自然な階層構造を作れるDSL的な書き方になっています。

4. inline & reified

最後に紹介するのは、例外検証を簡潔に行うために用いた inlinereified です。 Kotest の shouldThrow<T>() と同じように、簡易ライブラリでは次のように記述できます。

shouldThrow<ArithmeticException> {
    val result = 1 / 0
}

この 型を指定して例外を検証する 部分が inline & reified で実現されています。

4.1 inline

Kotlinでは、コンパイラが状況に応じて匿名クラスに変換するケースがあります。ラムダ式はその一例ですが、これにより、関数呼び出しに関わるオーバーヘッドが発生します。

一方、 inline キーワードを使うと、コンパイラが関数の呼び出しを呼び出し元のコードにインライン展開(埋め込み)するよう最適化します。つまり、わざわざ匿名クラスを作成して実行するのではなく、元のコードに直接ラムダ式の処理を書き込むイメージです(参考)。

公式ドキュメント(Inline functions)の冒頭でも「インライン関数は呼び出しのオーバーヘッドを削減してくれる」ことが紹介されています。 これにより:

  • ラムダ式呼び出しに伴うオブジェクト生成のオーバーヘッドが消える
  • 後述する reified が使用可能になる

というメリットが得られます。

4.2 reified

Kotlin では、Java と同じく型消去(type erasure)の概念があります。 ジェネリック型パラメータ(TE など)はコンパイル時にチェックされるだけで、実行時にその型情報が失われます。 例えば以下のようなコードで、実行時には「List<Int>List<String> の違いが分からない」などの状況が起こります。

fun checkList(list: List<Any>) {
    // 実行時点では list の型パラメータが消えているので
    // どんな要素型か判別できない...
}

しかし インライン関数の型パラメータ に reified を付与すると、コンパイラが「型情報を削除せずに持ち回る」ようにコードを生成します。その結果、実行時に thrown is E -> thrown のように型情報を参照できるようになるわけです。(ここを深く追いかけると記事が膨大になりそうなので、別の機会に回します。ご了承ください)

先ほどの簡易ライブラリ内の shouldThrow 関数をもう一度見てみましょう。

inline fun <reified E : Throwable> shouldThrow(block: () -> Any?): E {
    val thrown = try {
        block()
        null
    } catch (e: Throwable) {
        e
    }

    return when {
        thrown == null -> throw AssertionError("Expected exception ${E::class.simpleName} but none was thrown.")
        thrown is E -> thrown
        else -> throw AssertionError("Expected exception ${E::class.simpleName} but ${thrown::class.simpleName} was thrown.")
    }
}
  • inline によって、ラムダ式をインライン展開 する(=> オーバーヘッド削減 + reified 利用が可能)
  • reified E によって、実行時に「thrown is E -> thrown」で「期待していた例外クラスか」を判定できる。

つまり shouldThrow<ArithmeticException> のように書くだけで、「もし投げられた例外が ArithmeticException と違ったら?」とか「そもそも例外が投げられなかったら?」というケースを簡潔に検証できるわけです。

まとめ

Kotest で出てくる "不思議な記法" は、実は Kotlin 標準の機能を巧みに組み合わせた結果でした。 本記事では簡易版テストライブラリを自作してみることで、以下の機能を一通り確認しました。

  • infix
    • object methodName arg」のように.()を省略できる技術
  • 拡張関数
    • 「既存クラスに関数を生やせる」機能で、Kotest でのアサーション (shouldBe) などが直感的に書ける
  • レシーバ付きラムダ式
    • ラムダ式の中で this を特定のオブジェクトに紐づけ、DSL のような階層的構文を実現できる
  • inline & reified
    • ラムダ式の呼び出しオーバーヘッドを抑えつつ、型情報を実行時にも参照できるので、shouldThrow のように「型を明示して例外チェック」を簡潔に実装可能

こうした言語機能を知ると、Kotest の公式リポジトリを眺める際も「あ、ここは拡張関数で実現してるのか」と発見が得られるかもしれません。(mockkの returnsevery など) 実際に Kotest のコードを追ってみると、もっと洗練された書き方や工夫された実装が散りばめられていますので、興味を持たれた方はぜひ覗いてみてください。

最後までお読みいただきありがとうございました。

Copyright © RAKUS Co., Ltd. All rights reserved.