My Favorite Things - Coding or die.

とある技術者の経験記録、的な。

Quick/Nimbleで独自Matcherを自作して、テストコードの可読性を上げる

さて今年も終わりに近づいてきました。

この記事はモバイル 自動化 / 自動テスト Advent Calendar 2017の17日目の記事です。

実はアドベントカレンダーの参加は今回が初めてだったりするのですが、まぁそれはどうでも良いですよね。

Tl;Dr

Quick/Nimbleで、独自Macherを作成するとテストコードの可読性が上がるかも?

// 標準のMatcherを使用
it("use built-in matcher") {
    expect(person1.isTeen()).to(beTrue())
    expect(person2.isTeen()).to(beFalse())
    expect(person1.gender).to(equal(person2.gender))
    expect(person1.gender).toNot(equal(person3.gender))
}

// 独自のMatcherを使用
it("use custom matcher") {
    expect(person2).to(teen())
    expect(person1).toNot(teen())
    expect(person1).to(sameGender(person2))
    expect(person1).toNot(sameGender(person3))
}

iOS Test Night

さて、iOS Test Night #6 - 1周年 - では、Quick/Nimbleのイケてないところを改善したい、といった内容で発表をさせていただきました。 speakerdeck.com

その中で「Matcher APIはコード補完が効きづらいのがイケてない」的な話をしました。

たしかにMatcher APIはコード補完が効きづらく、初心者にとっては学習コストが高いものです。しかし、Matcher APIが悪いかというとそうではなく、あえて柔軟に作られるように設計されています。

そこで今回は、独自のMatcherを作成する方法と、それによってテストコードがどのように変わるのか見ていきたいと思います。

Quick?

QuickはSwift製のBDDフレームワーク(+Matcher APIベースのAssertionライブラリ)です。

私の記憶では、Swiftがリリースされてから2日後くらいには公開されていた、かなり早くからあるテスティングフレームワークです。Ruby製のBDDフレームワークであるRSpecなどにインスパイアされていると、公式のREADMEで書かれています。

BDDフレームワークとは「振る舞い(Behavior)」に着目してテストコードを書こうという思想のテスティングフレームワークです。

歴史的には、先にTDDによる「テスト駆動」という「テストを先に書く」という思想を開発にもたらされました。

しかし、「テストを書くこと」が目的になるという悪い側面もあり、それを解決するために(テスト対象の)「振る舞い」に着目することで質の良いテストを書こう、ということでBDDが生まれたとかどうとか。(そんな話を聞いた覚えがあるような、くらいなので間違っているかも)

以下のようにテストコードが(DSLによって)構造化されるのが最大の特徴になっています。

class SampleTest: QuickSpec {
    override func spec() {
        describe("足し算") {
            context("1 + 1") {
                it("2") {
                    expect(1 + 1).to(equal(2))
                }
            }
            context("1 + 2") {
                it("3") {
                    expect(1 + 2).to(equal(3))
                }
            }
        }
    }
}

よくありがちな単純な例ですが、テストが構造化されるのが見て取れるかと思います。

コード中に出てくる単語は、以下のような意味を持っています。

  • describe:テストの概要説明
  • context:テストの条件
  • it:期待される振る舞い

このような形で、テストコード全体が構造化されるのがBDDフレームワークの特徴になっており、さまざまな言語でBDDフレームワークが作成されていますが、この見た目に関してはだいたい似たような感じになっています。

Nimble?

Quick/Nimbleとセットで呼ばれる事が多く、実際セットで使われることが多いため、あまり意識されることは多くない(ように思える)のですが、Nimbleは「Assertionライブラリー」という位置付けになっています。

QuickがBDDフレームワークとしてDSLを提供するのに対し、NimbleはXCTestでいうところのXCTAssertEqualのような期待値と結果を比較して、テストの成否を判定する機能を提供しています。

