21
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Swift で Parameterized-test するライブラリを作ってみた話。あるいは Function builders の使いどころ。

Last updated at Posted at 2020-03-13

Tl;Dr

XCTest で普通にテストを書くとこうなるところを、

通常のXCTestの書き方
XCTAssertEqual(Calc.add( 0,  1),  1)
XCTAssertEqual(Calc.add(-1, -1), -2)
XCTAssertEqual(Calc.add(-3,  2), -1)

こう書ける。

SwiftParamTestの書き方
assert(to: Calc.add) {
    args(( 0,  1), expect:  1)
    args((-1, -1), expect: -2)
    args((-3,  2), expect: -1)
}

あるいはこう。

演算子を使った書き方
assert(to: Calc.add) {
    expect(( 0,  1) ==>  1)
    expect((-1, -1) ==> -2)
    expect((-3,  2) ==> -1)
}

可読性のために引数ラベルを追加することもできる。

引数ラベルを利用
assert(to: Calc.add) {
    expect((x:  0, y:  1) ==>  1)
    expect((x: -1, y: -1) ==> -2)
    expect((x: -3, y:  2) ==> -1)
}

MITライセンスの OSS として公開しているので、もし興味があれば是非使ってみて欲しい。
https://github.com/YusukeHosonuma/SwiftParamTest

Parameterized Test(a.k.a テーブル駆動テスト)とは

テストコードを記述する際に、『入力値』と『期待値』を列挙するように記述する方法を『Parameterized Test』または『テーブル駆動テスト(Table-driven test)』と呼んだりする。

冒頭のCalc.addのテストパターンを表に起こすと以下のようになる。

引数 x 引数 y 期待値
0 1 1
-1 -1 -2
-3 2 1

このような網羅性が分かりやすい『表形式』の表現をテストコードに持ち込んだものと考えると分かりやすいかもしれない(私も厳密な定義は把握していない)。

特に Go においては一般的なテストコードの書き方となっており、標準ライブラリのテストコードも殆どテーブル駆動テスト(Go ではこちらでの呼称が一般的に思う)で記述されている。例えば src/strings/strings_test.go を読むと、大量のテストデータがテーブル形式で定義されていることが分かる。

Parameterized Test のメリット

ある1つの関数をテストする際、多数の入力値をテストしたいケースは多いが、それを典型的なテストコードとして記述すると『関数呼び出し』と『アサーション』が重複して読みづらくなることがある。

例えば、FizzBuzz関数のテストコードを素直に記述すると以下のようになる。

XCTAssertEqualを利用した素直な書き方
XCTAssertEqual(fizzBuzz( 1), "1")
XCTAssertEqual(fizzBuzz( 2), "2")
XCTAssertEqual(fizzBuzz( 3), "Fizz")
XCTAssertEqual(fizzBuzz( 4), "4")
XCTAssertEqual(fizzBuzz( 5), "Buzz")
XCTAssertEqual(fizzBuzz( 6), "Fizz")
XCTAssertEqual(fizzBuzz( 7), "7")
XCTAssertEqual(fizzBuzz( 8), "8")
XCTAssertEqual(fizzBuzz( 9), "Fizz")
XCTAssertEqual(fizzBuzz(10), "Buzz")
XCTAssertEqual(fizzBuzz(11), "11")
XCTAssertEqual(fizzBuzz(12), "Fizz")
XCTAssertEqual(fizzBuzz(13), "13")
XCTAssertEqual(fizzBuzz(14), "14")
XCTAssertEqual(fizzBuzz(15), "FizzBuzz")

このコードはシンプルでスッキリしており、空白を意図的に追加することで2桁の数字の縦ラインがそろっていて申し分ないように思える。

しかし、アサーションであるXCTAssertEqualと関数呼び出しであるfizzBuzzが重複して登場しており、ややノイズ感があることが分かる。今回はテスト対象の関数名が短く、かつ引数も1つなのでそこまで読みづらくは感じないかもしれないが、一般的な関数・メソッドはこの例よりも複雑だろう。

これを SwiftParamTest では次のように記述できる。

assert(to: fizzBuzz) {
    args( 1, expect: "1")
    args( 2, expect: "2")
    args( 3, expect: "Fizz")
    args( 4, expect: "4")
    args( 5, expect: "Buzz")
    args( 6, expect: "Fizz")
    args( 7, expect: "7")
    args( 8, expect: "8")
    args( 9, expect: "Fizz")
    args(10, expect: "Buzz")
    args(11, expect: "11")
    args(12, expect: "Fizz")
    args(13, expect: "13")
    args(14, expect: "14")
    args(15, expect: "FizzBuzz")
}

