[Quick/Nimble] M1 MacでfatalErrorのテストを(しなくていいように)するには [Swift]


TL;DR

M1 MacではNimbleのthrowAssertion()マッチャーは使えない。
そもそもアサーションはテストしづらいので、なるべくアサーションが発生しないようなロジックに修正したり、アサーションの代わりにErrorで代用するのが良いと思う。

本文

こんにちは。 @zrn-ns です。

最近暖かくなってきましたね。
とても過ごしやすいなあと感じるとともに、アレルギーで鼻と喉が大変なことになってます。
花粉症の特効薬はまだですか。

Quick/NimbleのthrowAssertion()マッチャー、便利ですよね

iOS向けのテストフレームワークQuickのサブプロジェクトとして公開されているNimbleには、fatalErrorやpreconditionFailureなどのアサーションを検知する事ができる throwAssertion()マッチャーが用意されています。

通常、アサーションに失敗するとアプリがクラッシュしてしまうので、アサーションのテストはできないのですが、この throwAssertion()マッチャーを使うことで、エラーハンドリングと同じ感覚でアサーションの失敗をハンドリングし、テストを行うことができます。

throwAssertion()マッチャーの使いかた

例を挙げます。繰り返しの文字列を返してくれるような拡張メソッドをString型に用意します。
マイナス回繰り返すことはできないので、timesが0以上であることをpreconditionでチェックしています。

extension String {
    func repeated(times: Int) -> String {
        precondition(times >= 0, "繰り返し回数に負の数を指定することはできません!")

        var text = ""
        (0 ..< times).forEach { _ in text += self }
        return text
    }
}

precondition部分のテストをQuickを使って書くと、下のようになります。

it("繰り返し回数に負の数が与えられた場合、クラッシュすること") {
    expect { "abc".repeated(times: -1) }.to(throwAssertion())
}

通常の値のテストと同じように、throwAssertion()マッチャーを使うことで、アサーションエラーの発生を検知する事ができます。

M1 MacではthrowAssertion()マッチャーが使えない

ここから本題です。

便利なthrowAssertion()マッチャーですが、実は現状、M1チップ搭載のMacでは利用する事ができません。
READMEには、下記のように記載されています。

It is only supported for x86_64 binaries, meaning you cannot run this matcher on iOS devices, only simulators.

x86_64アーキテクチャのバイナリでは、throwAssertion()マッチャーはサポートされていないという記述がされています。
M1チップではx86_64ではなくarm64というアーキテクチャが採用されているため、動作しないわけですね。

※ちなみに、実際にM1 MacでthrowAssertion()を使うと、テスト実行時に下記のようなエラーが表示されます。

M1 Macでアサーションのテストをどうするか

今後Apple Siliconが主流になっていく中で、どうやってこの問題と向き合っていくのか。
幾つか方針を考えてみました。

今回のサンプルコードはこちらに上げてあります。
https://github.com/zrn-ns/AvoidUsingFatalError

Plan.A: throwAssertion()マッチャーががarm64アーキテクチャに対応してくれるのを待つ

いつか対応してくれるだろうと願って待ちます。
実際いつ対応されるのか、そもそも対応可能な問題なのかはよくわかりません。

一応去年末にIssueが立てられています。
アーキテクチャに関する分岐を除去すればちゃんと動くというようなことが書かれていますが、試していないのでわかりません。

Plan.B: arm64アーキテクチャではアサーションのテストを行わないようにする

一番最初に思いついたのはこれでした。超消極的な対応ですが、一応これでテストがクラッシュすることは防げます。

describe("異常パターン") {
    #if arch(x86_64)
    it("繰り返し回数に負の数が与えられた場合、クラッシュすること") {
        expect { "abc".repeated(times: -1) }.to(throwAssertion())
    }
    #endif
}

当たり前ですがM1チップではテストが実行されなくなるので、M1 Macを持っている人はメンテナンスが難しくなります。
さらに実行されない処理が発生するため、カバレッジも低下します。
応急処置としてはいいかもしれませんが、長い期間この状態で運用するのは避けたいです。

Plan.C: 異常パターンをErrorとして表現してみる

そもそも、アサーションではなく、Errorとして表現してみるという案です。
Errorなら、throwAssertion()マッチャーなしでもテストができます。

extension String {
    enum Errors: Error {
        case indexOutOfRange
    }

    func repeated(times: Int) throws -> String {
        guard times >= 0 else { throw Errors.indexOutOfRange }

        var text = ""
        (0 ..< times).forEach { _ in text += self }
        return text
    }
}
it("繰り返し回数に負の数が与えられた場合、例外が発生すること") {
    expect {
        try "abc".repeated(times: -1)
    }.to(throwError(String.Errors.indexOutOfRange))
}

Plan.D: そもそも異常パターンが発生し得ないロジックに変更する

引数に渡せるパラメータの縛りを強くすることで、アサーションを書く必要がないロジックに変更するという案です。
UIntは負の数が渡せないので安心ですね。

func repeated(times: UInt) throws -> String {
    var text = ""
    (0 ..< times).forEach { _ in text += self }
    return text
}

この方針はあらゆる場面に適用できるわけではありませんが、なるべく想定外の状態が発生しないようにするというのは、重要な観点だと考えています。
timesをUIntではなく、完全に独自の型として定義してしまうというのもありかもしれません。

extension String {
    func repeated(times: RepeatTimes) -> String {
        var text = ""
        times.range.forEach { _ in text += self }
        return text
    }
}

struct RepeatTimes {
    enum Errors: Error {
        case indexOutOfBounds
    }

    init(_ intValue: Int) throws {
        guard intValue >= 0 else { throw Errors.indexOutOfBounds }

        raw = intValue
    }

    static let zero = try! Self.init(0)

    let raw: Int
    var range: Range<Int> { 0 ..< raw }
}

このロジックであれば、RepeatTimes型の中に、指定できる値の範囲に関する処理を押し込む事ができます。
(この例では少し大袈裟に見えますが、RepeatTimesもっと複雑な前提条件を持つ場合、このほうがテストしやすいかと思います)

Plan.E: アサーションを発生させる代わりにnilを返す

なんだかんだよく見るやつです。

extension String {

    func repeated(times: Int) -> String? {
        guard times >= 0 else { return nil }

        var text = ""
        (0 ..< times).forEach { _ in text += self }
        return text
    }
}

テストはしやすいものの、なぜnilが返ってきたのかわかりませんし、その辺はドキュメントで補う必要が出てきます。
あまり好きな書き方ではありません。

結論

書かれているロジックやその時の状況によって、案A~Eを使い分けていく必要がありそうです。

個人的には、なるべく引数の型を絞り(Plan.D)、必要に応じてErrorをthrowする(Plan.C)ような作りにすることで、テストを書く必要がある場所ではアサーションを書かなくていいようにしていくのが良さそうだなと感じました。


以上です。
少しでも皆様のご参考になれば幸いです。