さきほどのコード中にexpect(1 + 1).to(equal(2))といったコードがありましたが、この部分はQuickではなくNimbleに用意されたAPIを利用しています。テストコードが自然な英文になるようなAPIが提供されており、「Matcher API」と呼ばれることが多いです。

Matcher APIのメリットは、テストコードが自然な英文になることで意図が分かりやすくなることと、失敗時のエラーメッセージが分かりやすいという点が挙げられます。

expect(1 + 1).to(equal(3))
// => expected to equal <3>, got <2>

この失敗時のエラーメッセージが分かりやすいというのは意外と重要で、プロダクトコードに何らかの変更を加えた時に既存のテストが失敗した場合は、その原因をできるだけ早く知りたいと思うはずです。失敗時のエラーメッセージが不親切だと、なぜテストが失敗したかの原因を調べるのに多大な時間を消費してしまいます。

これはミクロな視点で見た時は大した問題にならないように見えますが、マクロな視点で見ると結構重要だったりします。

CI(継続的インテグレーション)により自動テストが失敗した場合に、その原因がすぐに分からない場合は(他の作業との兼ね合いで)修正が後回しにされることが多いように感じます。それが繰り返されると、テストコードのメンテナンスがされなくなり、最後には捨てられるということも少なくありません。(実際に、私も以前関わったPJで目の当たりにしています)

Matcher APIの欠点

Matcher APIは以下を提供することで、前述したような問題への対処を試みています。

  • テストコードの意図を明確にする
  • テストが失敗したときの原因を明確にする

一方で、IDEによるコード補完との相性は悪く、初心者にとっては学習コストが高めです。

その対処として、

といった改善策を考えた、というのが冒頭のiOS Test Nightでの発表内容になります。(詳しくはスライドをご参照ください)

独自Matcherを自作する

ようやくタイトル回収までたどり着きました。

Matcher APIの他のメリットとして、独自のMatcherを作成することが出来るという点が挙げられます。つまり、Built-inのAPIで十分な可読性が得られなければ、自分で拡張することもできるという意味です。

今回は以下のPerson構造体に対して、「ティーンエイジャー(13〜19歳の間)であること」と「性別が同じであること」というMatcherを作成してみたいと思います。

struct Person {
    
    enum Gender {
        case male
        case female
    }
    
    let age: Int
    let gender: Gender
    
    func isTeen() -> Bool {
        return 13...19 ~= age
    }
}

期待値を受け取らないMatcher - teen()

まずは「ティーンエイジャー(13〜19歳の間)であること」を確認するテストコードについてです。

標準のMatcherを利用したテストコードを見てみます。

let person1 = Person(age: 17, gender: .male)
let person2 = Person(age: 20, gender: .male)

describe("Person") {
    
    describe("is teen?") {

        it("use built-in matcher") {
            expect(person1.isTeen()).to(beTrue())
            expect(person2.isTeen()).to(beFalse())
        }
    }
}

悪くありませんが、isTeen()の呼び出し結果がtrue/falseであること、といった感じで少しだけ回りくどいテストコードのようにも見えます。

次に自作したteen()というMacherを利用したコードを見てみます。

let person1 = Person(age: 17, gender: .male)
let person2 = Person(age: 20, gender: .male)

describe("Person") {
    
    describe("is teen?") {

        it("use custom matcher") {
            expect(person1).to(teen())
            expect(person2).toNot(teen())
        }

    }
}

さきほどの標準APIに比べて、意図が分かりやすくなったのではないでしょうか?

以下がMatcherの実装です。

func teen() -> Predicate<Person> {
    return Predicate { (actualExpression: Expression<Person>) throws -> PredicateResult in
        let message = ExpectationMessage.expectedTo("teenager")
        if let actualValue = try actualExpression.evaluate() {
            return PredicateResult(
                bool: actualValue.isTeen(),
                message: message
            )
        } else {
            return PredicateResult(
                status: .fail,
                message: message
            )
        }
    }
}

