🫖

Kotlin と jqwik で Property Based Testing

2022/10/06に公開約10,100字

概要

Property Based Testing とは、テスト対象の特性をもとにデータの自動生成とテストをする手法です。
本記事では、Property Based Testing について、メリット、Kotlin で実践する方法を紹介します。

Property Based Testing について

概要

Property Based Testing(以下、PBT)について、より詳細に説明します。
PBT は、テストデータの特性(Property)を条件に自動生成して、生成された値について全て真であるかテストする手法です。
開発者が固有の値を用意する必要がなく、自動生成で同じ特性(ex: 1 以上 10 未満など)を持った膨大な値を検証できます。
有名なライブラリで言えば、Haskell の QuickCheck、Scala の scalaprops、ScalaCheck などがあります。
Haskell の QuickCheck を発端に広まったと考えています。

PBT の歴史は古く、George Fink 氏による論文は 1997 年と 1994 年に遡ります。
[Fink:ACSAC1994]が出版された時点ではセキュリティ的な観点から紹介されていました。その後、[Fink:ACM SIGSOFT Software Engineering Notes1997]で品質的な観点から紹介されています。

以下、[Fink:ACM SIGSOFT Software Engineering Notes1997]から Abstract の引用と翻訳です。

The goal of software testing analysis is to validate that an implementation satisfies its specifications. Many errors in software are caused by generalizable flaws in the source code. Property-based testing assures that a given program is free of specified generic flaws. Property-based testing uses property specifications and a data-flow analysis of the program to guide evaluation of test executions for correctness and completeness.
ソフトウェアのテスト分析の目的は、実装がその仕様を満たしていることを検証することである。ソフトウェアのエラーの多くは、ソースコードに含まれる一般化可能な欠陥によって引き起こされる。プロパティベースのテストは、与えられたプログラムに指定された一般的な欠陥がないことを保証する。プロパティベースのテストでは、プログラムのプロパティ仕様とデータフロー分析を用いて、テスト実行の正しさと完全性を評価します。

[Fink:ACM SIGSOFT Software Engineering Notes1997] より引用と翻訳

以下、[Fink:ACSAC1994] より Abstract の引用と翻訳です。

Addresses the problem of testing security-relevant software, especially privileged (typically, setuid root) and daemon programs in UNIX. The problem is important, since it is these programs that are the source of most UNIX security flaws. For some programs, such as the UNIX sendmail program, new security flaws are still being discovered, despite being in use for many years. For special-purpose systems with fewer users, flaws are likely to remain undiscovered for even longer. Our testing process is driven by specifications we create for the privileged programs. These specifications simultaneously define the allowed behavior far these programs and identify problematic system calls, regions where the program is vulnerable, and generic security flaws. The specifications serve three roles in our testing methodology: as criteria against which a program is sliced, as oracles against which it is tested, and as a basis for generating useful tests. Slicing is employed to significantly reduce the size of the program to be tested. We show that a slice of a privileged program (rdist) with respect to its security specifications is quite small. We introduce the Tester's Assistant, a collection of tools to mechanize the process of testing security-related C programs.< >
セキュリティに関連するソフトウェア、特に UNIX の特権プログラム (典型的には setuid root) やデーモンプログラムをテストする際の問題を扱います。この問題は重要です。なぜなら、ほとんどの UNIX のセキュリティ上の欠陥の原因はこれらのプログラムだからです。UNIX の sendmail プログラムのように、何年も使われているにもかかわらず、いまだに新しいセキュリティ欠陥が発見されているプログラムもある。また、利用者の少ない特殊な用途のシステムでは、さらに長い間、欠陥が発見されない可能性がある。私たちのテスト工程は、特権プログラムに対して作成した仕様書によって推進されています。この仕様書は、特権プログラムに対して許される動作を定義すると同時に、問題のあるシステムコール、プログラムが脆弱な領域、一般的なセキュリティ上の欠陥を特定するものです。仕様書は、私たちのテスト手法において、プログラムをスライスする際の基準として、プログラムをテストする際の卦として、そして有用なテストを生成するための基礎として、3 つの役割を担っています。スライシングは、テストするプログラムのサイズを大幅に縮小するために採用される。我々は、特権プログラム(rdist)のセキュリティ仕様に対するスライスが非常に小さいことを示す。セキュリティ関連の C 言語プログラムのテストプロセスを機械化するツール群である Tester's Assistant を紹介する。

