テストとコード品質

Swiftのasync/await非同期コードをテストする方法

Swift 5.5以降では、テスト関数をasyncとして宣言し、awaitで結果を待ってから検証します。エラー検証、古いコールバックAPIのテスト、expectationがまだ必要なケースまでまとめました。

読了 4 分
Swiftのasync/await非同期コードをテストする方法のカバー画像

async/awaitでコードを書くのには慣れたのに、いざテストを書こうとすると途方に暮れたことはありませんか?

XCTestExpectationwait(for:)を付けながらコールバック地獄をテストしていた頃の習慣が残っていると、特にそう感じます。

結論から言います。Swift 5.5以降(Xcode 13+)なら、テスト関数そのものをasyncとして宣言し、awaitで結果を待ってからで検証すれば完了です。expectationとタイムアウトを使う従来の方法は、ほとんど必要なくなりました。

今日は、実務で整理したasync/awaitのテスト方法を最初から最後まで解説します。

まずは要点をまとめます

忙しい方のために、この記事のポイントだけ押さえます。

  1. テストメソッドにasync throwsを付け、内部でawaitを呼び出す
  2. エラー検証にはdo-catchまたはXCTAssertThrowsErrorのasync版パターンを使う
  3. タイムアウトが本当に必要な場合だけexpectationを併用する
  4. 古いコールバックAPIはwithCheckedThrowingContinuationでラップしてテストする

Swiftのasync/awaitテストはどう行う?

まずは基本形から見ていきましょう。

以前は非同期の結果を待つためにexpectationを作り、クロージャ内でfulfillを呼び出していました。コードが長く、読みにくかったものです。

今では、もっと簡潔に書けます。

// テスト関数に async throwsを付けるだけ
func test_ユーザーを_読み込む() async throws {
    let service = UserService()
    let user = try await service.fetchUser(id: 1)
    XCTAssertEqual(user.name, "イ・ソグ")
}

上のコードで注目する点は二つだけです。

関数宣言にasync throwsが付いていること、そしてtry awaitで結果を待ってから、通常どおりXCTAssertEqualで検証することです。

テスト関数自体をasyncにすると、非同期コードも同期コードのように上から下へ読めます。

この方法に変えてから、テストコードの行数が半分近くまで減りました。

awaitキーワードを強調したSwiftのasyncテスト関数と緑色のチェックマーク
async関数はテストもasyncにすれば、それで完了です

エラーが発生する場合はどう検証する?

非同期関数がエラーを投げるケースも、よくテストすることになります。

最も直感的な方法はdo-catchです。

func test_存在しない_ユーザー_エラー() async {
    let service = UserService()
    do {
        _ = try await service.fetchUser(id: -1)
        XCTFail("エラーが発生するのが正常です")
    } catch {
        XCTAssertTrue(error is UserError)
    }
}

成功ケースにXCTFailを入れておくのがポイントです。

エラーが発生せず、そのまま通過すると、テストが静かに成功したように見えてしまいます。これを防ぐ安全装置だと考えてください。

ちなみに、同期コードで使っていたXCTAssertThrowsErrorは、基本的にasync関数を直接受け取れません。そのため、上のようにdo-catchとして書く方法を私はよく使います。


古いコールバックAPIはどうテストする?

すべてのコードがasyncへ移行できていれば理想ですが、現実はそうではありません。

まだcompletionハンドラーで結果を返す古いAPIが、プロジェクトに残っているはずです。

この場合はwithCheckedThrowingContinuationでラップして、asyncの世界へ持っていきます。

func fetchLegacy() async throws -> Data {
    try await withCheckedThrowingContinuation { continuation in
        oldAPI { data, error in
            if let error { continuation.resume(throwing: error) }
            else { continuation.resume(returning: data!) }
        }
    }
}

一度ラップすれば、テスト側ではtry await fetchLegacy()で呼び出すだけなので、先ほどの方法と同じになります。

注意点が一つあります。continuationは必ず一度だけresumeしてください。二度呼ぶとクラッシュし、まったく呼ばないとテストが永遠に停止します。


expectation方式とasync方式、いつ何を使う?

二つを比較してまとめました。(Xcode 13以降、2026年時点)

項目 expectation方式 asyncテスト方式
コードの長さ 長い 短い
可読性 コールバックの入れ子 上から下への順次実行
タイムアウトの指定 簡単 別途処理が必要
推奨される場面 通知・タイマーを待つ場合 ほとんどの非同期処理

一般的なasync/await関数のテストなら、async方式のほうがはるかに便利です。

ただし、特定の時間内に通知が届くか、タイマーが時間どおりに動くかのように、明示的なタイムアウトが重要な場合は、expectationを併用する価値があります。

XcodeのテストナビゲーターにTest Passedの緑色のチェックマークが表示された画面
緑のランプが点灯する瞬間が、いちばんうれしいものです

まとめ

最初は慣れなくても、テスト関数にasync throwsを一度付けてみれば、なぜもっと早く変えなかったのかと思うはずです。

今日まとめた4つのパターンを身に付ければ、ほとんどの非同期テストを無理なくカバーできます。気軽に一つずつ試してみてください。応援しています!