しおメモ

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

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

ARCによるオブジェクトの破棄タイミングをずらす

Swiftの場合、特に意識せずとも各オブジェクトに対してARCによるメモリ管理が行われ、コンパイル時にreference countのincrement/decrementの処理(retain/release)が挿入されます。

ほとんどの場合、retain cycleを避けることなどのわずかなことを意識しておけば、メモリ管理はARCに任せておいて問題ないはずです。
逆に、後述のUnmanaged等を利用した、ARCに任せないメモリ管理を行うと、メモリ安全性を損ねることになります。

ただし、ごくまれに、意識外でのオブジェクト解放と実行スレッドの兼ね合いでクラッシュするケースがあり、何度か応急処置した記憶があるので、読み物として書いておきます。

releaseのタイミングと動作スレッドに関連するバグの例

Hogeというクラスがあって、サブスレッドからの操作でreference countが0になるケースを考えます。

final class Hoge {
    // ...

    deinit {
        print("deinit (\(Thread.isMainThread))")
    }
}

var dict: [Int: Hoge] = [1: .init()]
let queue = DispatchQueue(label: "hoge", qos: .background)
let queue2 = DispatchQueue(label: "hoge", qos: .background)
let hoge = Hoge()

queue.async {
    print("Async Start")
    queue2.sync {
        print("Sync Start")
        dict[1] = hoge
        print("Sync End")
    }
    print("Async end")
}

dict[1] = hogeで上書きしたタイミングでreference countが0になるので、Hogedeinitが呼ばれます。

コンパイラによりreleaseが呼ばれるタイミングが決められるのですが、この場合dict[1] = hogeの直後か、ブロックを抜ける直前になります。
したがって、releaseの処理はqueue2で処理されることになります。

Async Start
Sync Start
deinit (false)
Sync End
Async end

一例ですが、HogeUIViewなどだったり、あるいはその中で強参照を持っていると、 UIViewreleaseメインスレッドで動かないことになり、このコードはクラッシュを引き起こします。

このようなケースの実践的な解決策としては、弱参照を活用したり、UIKit関連のクラスは状態管理する部分から分けるなど、分かりづらいタイミングで暗黙的にUIViewを解放するコードを書くのを避けるのが良いです。

ただ、実際にこのようなバグに遭遇したケースもそうなのですが、SDK経由のオブジェクトでそこまで修正が届かないという場合もあるので、他の解決策も考えてみます。

変数に代入してreference countを増やす解決策

syncの意図を維持するため、dictへの操作はatomicのままにしつつ、releaseの処理だけ後回しすることを考えます。
無難かつ愚直に実装するなら、別の変数でreferenceを持ってあげて、main threadで解放してあげるなどでしょうか。

queue.async {
    print("Async Start")
    var tmp: Hoge? = dict[1]
    queue2.sync {
        print("Sync Start")
        dict[1] = hoge
        print("Sync End")
    }
    DispatchQueue.main.async {
        _ = tmp // warning抑制
        tmp = nil
    }
    print("Async end")
}

例えばこのように別の参照を作ってあげれば、reference countが2になるので、dict[1] = hogeでcountが1つ減っても、オブジェクトは破棄されなくなります。

Async Start
Sync Start
Sync End
Async end
deinit (true)

わざわざ参照カウントを増やすためだけに変数宣言と代入をしているため、読みづらさはあるものの、全体としてはARCの管理から外れていないので、比較的安全と言えます。

Unmanagedを使う解決策(非推奨)

ARCの管理に頼らず、手動でreference countを操作するためにUnmanagedというクラスも用意されています。Objective-Cのmanual reference countingとほぼ同じです。

Reference countを増やしておく考え方は上のやり方と同じですが、こちらはARCのようなreference countの整合性は保証されないので、retainreleaseを呼び間違えると別のバグの原因にもなります。

https://developer.apple.com/documentation/swift/unmanaged

わざわざメモリ安全性を壊すようなコードを書く必要もなく、プロダクトでは非推奨ではあるものの、せっかくクラスやドキュメントがあるので、勉強のためにも実装してみます。

var dictUnmanaged: [Int: Unmanaged<Hoge>] = [1: .passRetained(.init())]

queue.async {
    print("Async Start")
    queue2.sync {
        print("Sync Start")
        DispatchQueue.main.async {
            dictUnmanaged[1]?.release()
        }
        dictUnmanaged[1] = .passRetained(hoge)
        print("Sync End")
    }
    print("Async end")
}
Async Start
Sync Start
Sync End
Async end
deinit (true)

Unmanagedでwrapした場合、dictUnmanaged[1] = .passRetained(hoge)のように上書きをしても中のインスタンスのreference countは減りません。

このため、リークや二重解放を引き起こす危険性と引き換えではあるものの、任意のタイミングでインスタンスを解放することができます。

関連

WWDC21でARCについてのセッションがありました。こちらも参考にしてみてください。

https://developer.apple.com/videos/play/wwdc2021/10216/

CADisplayLinkを用いたアニメーション実装

複雑なアニメーションを実装する場合に、画面の更新(垂直同期)に合わせて、アニメーションの更新処理をしたいケースがあります。
単純にTimerなどで実装した場合、アニメーションの処理が呼ばれるタイミングが画面の更新と合わないため、次の垂直同期までに間に合わない可能性があり、 結果としてanimation hitchを生じさせる原因となります。

そのような問題を回避するために、垂直同期の直後のタイミングでの呼び出しが行われるCADisplayLinkを利用することができます。

Timerを利用した場合に困るケース

iOSに限らずですが、大体のモバイルアプリやゲームでは、ダブルバッファリングや、トリプルバッファリングを用いて描画をしています。
ダブルバッファリングでは、表裏の2枚のスクリーンがあり、今見えている画面の裏で次のフレームの準備をしつつ、垂直同期のタイミングに合わせて表裏を入れ替えています。