[Fink:ACSAC1994] より引用と翻訳

Property Based Testing のメリット

本記事で紹介する PBT のメリットは 2 つです。
PBT を実現するライブラリでは、これらのメリットを享受できます。

  • エッジケースのテストが容易
    • 境界値を定義するだけで、膨大なテストをおこなえるため
  • 特性による生成条件を共通化
    • 特性(Property)をもとにテストデータを生成するため、値と比較して変更に強い
    • 関数化できるため、使いまわしやすい

PBT を実現するライブラリの 1 つである jqwik について解説していきます。

Kotlin での実践方法

jqwik

前述した Haskell や Scala のライブラリのように、Java と Kotlin には jqwik というライブラリが公開されています。

https://jqwik.net/

以下の 4 つのテストは、jqwik の Kotlin チュートリアル(Property-based Testing in Kotlin)に記載されているソースコードです。
1 つ目の通常のテスト(リストを逆にするテスト)は値をハードコードしています。残り 3 つのテストで PBT によって特性をもとに自動生成された値でテストします。

以下の例で分かる通り、jqwik では、アノテーション(@ForAll@Size)を用いてテストデータを自動生成します。
以降 jqwik を用いて、オブジェクトに対してどのように PBT でき、PBT のメリットがあるのか確認していきます。

// PBT ではないテストパターン
@Test
fun `any list with elements can be reversed`() {
    val original: List<Int> = listOf(1, 2, 3)
    assertThat(original.reversed()).containsExactly(3, 2, 1)
}

// PBT のテストパターン 1
@Property
fun `reversing keeps all elements`(@ForAll list: List<Int>) {
    assertThat(list.reversed()).containsAll(list)
}

// PBT のテストパターン 2
@Property
fun `reversing twice results in original list`(@ForAll list: List<Int>) {
    assertThat(list.reversed().reversed()).isEqualTo(list)
}

// PBT のテストパターン 3
@Property
fun `reversing swaps first and last`(@ForAll @Size(min=2) list: List<Int>) {
    val reversed = list.reversed()
    assertThat(reversed[0]).isEqualTo(list[list.size - 1])
    assertThat(reversed[list.size - 1]).isEqualTo(list[0])
}

サンプル

本記事のために、Kotlin で jqwik 用いて PBT を実践しました。
サンプルコードは以下です。

https://github.com/Msksgm/kotlin-jqwik-sample

サンプルコードのsrc/main/kotlin/article/配下のArticleBodyTitleオブジェクトで実践していきます。

メリット 1 エッジケースのテストが容易

エッジケースのテストについて確認します。
以下のソーヅコードはデータクラスBodyImplを生成する interface Bodyがあります。
Bodyは「1 文字以上 1024 文字以下」という特性を持っています。

Body
interface Body {
    val value: String

    /**
     * Factory メソッド
     */
    companion object {
        /**
         * バリデーションあり
         *
         * @param body
         * @return
         */
        fun new(body: String): Body {
            if (body.isEmpty() || body.length > 1024){
                throw  IllegalArgumentException("body は 1 以上 1024 文字以下です")
            }

            return BodyImpl(body)
        }

        /**
         * バリデーションなし
         * DB から生成時 or new メソッドのテストにつかう
         *
         * @param body
         * @return
         */
        fun reconstruct(body: String): Body = BodyImpl(body)
    }

    /**
     * 実装
     */
    private data class BodyImpl(override val value: String) : Body
}

PBT を使用しない正常系のテスト(以下)では、値をハードコードします。
引数の"dummy-bodyは、「1 文字以上 1024 文字以下」という特性を満たしていますが、エッジケースではなく本当に条件を満たしているかわかりません。
エッジケースのテストのため、「0 文字」「1 文字」「1024 文字」「1025 文字」のテスト自体は可能です。
しかし、次のデメリットがあります。

  • 冗長になりがち
  • 条件を満たした値(特に、1024 文字)の用意が手間
  • 変更(1024 文字 -> 2048 文字など)に弱い
BodyTest PBT なし
    @Test
    fun `正常系 not PBT`() {
        /**
         * given:
         */

        /**
         * when:
         * 1 以上 1024 文字以下であるが、エッジケースではない
         */
        val actual = Body.new("dummy-body")
        val expected = Body.reconstruct("dummy-body")

        /**
         * then:
         */
        assertThat(actual).isEqualTo(expected)
    }

