しおメモ

雑多な技術系ブログです。ニッチな内容が多いです。

XCTestを使ったflaky testの扱い方

Xcode13からXCTest周りの機能が強化され、特にflaky testに対応しやすくなったので、実際の対応方法をいくつか書きます。

Flaky testの再現確認

まず始めに、失敗する状況を手元で再現できれば原因を特定できる可能性があるので、Xcodeの機能を使って繰り返し実行してみることをおすすめします。

Xcode13から追加された機能で、Xcodeから操作する場合は、テストケースの左側にあるボタンから実行することができます。
本題から少し外れてしまうので、詳しいオプションはドキュメントを参照してください。

developer.apple.com

xcodebuildで行う場合は、-test-iterationsというオプションで実行可能です。

XCTSkipを利用してskipする

特定の環境のみで再現するケースでは、暫定対応としてまずはXCTSkipを使って該当する環境でskipすることをおすすめします。
単純にコメントアウトする場合と異なり、skipする条件を指定できるので、正常に動いている環境ではテストを実行したままにすることが出来ます。

func testSkip() throws {
    try XCTSkipIf(environment.isCI, "<https://hoge1234fuga.com/bugs/5678> hoge bug")
}

またXCTSkipを利用した場合、skipされたテストケースも出力には残るため、集計の点でもメリットがあります。

XCTExpectedFailureを利用して結果を成功扱いにする

Flaky testになっている原因が特定できない場合は、XCTExpectedFailureを使うことで、CIなどでテストケースを実行させながら、結果は成功扱いにすることが出来ます。
XCTSkipを使う場合よりも、テストケースの実行を維持できる点と、場合によっては失敗した原因も探ることができる点で、いくらかメリットがあります。

// メソッドの一部分を対象にする
func testExpectedFailure() throws {
    XCTExpectFailure("<https://hoge1234fuga.com/bugs/5678> hoge bug", strict: false) {
        // テストケースのコード
    }
}

// メソッド全体を対象にする
func testExpectedFailure2() throws {
    XCTExpectFailure("<https://hoge1234fuga.com/bugs/5678> hoge bug", strict: false)
    // テストケースのコード
}

strictオプションはデフォルトでtrueですが、この場合は逆にブロック内のテストが成功した場合に、テストケースが失敗扱いになってしまうため、flaky testの場合はfalseにする必要があります。

さらに細かく、テストケースの失敗の原因のうち一部のみを許容したい場合は、issueMatcherと組み合わせることが出来ます。
例としてHogeError.expectedだけ許容するケースを考えてみます。

// HogeError.expectedは許容するので成功扱い
func testMatcherExpectedFailure() throws {
    try XCTExpectFailure("<https://hoge1234fuga.com/bugs/5678> hoge bug", strict: false) {
        throw HogeError.expected
    } issueMatcher: { issue in
        issue.type == .thrownError && issue.associatedError as? HogeError == .expected
    }
}
// HogeError.unexpectedは許容しないので失敗扱い
func testMatcherExpectedFailure2() throws {
    try XCTExpectFailure("<https://hoge1234fuga.com/bugs/5678> hoge bug", strict: false) {
        throw HogeError.unexpected
    } issueMatcher: { issue in
        issue.type == .thrownError && issue.associatedError as? HogeError == .expected
    }
}
// ブロック内のテストが成功する場合はそのまま成功扱い
func testMatcherExpectedFailure3() {
    XCTExpectFailure("<https://hoge1234fuga.com/bugs/5678> hoge bug", strict: false) {
        print("hoge")
    } issueMatcher: { issue in
        issue.type == .thrownError && issue.associatedError as? HogeError == .expected
    }
}

失敗の原因のうちissueMatcherでマッチしなかったものは、XCTExpectFailureを書かない場合と同じ扱いで、失敗になります。

実際の出力は以下のようになります。

Test Case '-[__lldb_expr_31.HogeTests testMatcherExpectedFailure]' started.
XCTExpectFailure: matcher accepted Thrown Error: failed: caught error: "expected"
Test Case '-[__lldb_expr_31.HogeTests testMatcherExpectedFailure]' passed (0.034 seconds).

Test Case '-[__lldb_expr_31.HogeTests testMatcherExpectedFailure2]' started.
XCTExpectFailure: matcher rejected Thrown Error: failed: caught error: "unexpected""unexpected"
Test Case '-[__lldb_expr_31.HogeTests testMatcherExpectedFailure2]' failed (0.031 seconds).

Test Case '-[__lldb_expr_31.HogeTests testMatcherExpectedFailure3]' started.
hoge
Test Case '-[__lldb_expr_31.HogeTests testMatcherExpectedFailure3]' passed (0.002 seconds).

issueMatcherの引数のXCTIssueについては、typeassociatedErrorが情報として活用できますが、不足する場合はdetailedDescriptionなどの文字列を見るケースもあり得ます。

developer.apple.com

動画リンク

今回は結構ざっくりしていて、ドキュメントもわかりづらいので、実際に動かすのが一番良いかもです

developer.apple.com

developer.apple.com