例えば、60Hzのリフレッシュレートのディスプレイで、最大のパフォーマンスを出す場合、 1フレームあたりおよそ16ms以内に次のフレームの描画の準備を終える必要があります。
仮に処理が重すぎて、次の垂直同期までに間に合わない場合は、以前のフレームがそのまま描画される状態なります。
(iOSの文脈では、animation hitchと呼ばれています)

developer.apple.com

Timerの場合は特に垂直同期のタイミングとは関係なく、startしたタイミングから一定の間隔でcallbackが呼ばれます。 アニメーションの処理の開始が垂直同期のタイミングとずれることで、次の垂直同期までに動ける時間が短くなるため、後述のCADisplayLinkよりも不利になります。
(これについては、以下のWWDCの発表がわかりやすいです)

developer.apple.com

1秒に1回くらいの更新ではTimerでもほとんどのケースで困らないのですが、 高頻度になるほど準備の時間がより短くなるので、60Hz、120Hzのリフレッシュレートに合わせようとした場合は不適切と言えます。

CADisplayLinkの使い方

リフレッシュレートと同調させるような、高頻度なアニメーションの更新をしたい場合には、TimerよりもCADisplayLinkが適しています。 CADisplayLinkRunLoopに紐付けて使用します。

let displayLink = CADisplayLink(target: self, selector: #selector(update(_:)))
displayLink.add(to: .main, forMode: .default)

@objc func update(_ displayLink: CADisplayLink) { ... }

上記の場合は、垂直同期のすぐ後にupdateが呼ばれ、効率よく次のフレームの準備を進めることができます。

現行のiPhoneやiPadのリフレッシュレートは60Hzや120Hzですが、 アプリの処理の重さや端末の状態、設定によって、実際のフレームレートは30fpsであったり、60fpsであったりと異なります。

そのため、Timerで一定間隔に固定にしても、期待通りのフレームレートで動くとは限らず、過剰に呼び出されることもあり、効率の面でもあまり良い書き方とは言えません。
(特にiPhone 13 Proなどの端末では、最大120Hzの可変リフレッシュレートとなっているため、さらに配慮が必要です。)

また、CADisplayLinkの場合はpreferredFramesPerSecondというプロパティで、callbackが呼ばれる頻度を大まかに設定することができます。

displayLink.preferredFramesPerSecond = 60

ただし、iOS15でdeprecatedになっていて、代わりにiOS15+では拡張されたpreferredFrameRateRangeが使えます。

displayLink.preferredFrameRateRange = .init(minimum: 15, maximum: 60, preferred: 60)

例えば、60fpsに設定しておくと、リフレッシュレートが120Hzの端末でも毎秒60回の更新に抑えられますが、 あくまでpreferredなので、元々30fpsしか出ていないようなケースでは、callbackが呼ばれる回数はおおよそ毎秒30回となります。

何も設定しなければ、自動で最大のfpsを取るようになるので、更新の頻度を抑える必要がない場合は設定しないでもOKです。

フレーム毎の時間の計算

先ほども書いたように、端末の種類や、設定、アプリによって実際のフレームレートは異なるため、 アニメーションがどの環境でも同じように見えるようにするためには、フレーム毎の時間を計測するのが適切です。

例えば、1回のcallbackで6度回転するようなアニメーションを実装した場合、 60fpsでは1秒に360度回転しますが、30fpsでは1秒に180度しか回転しません。
このように環境によってアニメーションの速度が変わってしまうので、意図した速さでアニメーションを動かすには、 実際に1フレームあたりどれくらいの時間がかかっているかを知ることが必要になります。

CADisplayLinkにはtimestamptargetTimestampの2種類のプロパティがありますが、timestampは今準備しているフレームの開始時刻で、targetTimestampは今準備しているフレームが表示される予定時刻なので、アニメーションに時刻を利用する場合はtargetTimestampが適切になります。

Always use targetTimestamp to drive any animation, physics, or other time-related content provided in your CADisplayLink callback.

実際には、高負荷などで処理が間に合わない際に、callbackがスキップされるケースがあるので、 今のフレームのtargetTimestampが次のフレームのtimestampに一致するとは限りません。
したがって、厳密にアニメーションの計算のために時間の差分を取るには、targetTimestamp - timestampよりも、前回のtargetTimestampと比較をするのがより正確な手段になります。

var previousTimeStamp: CFTimeInterval = .zero

func start() {
    let displayLink = CADisplayLink(target: self, selector: #selector(update(_:)))
    displayLink.add(to: .main, forMode: .default)
    previousTimeStamp = CACurrentMediaTime()
}

@objc func update(_ displayLink: CADisplayLink) {
    let delta = displayLink.targetTimestamp - previousTimeStamp
    previousTimeStamp = displayLink.targetTimestamp

    // ...
}

先ほどの例のように、1秒に360度回転させるようなアニメーションをしたい場合には、以下のようになります。

@objc func update(_ displayLink: CADisplayLink) {
    let delta = displayLink.targetTimestamp - previousTimeStamp
    previousTimeStamp = displayLink.targetTimestamp

    angle += CGFloat.pi * 2 * delta
}

CADisplayLinkTimerとは異なり、呼び出しの間隔が一定でない場合がありますが、 経過時間を考慮することで、環境によらず意図したスピードでアニメーションを動かすことができます。

リンク

書いている途中で試聴したのですが、以下のWWDC21の発表の後半でわかりやすくまとまっているので、そちらが参考になります。
iPhone13 Proなどの可変リフレッシュレートのディスプレイ対応についてもAppleのドキュメントがあります。