そこで、jqwik を用いた PBT に書き換えると以下になります。
アノテーションは PBT を宣言する@Propertyと特性を付与する@ForAll@StringLengthを使用します。
結果、validStringという「1 文字以上、1024 文字以下」という特性も持ったデータが自動生成されます。
エッジケースも含められ、1000(@Propertytriesで指定したパラメータ)個のデータには重複がありません。

BodyTest PBT
    @Property(tries = 1000) // デフォルトでも 1000 だが明示的に指定
    fun `正常系 PBT`(@ForAll @StringLength(min = 1, max = 1024) validString: String) {
        /**
         * given:
         */

        /**
         * when:
         */
        val actual = Body.new(validString)
        val expected = Body.reconstruct(validString)

        /**
         * then:
         */
        assertThat(actual).isEqualTo(expected)
    }

異常系においても同様のことが可能です。
「1024 文字超過、1 未満」をアノテーションによって表現できます。

    @Property
    fun `異常系 PBT`(@ForAll @StringLength(min = 1025, max = 0) invalidString: String) {
        /**
         * given:
         */

        /**
         * when:
         */
        val e : IllegalArgumentException = assertThrows {
            Body.new(invalidString)
        }
        val actual = e.message
        val expected = "$invalidString は不正な値です。body は 1 以上 1024 文字以下にしてください。"

        /**
         * then:
         */
        assertThat(actual).isEqualTo(expected)
    }

メリット 2 特性による生成条件を共通化

jqwik の生成方法は、オブジェクト(Supplier)に切り出せます。
以下の例では、jqwik に用意されている、ArbitraryArbitrarySupplierという型で、生成方法を切り出しています。
BodyValidRangeオブジェクトは「1 文字以上 1024 文字以下の文字列」を生成するオブジェクトになりました。

Arbitraryを用いて生成方法をオブジェクトに切り出した
    class BodyValidRange : ArbitrarySupplier<String> {
        override fun get(): Arbitrary<String> = Arbitraries.strings().ofMinLength(1).ofMaxLength(1024)
    }

    @Property
    fun `正常系 PBT with Arbitraries`(
        @ForAll @From(supplier = BodyValidRange::class) validString: String
    ) {
        /**
         * given:
         */

        /**
         * when:
         */
        val actual = Body.new(validString)
        val expected = Body.reconstruct(validString)

        /**
         * then:
         */
        assertThat(actual).isEqualTo(expected)
    }

Supplier を作成すると、他のテストで使いまわせるようになり、同一の生成条件でテスト可能です。
以下は、BodyオブジェクトとTitleオブジェクトを引数にもつ、Articleオブジェクトのテストの例です。
引数にBodyTestTitleTestで使われる Supplier を利用しています。
BodyTitleの特性を満たしつつ、Articleのテストがおこなえます。

ArticleTest
class ArticleTest {
    @Property
    fun `正常系 PBT`(
        @ForAll @From(supplier = TitleTest.TitleInvalidRange::class) title: String,
        @ForAll @From(supplier = BodyTest.BodyValidRange::class) body: String,
    ) {
        // テストを記述する
    }
}

まとめ

PBT についてと、jqwik を用いた Kotlin における実践方法を紹介しました。
PBT は、定義された条件に合わせて自動生成された膨大な値に対してテストをおこなう手法です。
本記事で紹介したメリットは「エッジケースのテストが用意」「性による生成条件を共通化」です。これらによって、品質保証に加えて追加や修正に対して柔軟に対応できます。
本記事では簡単なアノテーションのみを紹介しましたが、他のアノテーションを多数用意されているので、確認してみてください。

https://jqwik.net/docs/current/user-guide.html

参考

https://ieeexplore.ieee.org/document/367311

https://dl.acm.org/doi/abs/10.1145/263244.263267

https://gakuzzzz.github.io/slides/property_based_testing_for_domain/#1

https://jqwik.net/

https://zenn.dev/ryo_kawamata/articles/22d4408bd1f138

https://dl.acm.org/doi/abs/10.1145/263244.263267

https://ieeexplore.ieee.org/document/367311

Discussion

ログインするとコメントできます