Kotlinのテストコードが劇的に読みやすくなる初心者向けKotest便利機能紹介

Kotestは、単に「Kotlinで書ける」という理由だけで選ばれているのではありません。それは、JUnit5と比べて表現力、読みやすさ、そして何よりテストの安定性を劇的に高めるための、強力な仕掛けがいくつも詰め込まれているからです。
テストコードは、一度書いて終わりではありません。それは、機能の「動く仕様書」として、未来の自分やチームメンバーが最も頻繁に読むコードの一つです。それなのに、JUnit5スタイルで書かれたテストは、しばしば次のような課題を抱えていませんか?
-
冗長なボイラープレートや気づきにくい表記ゆれ:
@Testやメソッド定義などのボイラープレートやassertEquals()に渡す期待値・実測値の順番など、本質的ではないところに多少なりとも時間や思考が割かれる。 -
読みにくい仕様書:
assertEqualsが羅列され、「結局、このテストは何を保証しているのか?」が伝わりにくい。 -
Flaky Testの脅威:非同期処理が絡むと、
Thread.sleep()でごまかしたり、CIでたまに落ちる不安定なテストに悩まされる。
もし、これらの課題に一つでも心当たりがあるなら、Kotestはあなたのテストコードを根本から変える「武器」になります。
この記事は、公式ドキュメントの機能一覧をなぞるのではなく、現場で実際にどう使うと最も効果があるのか?という実践的な視点に絞って解説します。
Testing Style: Kotestらしさの入口
Kotest といえば、まず最初に名前が挙がるのが「Testing Style(Specクラス)」です。
テストクラスの継承先を変えることで、テストの書き味・構造を選べる仕組みになっています。
代表的なスタイルを以下に示します(どれも機能的な差はほぼなく、「見た目・構造の好み」の違いです。どのSpecクラスであってもJUnit5と比べると直感的にテストを記述することができます)
| スタイル | 雰囲気・由来のイメージ | ざっくり特徴 |
|---|---|---|
FunSpec |
ScalaTest 由来の「context / test」スタイル | ネスト前提で、文脈ごとに context をぶら下げていく構造的な書き方 |
StringSpec |
「テスト名をそのまま文字列で書く」最小スタイル | 1行ごとに "名前" { ... } と並べるだけ。サクッと書きたいとき向き |
DescribeSpec |
RSpec 風 (describe / context / it) |
BDD / ストーリー立てて読みたいときに向く |
ShouldSpec |
「〜すべき」にフォーカスした BDD 風 |
context / should("...") で期待値を明確に表現 |
BehaviorSpec |
given / when / then でシナリオを書くスタイル | ユースケース単位の振る舞いを、そのままテキストで表現できます |
WordSpec |
英文をつなげて読むようなスタイル |
"A feature" should { "do something" { ... } } のように英文で読む |
FreeSpec |
自由度高めなネスト構造 | 複雑な階層構造のテストをそのままDSLで表現 |
FeatureSpec |
feature / scenario ベース | 受入テスト・E2E寄りの表現をしたいときに便利 |
ExpectSpec |
expect ベース | 「何を期待しているか」を前面に出したいとき |
AnnotationSpec |
ほぼ JUnit5 互換のアノテーションスタイル | 既存の JUnit5資産から段階的に移行したいとき向け |
どのスタイルも、
class MySpec : FunSpec({
context("...") {
test("...") {
// ...
}
}
})
のように、「Specクラスのコンストラクタにテストの定義を渡す」という基本形は共通しています。
Testing Style の具体的な一覧や選び方は、公式の Testing Styles ドキュメントもあわせて見ると全体像が掴みやすいです。
アサーション機能: AssertJを超える「何でもMatcher」
Kotest のアサーションはAssertJの置き換えにとどまりません。
-
shouldBe,shouldNotBeといった Fluent API - 文字列・コレクション・数値・例外など型ごとの専用Matcher
- Soft Assertions / Clues / Power Assert などデバッグを楽にする仕掛け
- JSON や kotlinx-datetime, SQL, Arrow などの拡張Matcherモジュール
を組み合わせることで、ほとんどの検証ロジックを宣言的に書けるようになります。
Fluent Assertions の真価
JUnit5 でよく見るスタイル:
assertEquals(expected, result)
Kotest ではこう書き換えられます。
result shouldBe expected
// 他にも
user.shouldNotBeNull()
user.email shouldStartWith "admin"
list shouldHaveSize 5
左辺が実際の値、右辺が期待値、という形が一貫しているので、レビュー時に「どっちが expected?」と目を凝らす必要がなくなります。
IDE の補完も効きやすく、型ベースでMatcherを選べることも実務では大きな効果があります。
例外検証もオブジェクトとして扱う
shouldThrow の戻り値が例外オブジェクトそのものなので、その後に追加検証が書けます。
val exception = shouldThrow<IllegalArgumentException> {
userService.register("") // 不正な入力
}
exception.message shouldBe "Name cannot be empty"
exception.cause shouldBe null
型だけ見たいときは shouldThrowExactly もあります。
「標準装備」のMatcherをざっくり把握しておく
全部覚える必要はないですが、「何があるか」を薄く押さえておくと、「この程度であれば自前で実装する必要はない」と判断できる場面が増えます。
- 文字列系
shouldStartWith,shouldEndWith,shouldContain,shouldContainOnlyOnce,
shouldBeBlank,shouldMatch(Regex)など - コレクション系
shouldContain,shouldContainExactly,shouldContainAll,
shouldContainAllInAnyOrder,shouldHaveSize,shouldBeEmpty,shouldBeUniqueなど - 数値系
shouldBeGreaterThan,shouldBeLessThan,shouldBeBetween,shouldBeIn,
shouldBePositive,shouldBeNegativeなど - 型・null系
shouldBeTypeOf<T>(),shouldBeInstanceOf<T>(),
shouldNotBeNull(),shouldBeNull()など - マップ/ペア系
shouldContainKey,shouldContainValue,shouldContainExactlyなど
これらはすべてエラーメッセージも親切で、
- 実際の値 vs 期待値
- コレクションならどの要素で失敗したか
- データクラスならどのフィールドが違うか
まで含めて出してくれます。
フィールド比較系Matcher (IgnoringFields 系)
実務だと、
- DB保存時に
idやcreatedAt/updatedAtだけ変わる - APIレスポンスの一部フィールドは毎回違う (トークン・トレースIDなど)
- DTOのリストを「特定フィールドだけ無視して比較したい」
といったケースが頻出します。
Kotest にはこういう「一部フィールドを無視した比較」を支えるMatcherがまとまっていて、AssertJ 的な使い方ができます。
代表的なMatcher:
-
shouldBeEqualToIgnoringFields: 単一オブジェクト同士を、指定したプロパティだけ無視して比較 -
shouldBeEqualToComparingFields:equalsを使わず、フィールドを舐めて比較するフィールドベース比較Matcher -
shouldContainAllIgnoringFields: コレクション側に、指定したフィールドを無視した上で expected の要素がすべて含まれていることをチェックする
永続化後のエンティティ検証はこのように書けます。
val input = User(
name = "Kotlin",
email = "kotlin@example.com",
createdAt = null,
updatedAt = null,
)
val saved = userRepository.save(input)
// id / createdAt / updatedAt はDB側で採番・更新されるので無視したい
saved.shouldBeEqualToIgnoringFields(
input,
User::id,
User::createdAt,
User::updatedAt,
)
検索結果など、リスト全体を比較したい場合は shouldContainAllIgnoringFields がハマります。
val expected = listOf(
UserSummary(name = "Alice", email = "a@example.com", lastLoginAt = null),
UserSummary(name = "Bob", email = "b@example.com", lastLoginAt = null),
)
val actual = userService.searchUsers()
// lastLoginAt のような「動くフィールド」を無視して比較
actual.shouldContainAllIgnoringFields(
expected,
UserSummary::lastLoginAt,
)
「フィールド単位で細かく比較条件をいじれるMatcher」は Core / Collections 周りにかなり揃っていて、「これくらいなら既に用意されていそうだ」と一度標準Matcherを探してみると、自前ヘルパーを書く手間を減らせます。
アサーション早見表 (カテゴリ別)
テキストだけだとイメージしづらいので、カテゴリ別の早見表も用意します。
| カテゴリ | 代表的なMatcher例 | 主な対象 | コメント/よくある用途 |
|---|---|---|---|
| 基本 (汎用) |
shouldBe, shouldNotBe, shouldEqual, shouldNotEqual
|
あらゆる型 | JUnit5 の assertEquals 相当。まずはここから。 |
| 文字列 |
shouldStartWith, shouldEndWith, shouldContain, shouldContainOnlyOnce, shouldBeBlank, shouldMatch(Regex)
|
String |
部分一致、正規表現、空文字チェックなど。diff 付きメッセージが便利。 |
| コレクション (List/Set/Array) |
shouldContain, shouldContainExactly, shouldContainAll, shouldContainAllInAnyOrder, shouldHaveSize, shouldBeEmpty, shouldBeUnique
|
List<T>, Set<T>, Array<T> 等 |
検索結果リスト検証全般。順序込み/順序無視を選べる。 |
| マップ |
shouldContainKey, shouldContainValue, shouldContainExactly, shouldHaveSize, shouldBeEmpty
|
Map<K,V> |
クエリパラメータや設定値の検証。 |
| 数値 |
shouldBeGreaterThan, shouldBeLessThan, shouldBeBetween, shouldBeIn, shouldBePositive, shouldBeNegative
|
Int, Long, Double など |
スコア・ID範囲・境界値テストに。 |
| 型・null |
shouldBeTypeOf<T>(), shouldBeInstanceOf<T>(), shouldNotBeNull(), shouldBeNull()
|
任意型 + null 許容型 | キャスト前の型チェックや、null 返却の許容/不許容の明示に。 |
| データクラス/オブジェクト比較 |
shouldBeEqualToUsingFields, shouldBeEqualToIgnoringFields, shouldBeEqualToComparingFields, shouldContainAllIgnoringFields
|
データクラス、DTO、Entity、レスポンスモデル | 永続化後のEntityやAPIレスポンスの検証などで、id や createdAt のような変動するフィールドだけを除外して比較したいときの定番。shouldBeEqualToIgnoringFields(actual, expected, Foo::id, Foo::createdAt, …) のように、第2引数以降に無視したいプロパティ参照を渡す。shouldContainAllIgnoringFields はそのコレクション版。 |
| 例外 |
shouldThrow<T>(), shouldThrowExactly<T>(), shouldNotThrowAny()
|
例外 (Throwable) |
例外の型 + メッセージまで含めて検証。戻り値で例外オブジェクトを受け取れるのがポイント。 |
| 拡張モジュール (JSON / 日付など) |
json shouldEqualJson expected, localDate.shouldBeBefore(...) など (モジュールによる) |
JSON, kotlinx-datetime, SQL, Arrow 等 | ドメイン固有の比較ロジックを提供する拡張。必要に応じて追加する。 |
Soft Assertions (assertSoftly): まとめて落とす
巨大なレスポンスのテストで、
一箇所目で失敗 → 直す → 次の箇所でまた失敗…
という手戻りループに陥りやすくなります。
assertSoftly を使うと、同じオブジェクトに対する複数アサーションを一度に評価できます。
val response = client.postUser(payload)
assertSoftly(response) {
statusCode shouldBe 201
body.id shouldNotBe null
body.name shouldBe "Alice"
body.role shouldBe "MEMBER"
}
// 失敗した項目が一覧でレポートされる
プロジェクト設定で globalAssertSoftly = true にすると、テスト全体で Soft Assertions をデフォルト有効にすることもできます。
差分に強い類似性Matcherと周辺機能
- データクラス: フィールドごとの差分を表示
- 文字列: どこから差が出たかをハイライト (Diff) 表示
-
clue {}/withClue {}: 一連のアサーションに「文脈」をラベル付け - Power Assert: 失敗した式の部分式ごとの評価結果を出す
ログを目で追いながら「どこからおかしい?」と探すより、テスト結果だけでかなりの情報が手に入るようになります。
データ駆動テスト withData: テーブルテストを直接コードで表現する
Kotest はデータ駆動テスト (Data Driven Testing) をフレームワークレベルでサポートしています。
withData やそのバリエーション (withContexts, withTests など) を使うと、「テストのテーブル」をデータクラス列挙として表現できます。
Kotest 6.0 では、データ駆動テストはコアフレームワークに統合されており、別モジュール kotest-framework-datatest を追加する必要はありません。
5.x から移行する場合は、このモジュールを build から外す必要があります。
基本: データクラス + withData
まず 1 行分のテストデータをデータクラスで表現します。
data class DiscountCase(val price: Int, val expected: Int, val label: String)
withData に渡すと、行ごとに独立したテストケースとして実行されます。
class DiscountSpec : FunSpec({
context("割引計算") {
withData(
DiscountCase(1000, 100, "10% OFF"),
DiscountCase(5000, 1000, "20% OFF"),
) { (price, expected, _) ->
calculateDiscount(price) shouldBe expected
}
}
})
テストレポート上は「2件の別テスト」として出るので、どのケースが落ちたかが一目で分かります。
名前の付け方をコントロールする: nameFn と Map
実務では「どんなパラメータで失敗したのか」がテスト名から分かると便利です。
そのために nameFn や Map 形式が用意されています。
withData(
nameFn = { "定価 ${it.price}円 → 割引額 ${it.expected}円 (${it.label})" },
DiscountCase(1000, 100, "10% OFF"),
DiscountCase(5000, 1000, "20% OFF"),
) { case ->
calculateDiscount(case.price) shouldBe case.expected
}
あるいは、Map<String, T> で「名前 → データ」を渡すこともできます。
withData(
mapOf(
"10%割引" to DiscountCase(1000, 100, "10%"),
"20%割引" to DiscountCase(5000, 1000, "20%")
)
) { case ->
calculateDiscount(case.price) shouldBe case.expected
}
配列・シーケンス・ネスト: 大きめのテーブルに効かせる
テストデータが多いときは Sequence や List から渡すこともできます。
val cases = sequenceOf(
DiscountCase(1000, 100, "10%"),
DiscountCase(2000, 200, "10%"),
// 大量のケースをstream的に生成…
)
withData(cases) { case ->
calculateDiscount(case.price) shouldBe case.expected
}
withData はネスト可能です。これを使うと、「HTTPメソッド × パス」のような直積テストを、そのままコード構造として表現できます。
enum class Method { GET, POST }
class ApiSpec : FunSpec({
context("認証済みユーザーでの正常系") {
withData(
nameFn = { "method=${it}" },
Method.GET,
Method.POST,
) { method ->
withData(
nameFn = { path -> "path=$path" },
"/users/me",
"/projects",
) { path ->
val response = client.request(method, path)
response.status shouldBe 200
response.body shouldNotBe null
}
}
}
})
外側の withData がメソッド (GET / POST)、内側がパス (/users/me / /projects) という2段構成にしておくと、テスト結果上は
- 認証済みユーザーでの正常系 / method=GET / path=/users/me
- 認証済みユーザーでの正常系 / method=GET / path=/projects
- 認証済みユーザーでの正常系 / method=POST / path=/users/me
- 認証済みユーザーでの正常系 / method=POST / path=/projects
のような全組み合わせ (直積) が独立したテストケースとして出てきます。
メソッドやパスを増やしたいときも、外側/内側どちらかの withData に行を足すだけで済むので、組み合わせテストのメンテナンス性がかなり楽になります。
プロパティベーステスト (Property-Based Testing)
Property-Based Testing (PBT) は、通常の例示ベースの単体テストを強力に補完するテスト手法です。
従来のテストでは、開発者が自分で入力値を選び、そのときの期待結果を検証します。
PBT では逆に、テストフレームワークが大量のランダムデータを自動生成し、そのすべてで成り立つべき不変条件 (property) を検証します。
これにより、
- 開発者が思いつかなかったエッジケース
- たまたま境界値を踏んだときだけ起きるバグ
- 異常系の取りこぼし
といったケースを自動的に炙り出し、テスト網羅性を大きく底上げできます。
Kotest は PBT を第一級でサポートしており、checkAll と forAll という 2 つのテスト関数を提供しています。
class StringPropertiesTest : StringSpec({
"concatenated string length equals sum of lengths" {
// checkAll: 中で Kotest のアサーションを使うスタイル
checkAll<String, String> { a, b ->
(a + b) shouldHaveLength (a.length + b.length)
}
}
"concatenated string length property with forAll" {
// forAll: Boolean を返す述語スタイル
forAll<String, String> { a, b ->
(a + b).length == a.length + b.length
}
}
})
どちらも、Kotest がランダムな a と b を何百回も生成し、「連結した文字列の長さ = 個々の長さの和」という property が常に成り立つかどうかを検証します。
-
checkAll… ブロック内でshouldBeなどのアサーションを書く。例外が出なければ成功。 -
forAll… ブロックがtrueを返し続ければ成功。falseを返した時点で失敗。
好みでどちらを使っても構いませんが、既存の Kotest のアサーションと一緒に使えるぶん、現場では checkAll ベースで書くことが多くなりがちです。
試行回数 (iterations) の調整
Kotest はデフォルトで各 property を 1,000 回 実行します。実行時間とのバランスを取りたい場合は、先頭引数に回数を渡して上書きできます。
// Int のランダム値 10,000 個に対して property を検証
checkAll<Int>(10_000) { x ->
abs(x) shouldBeGreaterThanOrEqual 0
}
本番系のロジックであれば、まずはデフォルト (1,000 回) で回してみて、遅いと感じたところだけ局所的に回数を落とす、という運用が現実的です。
セットアップ: PBT を使うには kotest-property モジュールが必要です。
testImplementation("io.kotest:kotest-property:<version>")
ジェネレータと Arb: どんな値で試すかを決める
PBT の肝は「どんな入力値を試すか」です。Kotest では、値を生成する仕組みを総称して ジェネレータ (Generator) と呼び、特にランダムサンプルを生成するものを Arb (Arbitrary) と呼びます。
型引数だけ指定している場合、Kotest は組み込みのジェネレータを自動で選びます。
// String 用のデフォルト Arb が自動で選ばれる
checkAll<String, String> { a, b ->
(a + b).length shouldBe a.length + b.length
}
代表的なデフォルト Arb は次のようなイメージです。
-
Arb.int()… 負数 / 正数 / 0 / 極大値・極小値を含む整数全般 -
Arb.string()… Unicode 全域から生成される様々な文字列 -
Arb.list(Arb.int())… 任意長のリスト (空リストを含む)
エッジケース混入戦略
Kotest の Arb は、単にランダム値を投げるだけでなく、型ごとのエッジケースを計画的に混ぜる よう設計されています。
-
Intなら 0,Int.MIN_VALUE,Int.MAX_VALUE, -1, 1 など -
Stringなら空文字や極端に短い文字列 - コレクションなら空リスト・空セット
といった値を、全体の一部だけ必ず混入させることで、「たまたまランダムでは引けなかった境界値」をきちんと踏みに行きます。
ドメインに合わせた Arb を指定する
「なんでもありのランダム」より、「ドメインに即したランダム」の方がテストとして役に立つことが多いです。その場合は、Arb を明示的に渡してあげます。
"age verification works for adults" {
// 21〜150歳の範囲だけを対象に 100 回テスト
forAll(Arb.int(21..150), iterations = 100) { age ->
isAllowedToDrinkInChicago(age) shouldBe true
}
}
このようにすれば、「21 歳未満はそもそも生成されない」世界で property を検証できます。
Arb には他にも、
-
Arb.string()/Arb.stringPattern(...) -
Arb.list(Arb.int()),Arb.map(...) - メールアドレスや URL など、よくあるドメイン型向けのプリセット
- データクラスを丸ごと生成する
Arb.bind系 API
といった「標準的な型」をカバーするものに加えて、大量のドメイン特化 Arb コレクション も用意されています。
Extra Arbs: 実務でも遊びでも使えるドメイン特化ジェネレータ
io.kotest.extensions:kotest-property-arbs を追加すると、「名前」「住所」「プロダクトカテゴリ」など、実世界寄りのデータを簡単に生成できる Extra Arbs が使えるようになります。
代表的なArbと面白いArbをまとめてみました。
| カテゴリ | 代表的な Arb 一覧 | 用途・イメージ |
|---|---|---|
| 人名・ユーザー情報 |
Arb.firstName(), Arb.lastName(), Arb.name(), Arb.usernames(), Arb.logins()
|
ユーザー登録や顧客データ、ログイン履歴など。「同姓同名」「長い名前」「怪しいログイン」を含めて、ユーザーまわりのロジックを PBT で叩きやすくなります。 |
| Web / ネットワーク |
Arb.domain(), Arb.country(), Arb.continent(), Arb.zipcode()
|
ドメイン・国・地域・郵便番号など。Cookie スコープ判定、リージョン別課金、フォーマットバリデーションといった「Web サービスあるある」系のテストに使えます。 |
| 金融・EC |
Arb.transactions(), Arb.stockExchanges(), Arb.products(), Arb.googleTaxonomy()
|
決済トランザクションや証券取引所、商品カテゴリなど。与信限度チェック、不正検知、カテゴリ別手数料計算やフィルタリングロジックの PBT にそのまま流し込めます。 |
| ロケーション / 交通 |
Arb.airport(), Arb.airline(), Arb.airJourney(), Arb.tubeStation(), Arb.tubeJourney()
|
実在の空港・航空会社・地下鉄駅・経路などを生成。経路検索、レイオーバー計算、運賃計算、グラフ探索アルゴリズムの検証など、「移動・ルート」系ドメインのテストに向きます。 |
| ワイン / アイス / ゲーム系 |
Arb.wines(), Arb.wineRegions(), Arb.wineVarieties(), Arb.wineries(), Arb.wineReviews(), Arb.iceCreams(), Arb.iceCreamFlavors(), Arb.chessPiece(), Arb.chessSquare(), Arb.chessMove(), Arb.cluedoSuspects(), Arb.cluedoWeapons(), Arb.cluedoLocations(), Arb.cluedoAccusation()
|
レコメンドエンジンやスコアリング、メニュー組み合わせ、ゲームロジックなどを「遊べる」ドメインで PBT したいときに便利。PoC や勉強会のサンプル題材にも使いやすいラインナップです。 |
| その他 (ブランド・自動車など) |
Arb.cars(), Arb.brand()
|
自動車メーカー名やブランド名などを生成。マーケティング系ダッシュボード、料金シミュレーション、ダミーデータ投入など「それっぽい実データ」が欲しい場面で活躍します。 |
現場で PBT を導入するときは、まずは基本の Arb.int / Arb.string から始めて、
シナリオに応じて上記のような Extra Arbs を差し込んでいくと、
「実データっぽさ」を保ったままテストの幅を一気に広げられます。
ここで挙げたのはごく一部なので、興味があれば公式ドキュメントの一覧も眺めてみてください:
Exhaustive: 小さな入力空間は全列挙で潰す
入力パターンが有限で小さい場合は、ランダムに頼らず 全列挙 (Exhaustive) した方が早くて確実です。
Kotest では Exhaustive というジェネレータが用意されており、
- Boolean の全パターン
[true, false] - 列挙型の全定数
- 小さな値の組み合わせ
などを漏れなく試せます。
// Boolean の全パターンを試す
checkAll(Exhaustive.boolean()) { flag ->
val result = feature.toggle(flag)
// ... property を検証
}
Arb と Exhaustive は同じ checkAll / forAll の中で組み合わせられるので、
- 引数 A はランダム (
Arb) に - 引数 B は有限集合を全列挙 (
Exhaustive)
といった形で、「一部は網羅・一部はランダム」というバランスの良いテスト戦略も簡単に書けます。
その他のPBT機能 (ざっくり紹介)
ここでは、PBT の入口だけを扱いましたが、Kotest にはこのほかにも
Assumptions(前提条件)、Seeds(乱数シード固定)、Shrinking(最小限の反例)、Statistics(入力分布の集計)、Custom Generators(独自ジェネレータ)、Generator Operations(map / flatMap などでの合成)
といった具合に、いろいろな機能が用意されています。「なんかもっと色々できそうだな」と感じたら、公式の PBT セクションを軽く一周眺めてみるのが一番早いです。
非同期・非決定的なテスト
非同期な処理や最終的整合性 (eventual consistency) が絡むと、
- ローカルだと通る
- CI だとたまに落ちる
という「たまに落ちるテスト」が生まれがちです。
Kotest には、こうした非決定的テストのための専用アサーションがまとまっています。
-
eventually: いつかは通るはずのコードをリトライしながら待つ -
continually: 一定時間のあいだ安定して成功し続けることを確認する -
until: 条件が true になるまでポーリングする -
retry: 回数ベースでブロックをリトライする
eventually: 結果整合性の世界でテストを書く
userService.register(user)
// 一定時間内にメールが届くことを検証
eventually(5.seconds) {
val mail = mailServer.latestMail()
mail.subject shouldBe "Welcome"
}
eventually は内部で、
- ブロックを実行
- 失敗したら一定間隔でリトライ
- 時間または回数の上限に達したら失敗
というループを回しています。
細かい制御も可能です:
-
duration: 全体のタイムアウト -
interval/intervalFn: リトライ間隔 (固定 or Fibonacci バックオフなど) -
initialDelay: 最初の実行まで待つ時間 -
retries: 最大試行回数 -
expectedExceptions/expectedExceptionsFn: どの例外ならリトライを続けるか
例えば「ユーザーがまだ見つからないのは retry で許すが、DB 接続エラーなら即失敗」にできます。
val config = eventuallyConfig {
duration = 5.seconds
interval = 250.milliseconds
expectedExceptions = setOf(UserNotFoundException::class)
}
eventually(config) {
repository.getUser(id).name shouldBe "Alice"
}
continually: 壊れないことを証明する
eventually の対になるのが continually です。
continually(60.seconds) {
// このブロックが 60 秒のあいだ成功し続けなければならない
circuitBreaker.state shouldBe CircuitBreaker.State.Open
}
指定期間内に一度でも失敗すれば、その瞬間にテストが落ちます (fail fast)。
キャッシュやサーキットブレーカーのように「状態が維持されていること」をテストしたい場面で効きます。
until: 条件だけ見たいときの軽量版
「値自体には関心がなく、ある条件がいつか true になればよい」というときは until が向いています。
sendMessage()
until(5.seconds) {
broker.poll().isNotEmpty()
}
デフォルトでは 1 秒ごとに predicate を評価しますが、interval を変えたり、Fibonacci 的に伸ばしたりもできます。
retry: 回数ベースのリトライ
retry は「最大 N 回だけ試す」スタイルです。
retry(times = 4) {
paymentGateway.charge(1000) shouldBe true
}
ときどき 502 を返す外部 API など、「結果にばらつきはあるものの、待ち時間に上限を設けたい」ケースに向きます。
Coroutines 対応: runTest から解放される
Kotlin のテストで coroutines を扱うとき、JUnit5 だと
-
runTest { ... }で毎回ラップする - Dispatcher 切り替えのために
Dispatchers.setMain/resetMainなどを書く
といったボイラープレートが発生しがちです。
Kotest は テスト DSL が標準で suspend 対応しているので、基本的にテスト本体をそのまま suspend なコードとして書けます。
coroutineTestScope: TestScope でテストを走らせる
さらに「仮想時間を使ったテスト」(runTest 相当)を使いたいときは、coroutineTestScope を有効にするだけで済みます。
- プロジェクト全体:
AbstractProjectConfigでcoroutineTestScope = true - Spec 単位 / テスト単位:
defaultTestConfigや.config { coroutineTestScope = true }
を設定すると、そのテストは kotlinx.coroutines.test の TestScope 上で実行されます。
これにより、
-
delayが仮想時間に変わる(テストが実時間で待たされない) -
advanceUntilIdle,runCurrentなど TestScope のユーティリティが使える - Dispatchers をテスト用に差し替えやすくなる
といったメリットが得られます。
JUnit5 で
@Test
fun foo() = runTest {
// ...
}
と書いていたコードが、Kotest では
class FooSpec : StringSpec({
"foo" {
// ここが runTest の中身と同じように振る舞う
}
})
とそのまま書けます(coroutineTestScope 有効時)。
Coroutine Debugging: ハングしたテストの「今」を見る
非同期テストで怖いのが、
- テストがハングする
- どの coroutine が何を待っているか分からない
という状況です。
Kotest には kotlinx-coroutines-debug と連携したデバッグ機能が用意されており、プロジェクト設定で coroutineDebugProbes = true のような設定を入れると、テスト失敗やタイムアウト時に 現在動いている coroutine のスタックトレース をダンプしてくれます。
これにより、
- どの Job がキャンセルされていないか
- どのスレッド/dispatcher 上でブロックしているか
といった情報がテストログから直接拾えるようになり、
「CIだけでハングする謎のテスト」を追うときの手がかりになります。
テスト実行制御: Retry を理解しておく
さきほどの retry { ... } は「アサーションとしてのリトライ」でしたが、
Kotest には「非決定的テスト向け」の API 群がひとまとめに整理されています。
現場的な使い分けのイメージだけまとめると:
-
eventually… そのうち成功するはず のコードの検証(結果整合性、ポーリング) -
until… 述語で書く軽量版(戻り値は不要で条件だけ見たいとき) -
continually… 一定時間「失敗しないこと」を検証(状態が維持されることの保証) -
retry… 回数ベースのリトライ(時間よりも試行回数を意識したいとき)
Flaky Test を「sleep を足して誤魔化す」のではなく、
どの条件でどれくらいの時間・回数を許容するか を DSL で明示しておく、というのが Kotest 流のアプローチです。
ライフサイクルフック: セットアップ/後片付けの置き場所
テストの前後処理は、方針を決めずに書き始めるとすぐカオスになります。
Kotest はライフサイクルフックがかなり細かく整理されていて、beforeTest / afterTest を中心に設計されています。
ここでは実務的に使うことが多いものだけに絞ってざっくり整理します。
よく使うフック
| フック名 | スコープ | タイミング (ざっくり) | よくある用途 |
|---|---|---|---|
beforeTest |
すべてのテストノード (TestCase) |
各テスト (コンテナ含む) 実行直前 | 「テスト1件あたり」のセットアップ全般 |
afterTest |
すべてのテストノード | テスト実行直後 (失敗しても必ず呼ばれる) | テストごとのクリーンアップ、ログ出力 |
beforeSpec |
Spec インスタンス | Spec の最初のテストが動く前 | Spec 単位で一度だけ必要なセットアップ |
afterSpec |
Spec インスタンス | Spec に属するテストが全部終わったあと | Spec 単位のクリーンアップ |
prepareSpec |
Spec クラス (KClass) |
Spec 実行準備時、クラスにつき 1 回 | クラス単位の重い準備(テーブル作成など) |
finalizeSpec |
Spec クラス | Spec クラスの全テスト終了後、クラスにつき1回 | 結果集計・レポート出力など |
beforeInvocation / afterInvocation
|
テストの「呼び出し」 |
invocations = N の各回の前後 |
ベンチマーク的なテストや flaky 切り分け |
実務でのおすすめ: まずは beforeTest をデフォルトにする
ライフサイクルフックはいくつかありますが、現場で一番よく使うのは beforeTest / afterTest です。
特に、Kotest の Spring 拡張 (SpringExtension) と組み合わせて @Transactional なテストを書いているときに、セットアップを beforeEach 側に書いてしまうと、
- セットアップで投入したデータがテスト終了後にロールバックされず残る
- 別のテストケースに前のテストの状態が影響する
といった問題が実際に起き得ます(Spring 側のトランザクション境界の外で処理されてしまうため)。
そのため、
- 素の Kotest でも
- SpringExtension と組み合わせる場合でも
まずは beforeTest / afterTest を「デフォルトのフック」として使う ことをおすすめします。
beforeEach / afterEach は、
- JUnit5 ライクな挙動に寄せたい
-
TestType.Testだけを厳密に狙いたい
といった明確な理由があるときだけ使う、くらいの位置づけにしておくと、Spring 連携時のハマりどころを減らせます。
afterProject について一言
Lifecycle hooks とは別に、DSL として afterProject { ... } もあります。
- スコープ: テストプロジェクト全体 (全 Spec をまたいだ最外層)
- 役割: すべてのテストが終わったあとに 1 回だけ呼ばれる
- 実装:
ProjectListenerを内部で生成するためのショートカット
「テスト全体の最後に一度だけやりたいこと」 (テスト用 DB の削除、コンテナの停止など) がある場合は、ここに寄せておくと後から見返したときに分かりやすくなります。
DSL でサクッと使うパターン
Spec の中で beforeTest / afterTest などを直接呼ぶパターンです。
class UserSpec : WordSpec({
beforeTest {
println("Starting test: ${it.name.testName}")
}
afterTest { (test, result) ->
println("Finished ${test.name.testName} with $result")
}
"user registration" should {
"create user" {
// ...
}
}
})
裏では TestListener が自動生成されています。
override でまとめて書くパターン
Spec クラスのメンバとしてオーバーライドする方法もあります。
class UserSpec : WordSpec() {
override suspend fun beforeTest(testCase: TestCase) {
println("Starting: ${testCase.name.testName}")
}
override suspend fun afterTest(testCase: TestCase, result: TestResult) {
println("Finished: ${testCase.name.testName} = $result")
}
init {
"user registration" should {
"create user" {
// ...
}
}
}
}
クラス単位で共通処理をまとめたいときはこちらの方が見通しが良くなります。
TestListener で切り出すパターン
複数の Spec にまたがる共通処理は、TestListener を実装したクラスとして切り出し、
listeners() で登録するのが定石です。
object LoggingListener : TestListener {
override suspend fun beforeAny(testCase: TestCase) {
println(">> ${testCase.displayName}")
}
override suspend fun afterAny(testCase: TestCase, result: TestResult) {
println("<< ${testCase.displayName} : $result")
}
}
object ProjectConfig : AbstractProjectConfig() {
override fun listeners() = listOf(LoggingListener)
}
こうしておくと、「どの Spec で何が起きても必ずログが流れる」状態を、
プロジェクト設定だけで保証できます。
プロジェクト設定: AbstractProjectConfig で"作法"を固定する
AbstractProjectConfig を継承したクラス (または object) を 1 つ用意すると、
プロジェクト全体のテスト挙動をまとめて制御できます。
代表的な設定項目:
-
assertionMode: Kotest アサーションを使っていないテストをエラー or 警告にする -
globalAssertSoftly: すべてのテストで Soft Assertions をデフォルト有効にする -
timeout: テスト全体のデフォルトタイムアウト -
failOnIgnoredTests: Ignored なテストがあったらスイートごと失敗扱いにする -
testNameCase/testNameRemoveWhitespace: テスト名の整形ルール -
duplicateTestNameMode: 同名テストがあったときの扱い (エラー or 自動リネーム) -
coroutineTestScope,coroutineDebugProbesなど coroutine 用の設定
例:
object KotestProjectConfig : AbstractProjectConfig() {
override val assertionMode = AssertionMode.Error
override val globalAssertSoftly = true
override val timeout = 5.seconds
override val failOnIgnoredTests = true
override val coroutineTestScope = true
override val coroutineDebugProbes = true
}
テストコードの書き方の流儀をプロジェクトに強制することで、チーム全体のばらつきを抑えられます。
まとめ
ここまで見てきたように、Kotest は
- Kotlin らしいTesting Style
- FluentなAssertionと豊富なMatcher
- withDataやPBTによる書きやすく読みやすいテスト構造
- eventually系やCoroutine対応といった、非同期・非決定的なテスト向けの仕組み
- ライフサイクルフックやProjectConfigによる「テスト基盤」の整えやすさ
といった要素が組み合わさったテストフレームワークです。
すべてを一度に使いこなす必要はなく、まずは
- 既存のJUnit5テストの一部をKotestのSpecに移してみる
-
shouldBeやassertSoftlyなど、Assertionまわりだけを試してみる
といった小さなところから取り入れていくだけでも、テストコードの読みやすさやトラブルシュートのしやすさは大きく変わります。
この記事が、「Kotestという選択肢がある」ことを知るきっかけになったり、名前だけ知っていた方が、実際にプロジェクトへ導入してみる一歩を踏み出す後押しになったりすればうれしく思います。
日々の開発の中で、「テストを書くのが少し楽になった」と感じてもらえたら幸いです。
Discussion