見出し画像

Kotlin / Kotest でテストを書く Tips の社内共有会を開催しました!

株式会社ヘンリーでサーバーサイド開発をしている @agatan です。
先日社内向けに Kotlin でテストを書く際の Tips を共有する会を開きました。

目的

ヘンリーは、電子カルテ・医事会計システムを開発しており、その中でもサーバーサイドの開発には Kotlin を採用しています。
電子カルテ・医事会計システムは、高い堅牢性・信頼性が求められます。
また、医事会計システムに特有の事情として、二年に一度、必ず診療報酬制度(保険医療の価格を決めるルール)の改訂がなされるため、保守性についても高いレベルが求められます。

これらの要件を満たしつつ、継続的に開発速度を維持してために、自動テストの重要性は非常に高いと考えています。が、まだまだ筆者は自動テストを呼吸をするように書くというほどには、テストをうまく書けていませんでした。
プロジェクト全体を見ても、テスト自体は書かれていないわけではないのですが、より一層テストの量も質も向上していく必要があると感じていました。

そんな折、筆者が育休に入り、ちょこちょこと隙間時間で 「Clean Craftsmanship」を読み進めるうちに、改めてテストの重要性を認識し、読後の意識の高いうちにテストをもっと書いていくために何かしら社内で活動できないかと考えるようになりました。

テストを書いていくためにやるべきことはたくさんあると思うのですが、単にテストを便利に書く方法をたくさん知っていればテストも書きやすくなるのでは?と考え、まずは弊社内で採用しているテストフレームワークである Kotest のドキュメントを啄んで便利メソッドなどを紹介するちょっとした勉強会を実施してみることにしました。

開催してみて

テストフレームワークは、最低限のことは assert さえできれば意外と書けてしまうし、プロダクションコードで採用されているライブラリと比較して、あまりドキュメントを読み込まないでなんとなく使ってしまう、という方も多いのではないでしょうか。
自分はまさにこのタイプで、雰囲気でやってきてしまっていたので、この会を開催することを決めて改めてドキュメントを読み込むきっかけになりました。

読んでみると、やはり便利なメソッドや正しく書くための知識を知っていることで、より良いテストが書けるようになるという面はあるなと実感しました。
弊社メンバーに向けて発表した時も、既知な内容は多分に含みつつも、細かい Tips などで新しい発見を提供できたようでした。
また、やはりテストの書き方についても議論が盛り上がり、テストについてチームで向き合う良いきっかけになったのではないかと思います。

本当はライブラリ紹介よりも一歩踏み込んで、テスト自体の設計についても議論をしたかったのですが、時間の都合上そこまでたどり着けなかったので、また改めてそういった内容も社内で勉強会を開催したいなと考えています。

結び

Server-Side Kotlin で深いドメインに立ち向かうために、テストの設計などについてより深く学習・議論していきたいと考えています。
ご知見のある方・ご興味のある方がいらっしゃいましたら、ぜひ弊社の社内むけ勉強会にて一緒に議論しませんか!

また、弊社では、複雑で専門的な医療の業務課題を解決し続けることに挑戦する仲間を探しています!
採用情報はこちらです!
ご興味のある方がいらっしゃいましたら、ぜひカジュアル面談をさせてください! @agatan まで DM お待ちしております!


以下発表資料(の一部抜粋)です。

Kotest

  • Kotest | Kotest

  • kotlin 製のテストフレームワーク

    • 弊社も Spek から移行した

  • KMPにも対応していて、デファクトスタンダートになりつつある(?)

  • 細かく Project は分かれているが、ざっくり以下のものを内包している

    • Test Runner (テストの構造の記述とその実行形式を提供)

    • Assertions / Matcher (テストの中身の assert を書くための記法を提供)

    • Property-based Testing 

  • Extensions も色々ある

    • https://kotest.io/docs/extensions/extensions.html

    • メジャーな Kotlin の基盤っぽいライブラリと共存しやすくするための拡張

      • e.g.) Ktor, Sprint, Koin

    • JVM の提供する機能をテストしやすくする拡張 (System Extensions)

      • e.g.) withSystemProperty, NoSystemOutListener, withDefaultTimezone

    • 外部インフラが絡むテストを書きやすくする拡張

      • e.g.) Kafka, MockServer, Test Container (後述)

基本形

class FooTest : DescribeSpec({
    val foo = Foo()

    describe("foo.bar does something") {
        it("returns true if ...") {
            foo.bar(0).shouldBeTrue()
        }

        it("is another test ...") {
            foo.bar(1).shouldBe(false)
        }
    }
})

他に FunSpec とか StringSpec とか色々あるけど、多分弊社でメジャーなのは DescribeSpec。 基本的には書き方の問題だけで、本質的な差分はあんまりない。

Matchers

Spek には assertion library が付属していなかったので、 kotlin.test や Truth を使っていたが、 kotest は assertion library を内包しているので、のっかりましょう。

基本

  • kotest-assertions-core が基本の assertion。 (Doc)

  • Extension Methods で定義されているので、 Truth や kotlin.test とは書き味が割と違う。