テストにおいてより重要である『入力値』と『期待値』に目が行くようになったのではないだろうか。

for 文ではダメなのか?

ここまで読み進めた方は「事前に配列でデータを用意し、for ループで回せばよいのではないか?」と疑問を持たれるかもしれない。

つまり、

配列でデータを定義してfor文で回す
let tests: [(n: Int, expected: String)] = [
    ( 1, "1"),
    ( 2, "2"),
    ( 3, "Fizz"),
    ( 4, "4"),
    ( 5, "Buzz"),
    ( 6, "Fizz"),
    ( 7, "7"),
    ( 8, "8"),
    ( 9, "Fizz"),
    (10, "Buzz"),
    (11, "11"),
    (12, "Fizz"),
    (13, "13"),
    (14, "14"),
    (15, "FizzBuzz"),
]

for test in tests {
    XCTAssertEqual(fizzBuzz(test.n), test.expected)
}

で十分ではないか、ということだ。

この疑問はもっともなもので、実際に Go におけるテーブル駆動テストではそのように実装されるのが一般的となっている。

このテストコードは期待通りに動作するが、テストが失敗した場合に微妙な振る舞いをする。例えば、常に与えられた数値を返す、誤ったfizzBuzz実装に対してテストを実行した場合に次のようになる。

誤ったfizzBuzz実装
func fizzBuzz(_ n: Int) -> String {
    "\(n)"
}

756E6326-D6FA-4678-9A17-34836810B83A.png

上記のスクリーンショットでは、XCTest のエラー情報が1行に集約されており、どこのテストデータが失敗したのかパット見では判断がつかない。

もちろん、クリックして展開するなどしてアサーションエラーの内容を確認すれば分かるが、できれば入力データの行にエラーが表示されて欲しいと感じるだろう。
18293EAE-25CC-4048-BA7B-A6F5A6C32A47.png

SwiftParamTest では次のように入力データの行にエラー情報が表示される。
38911422-43FD-4004-946C-DEEC3B5CC411.png

#file と #line

XCTAssertEqual(および他のXCTAssert系のAPI)には、エラーが発生したときの位置情報として、以下のように:file:lineを引数として渡せるようになっている。