ゴチャゴチャしているように見えますが、だいたい決まりきったコードパターンになっているので、公式のREADMEに書かれたサンプルコードを真似すればわりと簡単に作成できます。(実際、内部的な実装までは把握していません)

beTrue()のように期待値を受け取らないパターンは、このような感じのコードで実装することが出来ます。

期待値を受け取るMatcher - ()

次に「性別が同じであること」を確認するテストコードについてです。

同じように標準APIのコードを見てみます。

let person1 = Person(age: 17, gender: .male)
let person2 = Person(age: 20, gender: .male)
let person3 = Person(age: 15, gender: .female)

describe("same gender") {
    
    it("use built-in matcher") {
        expect(person1.gender).to(equal(person2.gender))
        expect(person1.gender).toNot(equal(person3.gender))
    }
}

やはり悪くはありませんが、視覚ノイズが多く、パット見で意図を読み取るのが難しくなっている印象を受けます。

次に自作したsameGender()によるテストコードです。

let person1 = Person(age: 17, gender: .male)
let person2 = Person(age: 20, gender: .male)
let person3 = Person(age: 15, gender: .female)

describe("same gender") {
    
    it("use custom matcher") {
        expect(person1).to(sameGender(person2))
        expect(person1).toNot(sameGender(person3))
    }
}

先程の標準APIに比べ、はるかにテストの意図が分かりやすくなったのではないでしょうか?

sameGender()の実装は以下のとおりです。

func sameGender(_ expectedValue: Person) -> Predicate<Person> {
    return Predicate { (actualExpression: Expression<Person>) throws -> PredicateResult in
        let message = ExpectationMessage.expectedActualValueTo("same gender <\(expectedValue)>")
        if let actualValue = try actualExpression.evaluate() {
            return PredicateResult(
                bool: actualValue.gender == expectedValue.gender,
                message: message
            )
        } else {
            return PredicateResult(
                status: PredicateStatus.fail,
                message: message
            )
        }
    }
}

引数として期待結果expectedValueを受け取るようにしているという違いはありますが、基本的には先程と同じようなコードになっています。

いつMatcherを作成すべきか?

これについて明確な答えはありません、おそらくプロジェクトによってマチマチかと思います。

独自Matcherを作成したほうが読みやすくなるからと言って、片っ端から独自Matcherを作成していたのでは、そちらのコードの記述量が多くなってしまって逆にコストが掛かるでしょう。

それにテストが失敗した時に、独自Matcherの不具合ではないかと疑いをかけたくなるケースもあるでしょう。そういう意味では独自Matcherもきちんとテストされるべきかもしれません。

しかしアプリケーションの中心となるドメインモデルがあり、独自Matcherを作成することでテストコードの可読性があげられるのであれば、独自Matcherを作成することを検討する価値はあるでしょう。

テストコードを負債にしないために

最近わたし自身が感じているのは、テストコードが失敗したときの原因が「ほぼ一瞬」で分からない場合、「面倒だからあとで調べよう」となるという気持ちのコンテキストスイッチの切り替えが心の中で起きるということです。

それは「実際に調べてみたら大した事がなかった」としても、「対応までの時間と(心理的な面も含めて)コストが掛かる」ということです。テストコードが負債になっていくのは、失敗したテストコードに対するチームメンバーの心理的ストレスではないかと思うわけです。

そういう意味で独自Matcherの作成は、適材適所で利用すれば価値があるのでは、と思ったりします。

終わり

最初は独自Matcherの作成という技術的な記事にするつもりだったのですが、なんだか途中からポエミーな感じになってしまいました。まぁ、年の瀬ですし、たまにはこういうのも良いでしょうか。

個人的に次の課題として、NimbleのMatcher APIまわりの実装の仕組みをきちんと理解したいと思うので、今度コードリーディングしてみたいなと思ったりします。

そんなわけでモバイル 自動化 / 自動テスト Advent Calendar 2017の17日目の記事でした。

皆様良いお年を。(ちょっと早い?)