obj.shouldBe(expected)
obj shouldBe expected

obj.shouldBeTrue()
shouldThrow<SomeException> { ... }
obj.shouldBeNull()

// Collection
col.shouldBeEmtpy()
col.shouldHaveSize(3)
col.shouldBeUnique()
col.shouldContainExactlyInAnyOrder(a, b, c)
map.shouldContainKey(key)
map.shouldContain(k, v)

// Date
date.shouldHaveSameYearAs(other)
date.shouldBeBefore(other)

// Throwable
throwable.shouldHaveMessage(msg)

// Chain
map.shouldHaveSize(3).shouldContainKey("x")

ちょっと特殊で便利そうなやつ

// 特定の fields が全て等しい
p.shouldBeEqualToUsingFields(other, Patient::name, Patient::birthDate)
// 特定の fields **以外** の fields が全て等しい
p.shouldBeEqualToIgnoringFields(other, Patient::id, Patient::createTime)

// `equals` ではなく、public fields を一つずづ比較する
p.shouldBeEqualToComparingFields(other, ignorePrivateFields = true)

他にもいくつかのライブラリと上手いことやるための Matcher もあるので、ドキュメントを要チェック!

Clues

withClue

withClue("Name should be present") {
    user.name shouldNotBe null
}

のように書くと、

Name should be present
<null> should not equal <null>

のようなメッセージになる。assertion の生のメッセージだけでは readability が低いときに使うと良さそう。コメントで補足するくらいならこれを使うと良いのかなと思います。

asClue

withClue よりはこっちの方が簡単に使えて便利そう。

response.asClue {
    it.status shouldBe 200
    it.body shouldBe "the content"
}

のように書くと response.toString が clue として扱われるので、テスト失敗時に object の全体像をパッと把握するのが簡単になる。

assertSoftly

通常は一つのテストケース内で最初に落ちた assertion のところで実行が止まってしまう。 assertSoftly を使うことで、ブロック内の処理を全て実行した上で、失敗した assertion を全てまとめて報告してくれる。

assertSoftly {
    foo shouldBe bar
    foo should contain(baz)
}

Inspectors - コレクションの要素についての性質をテストする

Inspectors | Kotest
「リスト内の全ての要素が以下の性質を満たす」とか「リスト内の要素のうち最低でも一つは以下の性質を満たす」といったテストを書きやすくするための仕組み。

list.forAll { it.shouldNotBeEmpty() }
list.forNone { it.shouldBeEmpty() }

list.forAtLeastOne { it.shouldNotBeEmpty() }

Data Driven Testing

テーブルテストっぽいこともできる。(kotest-framework-datatset が必要) Introduction | Kotest

class MyTests : FunSpec({
    context("Pythag triples tests") {
        withData(
            PythagTriple(3, 4, 5),
            PythagTriple(6, 8, 10),
            PythagTriple(8, 15, 17),
            PythagTriple(7, 24, 25)
        ) { (a, b, c) ->
            isPythagTriple(a, b, c) shouldBe true
        }
    }
})

Nested Data Driven Testing

Nest して書くこともできるので、網羅的にパターンを書きたい時は便利かも。 Nested Data Tests | Kotest

context("each service should support all http methods") {
    val services = listOf(
        "<http://internal.foo>",
        "<http://internal.bar>",
        "<http://public.baz>",
    )
    val methods = listOf("GET", "POST", "PUT")

    withData(services) { service ->
        withData(methods) { method ->
            // test service against method
        }
    }
}

Extensions

Koin

DI ライブラリである Koin の Context の initialize/finalize 処理をテストごとに実行してくれる Extension。
Henry だとこれは使わないので紹介は割愛。(弊社固有の事情があって微妙にそのままだとマッチしない)
Koin | Kotest

class KoinExampleTest : DescribeSpec(), KoinTest {
    override fun extensions() = listOf(KoinExtension(appModule))

    val patientUseCase by inject<PatientUseCase>()

    init {
        describe("...") {
            it("...") {
                patientUseCase.foo().shouldBeTrue()
            }
        }
    }
}

Current Instant Listeners

テスト実行時の時を止める。 Current Instant Listeners | Kotest

val foreverNow = LocalDateTime.now()

withConstantNow(foreverNow) {
    LocalDateTime.now() shouldBe foreverNow
    delay(10) // Code is taking a small amount of time to execute, but `now` changed!
    LocalDateTime.now() shouldBe foreverNow
}

or

override fun listeners() = listOf(ConstantNowTestListener(foreverNow))

Test Clock

JVM が時刻をいじりやすいように java.time.Clock interface を提供してくれている。
Kotest は Clock interface を implement した TestClock クラスを提供している。 (io.kotest.extensions:kotest-extensions-clock)
withConstantNow より自由度も高いし、こっちを使えるようにしていったほうが良さそう。 (自分もよく = Instant.now() とやってしまうけど、Clock を DI すべき。)

val timestamp = Instant.ofEpochMilli(1234)
val clock = TestClock(timestamp, ZoneOffset.UTC)
clock.plus(6.minutes)