func XCTAssertEqual<T>(..., file: StaticString = #file, line: UInt = #line) where T : Equatable

デフォルト引数はそれぞれ#file#lineとなっているが、これは関数呼び出し側の『ファイル』と『行』がコンパイル時に設定されるようになっている。

赤色のエラー情報の表示は、これらの情報をもとに決定されているため、デフォルト引数の代わりに任意の値を与えることで表示位置を制御することができる。

言いかえると、前述の for ループを使ったコードは次のように改善できる。

#lineを利用して行情報を与える
let tests: [(line: UInt, n: Int, expected: String)] = [
    (#line,  1, "1"),
    (#line,  2, "2"),
    (#line,  3, "Fizz"),
    (#line,  4, "4"),
    (#line,  5, "Buzz"),
    (#line,  6, "Fizz"),
    (#line,  7, "7"),
    (#line,  8, "8"),
    (#line,  9, "Fizz"),
    (#line, 10, "Buzz"),
    (#line, 11, "11"),
    (#line, 12, "Fizz"),
    (#line, 13, "13"),
    (#line, 14, "14"),
    (#line, 15, "FizzBuzz"),
]

for test in tests {
    XCTAssertEqual(fizzBuzz(test.n), test.expected, line: test.line)
}

実行結果は以下のようになり、エラー情報が集約される問題が解消されていることが分かる。
A5174579-37E6-4D10-87C3-C388D74AFD9A.png

SwiftParamTest でも内部的にやっていることは同じで、DSL用の関数によってその処理が内部に隠居されているだけに過ぎない。

これは独自のアサーション関数を自作する場合でも利用できるテクニックで、拙著『iOSアプリ開発自動テストの教科書〜XCTestによる単体テスト・UIテストから、CI/CD、デバッグ技術まで』にも記載しているので、詳しく知りたい方はそちらを参照いただければと思う。

露骨な宣伝だいやらしい・・・

ライブラリ化する意義

あえてライブラリ化したのは以下の理由からである。

  1. ボイラープレートの削減
  2. 誰が書いても同じように記述できる
  3. 余計なローカル変数を作らずに済むように

しかし、API による DSL を利用しているため for ループに比べて柔軟性は少ない というデメリットもある。また、これは今回のライブラリに限った話ではないが、当然ながらライブラリのAPIへのロックインが発生する。

そうしたトレードオフを考慮して、あえてボイラープレートを受け入れ、前述した for ループのコードを毎回記述するという選択肢もありうるだろう(Xcodeのスニペットを利用すれば、さほど苦にはならないだろう)。

そのライブラリは「捨てやすい」か?

これは余談だが、私はライブラリを導入する際は「不要になった時に捨てやすいか?」ということも考慮に入れている。例えば、RxSwift などは新しいプログラミングパラダイムをコードに導入するため『自分たちに合わなかった』と後から気づいても、そう簡単には引き剥がせない。

その点、SwiftParamTest は薄いラッパー戦略(これは Java の Web フレームワークである SAStruts で知ったものだ)を採っており、自分たちに合わないと思ったら簡単に引き剥がすことができる(おそらく一日もあれば十分すぎるだろう)。

Function builders による可読性の高い DSL

ここまでのコードで気づいた方も多いと思うが(というより記事タイトルに入っているが)、SwiftParamTest では Swift 5.1 で導入された『Function builders』という機能を利用している。

これは 2.0.0 のリリース で導入したものであり、Swift 5.1 未満でも利用できるように用意されている互換API と比較すると次のような差異がある。

// Function builders を利用したAPI
assert(to: fizzBuzz) {
    args( 1, expect: "1")
    args( 2, expect: "2")
    args( 3, expect: "Fizz")
    ...
}

// Swift 5.1 未満でも利用できる互換API
assert(to: fizzBuzz, expect: [
    args(1, expect: "1"),
    args(2, expect: "2"),
    args(3, expect: "Fizz"),
    ...
])

Function builders を利用したAPIに比べ、互換APIは次のような違いがあるのが分かる。

  • 配列の構文でテストデータを定義する
  • そのため末尾に,が必要

かなり宣言的になっているとはいえ、どうしても Swift のプログラムが記述されているという印象を与え、わずかとはいえシンタックスノイズでもある(通常、配列構文をこのように記述することは稀なので、特に初心者は閉じ括弧を間違えやすいという問題もある)。

その点、Function builders を利用した API はそうしたノイズが排除されており、宣言的で一種の DSL としてみなすことができると思う。これはまさに SwiftUI で謳われている文句であり、Function builders は DSL を導入する際に有用であると今回実装してみて感じた。

Function builders については Qiita にも記事が上がっているが、個人的には以下の記事がわかりやすかった(といってもななめ読みしかしていないのだが・・・)。
https://medium.com/@carson.katri/create-your-first-function-builder-in-5-minutes-b4a717390671

Let’s Parameterized Test

ここまで読んで「使ってみよう」と思った方がいたら、まずは README を一読いただければと思う。

私の英語力が低いゆえ、つたない文章ばかり並んでいるのは間違いないが、『コードスニペット』や『カスタムアサーションの利用方法』など、最低限の情報は伝わるだろうと思っている。

Issue、PR、なども歓迎である(と言いつつ、Contributionガイドも作成していないのだが・・・)。

リリース名にアニメや漫画タイトル

これは本当に余談であるが 、React.js と並んで人気のあるフロントエンドのフレームワークである vue.js では、メジャーリリースの際にアニメや漫画のタイトルが含まれている。

例えば直近だと v2.6.0 のリリースでは『マクロス』 だし、v2.5.0 のリリースでは『レベルE』 だったりする(『レベルE』は宇宙人の話だが、なぜ『レベルE』なのかというとそれは・・・)。

私はこうした遊び心がすきなので インスパイア させてもらうことにした。

気になった方は是非とも リリース一覧 を見て欲しい。

最後に

私は去年(2019年)、共著であるが以下の 2冊のiOSテスト本 に著者として参画させていただいた。

私は上記のどちらでもユニットテストに関わる章の執筆に参画しながら、Parameterized Test について詳しく触れることができず個人的に心残りであった。

本記事が上記2冊を補完する・・・という表現は大げさかもしれないが一種の『おまけ』のような内容として楽しめる記事になっていれば著者冥利に尽きる。

露骨な宣伝だいやらしい・・・

もはや、↑のセリフが言いたいだけかもしれないがこのあたりで。

21
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?