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)
}
}
この APIClient は URLSession.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 通信のテストを書きたい方はぜひ試してみてください。