Test Containers

  • Test Containers | Kotest

  • Docker Container を使ってテスト時にミドルウェアをセットアップする。

  • 大元は java の Testcontainers というプロジェクト

  • kotest からそれを簡単に使えるようにする拡張が提供されている

  • 既製品の Container もあるし、汎用的に image を指定することもできる

    • Database (Postgres, JDBC)

    • GCloud (Pub/Sub, Firestore, BigTable, Spanner)

    • Elasticsearch, Solr

    • etc…

val container = install(TestContainerExtension("redis:5.0.3-alpine")) {
    startupAttempts = 1
    withExposedPorts(6379)
}

Mockk

kotlin 製の mock ライブラリ。Kotest とも相性が良く、はまりどころも少ないので便利。 他にも mockito とか色々あったが、今は kotlin で mock といったら mockk がデファクトスタンダード?

How to Use

基本は

val foo: Foo = mockk<Foo>()
every { foo.bar(any()) } returns 3

のように書くと mock できる。 引数の情報を使って mock の返り値を作りたい場合は、

every { foo.bar(any()) } answers {
    firstArg<Int>() + 1
}

のように answers を使う。
answers の引数の lambda の中では

it.invocation.args: List<Any?> 

をつかえるので、割となんでもできる。

Matcher

https://mockk.io/#matchers

  • every { foo.bar(any()) } の any() は何が来てもいいですよ、というマーク。

  • 例えば every { foo.bar(1) } のようにすると、1 以外の引数で呼ばれたら落ちる。

  • 引数に指定するものは Matcher と mockk では呼ばれている

    • or(1, 2) とか、 less(1) とか match { it == 3 } とかが指定できる。

    • 具体的な値を指定した時は eq(x) として扱われる。

      • every { foo.bar(1) } は every { foo.bar(eq(1)) } と同値

Constructor Mocks

RSpec の expect_any_instance_of 的なことができてしまう(!)

mockkConstructor(Foo::class)

every { anyConstructed<Foo>().bar(any()) } returns 1
Foo().bar(0).shouldBe(1)
verify(exactly = 1) { anyConstructed<Foo>().bar(0) }

every { constructedWith<Foo>(1).bar(any()) } returns 3
Foo(1).bar(2).shouldBe(3)
verify(exactly = 1) { constructedWith<Foo>(1).bar(2) }

Chained calls

method chain を一個ずつ mock する必要はなく、丸っと chain 全体を mock できる。
(面白いから紹介したけど、そもそもそこまでの mock をしなきゃいけない状況がよろしくない匂いがする。)

val car = mockk<Car>()

every { car.door(DoorType.FRONT_LEFT).windowState() } returns WindowState.UP
every { car.door() } returns mockk {
    every { it.windowState() } returns ....
}

car.door(DoorType.FRONT_LEFT) // returns chained mock for Door
car.door(DoorType.FRONT_LEFT).windowState() // returns WindowState.UP

verify { car.door(DoorType.FRONT_LEFT).windowState() }

confirmVerified(car)

Capture

モックが呼ばれた時の引数を後から使うこともできる。

val barSlot = slot<Int>()
every { foo.bar(barSlot) } returns 3

foo.bar(3)

barSlot.isCaptured.shouldBeTrue()
barSlot.captured.shouldBe(3)

のように使える。

Unnecessary stubbing

無駄に stub しちゃっていないかテストできる。実装を後から変更したときに、本質的でない stub が残ってテストが読みにくくなるのを防げる。

val m = mockk<Foo>()
every { m.bar() } just Runs
checkUnnecessaryStub(m)

Koin + Kotest + Mockk を正しく使うために、Kotest の Lifecycle を理解する

Lifecycle hooks | Kotest
テストケースの前後や、一定のテストの塊の前後に、特定の処理を挟むことができる。

class ExampleTest : DescribeSpec({
    val foo = Foo()

    beforeTest {
        println("テストケースの手前で実行される")
    }
    afterTest {
        println("テストケースの後に実行される")
    }
})

理解するにあたって必要な事前知識:

  • kotest の世界ではテストケース (TestCase のインスタンス) はネストする

    • describe は一つのテストケース (TestType.Container)。 it も一つのテストケース (TestType.Test)。

    • Container は Test を内包する。

  • デフォルトでは、FooTest の中にどれだけ describe や it があっても、FooTest のインスタンスは一度きりしか作られない

    • IsolationMode.SingleInstance (ref: https://kotest.io/docs/framework/isolation-mode.html )

    • したがって describe の外に書いた val などは全ての子テストケースで共有される

    • 挙動は override 可能 (Global にも設定できるしテストクラスごとにも設定できる)

よく使う Lifecycle Hooks

  • beforeAny, afterAny .. あらゆる TestCase に対して呼ばれる (つまり describe でも呼ばれる)

  • beforeTest, afterTest .. beforeAny, afterAny の alias (!!)

  • beforeEach, afterEach .. TestType.Test なテスト (つまり it )に対して呼ばれる

  • beforeContainer, afterContainer .. TestType.Container なテスト (つまり describe )に対して呼ばれる


この記事が気に入ったらサポートをしてみませんか?