📝

Spekでパラメーター化テストはどうやって書く?

2021/08/29に公開

Androidアプリの開発でSpekを使い始めた際にパラメータ化テストを書きたくなった。その時に考えたことについて書く。

Spekとは

Kotlinのテストフレームワーク。RSpecを参考にしたインタフェースになっている。

Core Concepts - Spek Framework

DroidKaigi 2019 - Spek で UnitTest を書こう

パラメータ化テストとは?

テスト対象の振る舞いに対してデータだけを変えて検証する手段。テストフレームワークがサポートしていることが多い。

これを利用するモチベーションは以下リンク先の説明がわかりやすい。

https://t-wada.hatenablog.jp/entry/design-for-testability#ポイント-パラメータ化テストParameterized-Test

Spekにパラメータ化テストの機能はある?

現状なかった。issueがあったけど進まなさそうな雰囲気。

Spekの1系ではパラメータ化テストの記述のための拡張があったらしい。
(少しissueを見てみたが2系で削除された経緯は分からなかった。)

書いてみる

フレームワークのサポートはなかったので、自分で書いてみる。

Assertion Rouletを避ける

テストコードにおけるアンチパターン。

テストが通っているときは問題ないが、失敗した際には「失敗するとそれ以降のテストケースが実行されない」、「どのケースで失敗したか分かりづらい」という状況に陥ることを指す。
ループを書いてパラメータテストを実現しようとするとハマりがち。

このAssertion Rouletを回避しながら書く。

テスト対象

Epoch Timeを曜日を表すEnumに変換する関数 fromEpoch を例にする。

素朴な記述(not パラメータ化テスト)

パラメータ化テストの前に、各条件を素朴に記述したテストコードを書いてみる。
やや冗長になっている。

object DayOfWeekTest: Spek({
    describe("fromEpoch") {
        context("2000-01-02 given") {
            val given = getEpoch(year = 2000, month = Calendar.JANUARY, day = 2)

            it ("should be sunday") {
                Assert.assertSame(
                    DayOfWeek.SUNDAY,
                    DayOfWeek.fromEpoch(given)
                )
            }
        }
        context("2000-01-03 given") {
            val given = getEpoch(year = 2000, month = Calendar.JANUARY, day = 3)

            it ("should be sunday") {
                Assert.assertSame(
                    DayOfWeek.SUNDAY,
                    DayOfWeek.fromEpoch(given)
                )
            }
        }
        context("2000-01-04 given") {
            val given = getEpoch(year = 2000, month = Calendar.JANUARY, day = 4)
            it ("should be tuesday") {
                Assert.assertSame(
                    DayOfWeek.TUESDAY,
                    DayOfWeek.fromEpoch(given)
                )
            }
        }
        context("2000-01-05 given") {
            val given = getEpoch(year = 2000, month = Calendar.JANUARY, day = 5)

            it ("should be monday") {
                Assert.assertSame(
                    DayOfWeek.MONDAY,
                    DayOfWeek.fromEpoch(given)
                )
            }
        }
        context("2000-01-06 given") {
            val given = getEpoch(year = 2000, month = Calendar.JANUARY, day = 6)

            it ("should be thursday") {
                Assert.assertSame(
                    DayOfWeek.THURSDAY,
                    DayOfWeek.fromEpoch(given)
                )
            }
        }
        context("2000-01-07 given") {
            val given = getEpoch(year = 2000, month = Calendar.JANUARY, day = 7)

            it ("should be friday") {
                Assert.assertSame(
                    DayOfWeek.FRIDAY,
                    DayOfWeek.fromEpoch(given)
                )
            }
        }
        context("2000-01-08 given") {
            val given = getEpoch(year = 2000, month = Calendar.JANUARY, day = 8)
            it ("should be friday") {
                Assert.assertSame(
                    DayOfWeek.FRIDAY,
                    DayOfWeek.fromEpoch(given)
                )
            }
        }
    }
}) {
    private fun getEpoch(year: Int, month: Int, day: Int) = Calendar.getInstance().let {
        it.set(year, month, day)
        it.timeInMillis
    }
}

テスト失敗時のフィードバック

失敗時のフィードバックは画像のようになる。
失敗時のフィードバック1

ループを記述

次の点を意識してループを記述するパラメータ化テストを実装した。

  • 1つのTestScope(ここでは it)に1つのAssertionになるようにする
    失敗した際にそれ以降のテストケースも実行される
  • 何件目のテストケースで失敗したか分かるようにする
data class TestCase<T, U>(val given: T, val expected: U)

object DayOfWeekTest : Spek({
    describe("DayOfWeek.fromEpoch") {
        val testCaseList = listOf(
            TestCase(
                given = getCalendar(year = 2000, month = Calendar.JANUARY, day = 2),
                expected = DayOfWeek.SUNDAY
            ),
            TestCase(
                given = getCalendar(year = 2000, month = Calendar.JANUARY, day = 3),
                expected = DayOfWeek.SUNDAY
            ),
            TestCase(
                given = getCalendar(year = 2000, month = Calendar.JANUARY, day = 4),
                expected = DayOfWeek.TUESDAY
            ),
            TestCase(
                given = getCalendar(year = 2000, month = Calendar.JANUARY, day = 5),
                expected = DayOfWeek.MONDAY
            ),
            TestCase(
                given = getCalendar(year = 2000, month = Calendar.JANUARY, day = 6),
                expected = DayOfWeek.THURSDAY
            ),
            TestCase(
                given = getCalendar(year = 2000, month = Calendar.JANUARY, day = 7),
                expected = DayOfWeek.FRIDAY
            ),
            TestCase(
                given = getCalendar(year = 2000, month = Calendar.JANUARY, day = 8),
                expected = DayOfWeek.FRIDAY
            ),
        )

        testCaseList.forEachIndexed { i, case ->
            val (given, expected) = case
            val actual = DayOfWeek.fromEpoch(given.timeInMillis)
            context("${SimpleDateFormat("yyyy-mm-dd").format(given.time)} given") {
                it("should be $expected [${i}]") {
                    Assert.assertSame(expected, actual)
                }
            }
        }
    }
}) {
    private fun getCalendar(year: Int, month: Int, day: Int) = Calendar.getInstance().also {
        it.set(year, month, day, 1, 1)
    }
}

テスト失敗時のフィードバック

失敗時のフィードバックは画像のようになる。
失敗時のフィードバック2

これでAssertion Rouletを避けられ、どのテストケースで失敗したかある程度わかりやすいようになった。

ただ、書き方が実装者依存で統一されづらかったり、テストコード内に制御構文が現れたりするので、JUnit5みたいなフレームワークでのサポートはあると嬉しい。
コードの統一感についてはチームで認識合わせられたら十分な範囲と思うので、これでも十分かな。

Discussion