Replay で Swift Testing の API スタブをスマートに書く

はじめに

NSHipster の記事Replay というライブラリを見かけて、Swift Testing で API のスタブに使えそうだなと気になったので試してみました。

Replay の特徴

Replay は URLProtocol ベースで HTTP リクエストをインターセプトする仕組みです。つまり、既存の URLSession を使ったコードを一切変更せずにテストできます。プロダクションコードにテスト用の DI を仕込む必要がありません。

Swift Testing の Trait として .replay() を提供しているのがポイントで、@Test アノテーションにスタブの定義を直接書けます。テストコードの見通しがよくなりますし、どのテストがどのスタブを使っているのか一目瞭然です。

セットアップ

SPM で Replay を追加します。テストターゲットの依存に Replay を追加するだけです。

// swift-tools-version: 6.1
import PackageDescription

let package = Package(
    name: "ReplaySample",
    platforms: [.iOS(.v17), .macOS(.v14)],
    dependencies: [
        .package(url: "https://github.com/mattt/Replay.git", from: "0.4.0"),
    ],
    targets: [
        .target(
            name: "App",
            path: "Sources/App"
        ),
        .testTarget(
            name: "ReplaySampleTests",
            dependencies: [
                "App",
                .product(name: "Replay", package: "Replay"),
            ],
            path: "Tests/ReplaySampleTests",
            resources: [.process("Resources")]
        ),
    ]
)

Xcode プロジェクトの場合は File > Add Packages から https://github.com/mattt/Replay.git を追加し、テストターゲットに Replay を紐付けてください。

テスト対象のコード

今回テストする APIClient はシンプルな構成です。URLSession.shared で JSONPlaceholder API を呼んでいるだけなので、特にテスト用の工夫はしていません。

import Foundation

public struct User: Codable, Sendable {
    public let id: Int
    public let name: String
    public let email: String
}

public struct APIClient: Sendable {
    private let baseURL: URL

    public init(baseURL: URL = URL(string: "https://jsonplaceholder.typicode.com")!) {
        self.baseURL = baseURL
    }

    public func fetchUser(id: Int) async throws -> User {
        let url = baseURL.appendingPathComponent("users/\(id)")
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(User.self, from: data)
    }

    public func fetchUsers() async throws -> [User] {
        let url = baseURL.appendingPathComponent("users")
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode([User].self, from: data)
    }
}

この APIClientURLSession.shared をそのまま使っていますが、Replay は URLProtocol レベルでインターセプトするので問題なくスタブできます。

インラインスタブでテストを書く

まずは一番シンプルな方法から。レスポンスの JSON をテストコード内に直接書くパターンです。

import Testing
import Foundation
import Replay
@testable import App

@Test(
    .replay(
        stubs: [
            .get(
                "https://jsonplaceholder.typicode.com/users/1",
                200,
                ["Content-Type": "application/json"],
                {
                    """
                    {"id": 1, "name": "Stub User", "email": "stub@example.com"}
                    """
                }
            )
        ]
    )
)
func inlineStubTest() async throws {
    let client = APIClient()
    let user = try await client.fetchUser(id: 1)

    #expect(user.id == 1)
    #expect(user.name == "Stub User")
    #expect(user.email == "stub@example.com")
}

.replay(stubs:).get() でスタブを定義しています。URL、ステータスコード、レスポンスヘッダー、レスポンスボディの順に指定します。ボディはクロージャで返すので、動的に生成することもできます。

テスト関数自体は普通に APIClient を使っているだけです。Replay が裏で URLProtocol を差し替えてくれるので、実際の HTTP リクエストは発生しません。

JSON ファイルからスタブを読み込む

レスポンスが大きくなると、インラインで書くのは辛くなってきます。そんなときは JSON ファイルに切り出しましょう。

まず Tests/ReplaySampleTests/Resources/user.json を配置します。

{
    "id": 1,
    "name": "楠本真大",
    "email": "masahiro@kusumoto.app"
}

テストコードではこのファイルを読み込んでスタブのボディとして渡します。

#if !SWIFT_PACKAGE
private final class BundleToken {}
#endif

@Test(
    .replay(
        stubs: [
            .get(
                "https://jsonplaceholder.typicode.com/users/1",
                200,
                ["Content-Type": "application/json"],
                {
                    #if SWIFT_PACKAGE
                    let url = Bundle.module.url(forResource: "user", withExtension: "json")!
                    #else
                    let url = Bundle(for: BundleToken.self).url(forResource: "user", withExtension: "json")!
                    #endif
                    return try! String(contentsOf: url, encoding: .utf8)
                }
            )
        ]
    )
)
func jsonFileStubTest() async throws {
    let client = APIClient()
    let user = try await client.fetchUser(id: 1)

    #expect(user.id == 1)
    #expect(user.name == "楠本真大")
    #expect(user.email == "masahiro@kusumoto.app")
}

SPM では Bundle.module でリソースにアクセスできますが、Xcode プロジェクトでは使えません。#if SWIFT_PACKAGE で分岐することで両方に対応しています。BundleToken はテストバンドルを特定するためのダミークラスです。

JSON ファイルに切り出しておくと、レスポンスの内容を変更したいときにテストコードを触らずに済むので楽です。

Replay を使ってみた感想

実際に使ってみて感じたのは、セットアップの手軽さです。.replay()@Test に付けるだけでスタブが有効になるので、テストごとにスタブの設定をセットアップ・ティアダウンする手間がありません。

XCTest 時代にも DVR のようなスタブライブラリはありましたが、Swift Testing の Trait として実装されている Replay では、@Test.replay() を付けるだけでスタブが有効になります。

また、URLProtocol ベースなので URLSession のカスタムインスタンスだけでなく URLSession.shared に対しても動作します。既存のコードにテスト用の注入口を用意していなくても、そのままテストを書き始められるのは助かります。

まとめ

今回はインラインスタブと JSON ファイルスタブの 2 パターンを紹介しましたが、Replay の基本形は record & replay パターンです。一度本番の API にリクエストを実行し、そのレスポンスを HAR(HTTP Archive)ファイルとしてキャッシュします。次回以降のテストではネットワークにアクセスせず、保存した HAR ファイルからレスポンスを返す仕組みです。手作りのモックを管理する手間が減り、実際の API レスポンスに基づいたテストが書けるのは心強いです。

Swift Testing で API 通信のテストを書きたい方はぜひ試してみてください。

参考リンク