はじめに
筆者について
- iOSアプリ開発の経験があります(Swift・SwiftUI・MVVM、Swift Testingでのテスト)
- サーバーサイドは初心者です。Goの
net/httpで、お気に入りを返す簡単なAPIサーバーを作り始めたところです - これまで、iOSの通信テストは
MockURLSessionで書いていました
この記事でやったこと
- GitHub APIの代わりになるスタブサーバー(決まったレスポンスを返すテスト用の偽のサーバー)をGoで作りました
- iOSのテストから本物の
URLSessionでつなぎ、検索・ユーザー詳細・リポジトリ一覧の取得と、ユーザー詳細での404・403・500のエラーを確かめました - スタブサーバーを止めてテストしたところ、アプリの接続エラーの処理が抜けていることに気づきました
モックのテストは残したまま、スタブサーバーを使うテストを追加しています。CIでスタブサーバーを動かすことはやっていません。
環境
| 項目 | バージョン |
|---|---|
| Go | 1.27.1 |
| Xcode | 26.1.1 |
| iOSシミュレーター | iPhone 17 / iOS 26.1 |
| macOS | 26.6.2 |
リポジトリ
- Go(APIサーバー・スタブサーバー):https://github.com/omomi-moti/github-user-search-api
- iOS:https://github.com/omomi-moti/github-user-search-ios
今までのテスト:MockURLSession
アプリの通信は、URLSession をプロトコルで包んで、テストのときに差し替えられるようにしていました。
protocol URLSessionProtocol {
func data(for request: URLRequest) async throws -> (Data, URLResponse)
}
extension URLSession: URLSessionProtocol {}
struct MockURLSession: URLSessionProtocol {
let statusCode: Int
let data: Data
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
let response = HTTPURLResponse(url: request.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)!
return (data, response)
}
}
この MockURLSession で、ステータスコードごとに正しい NetworkError(403 → rateLimited、404 → notFound など)になるかをテストしていました。準備なしでいろいろなケースを試せる一方、本物の URLSession を一度も通っていないことが気になっていました。そこで、サーバー側を偽物にして、本物の通信でもテストしてみることにしました。
Goでスタブサーバーを作る
構成
お気に入りのAPIサーバーとは別のプログラムとして、stub/ ディレクトリに作りました。go run ./stub で、8081番ポートで起動します。
stub/
├── main.go
├── stub.go
└── fixtures/
├── search_users.json
├── user_detail.json
└── repos.json
func main() {
log.Println("stub server listening on :8081")
log.Fatal(http.ListenAndServe(":8081", routes()))
}
返すJSONは fixtures/ に置き、中身はiOSのモックのテストと同じにしました。同じJSONにしておくと、モックとスタブサーバーで結果を比べやすいからです。JSONは Content-Type: application/json を付けて返しています。
エラーの返し方
404や500などのエラーをどう返すかで迷いました。候補は3つありました。
| 案 | 例 | iOS側の変更 |
|---|---|---|
| usernameで分ける(採用) |
/users/notfound → 404 |
不要 |
| クエリで指定する | /users/swift?status=404 |
URLを作る処理にクエリを足す必要がある |
| ヘッダーで指定する | X-Stub-Status: 404 |
通信処理にヘッダーを足す必要がある |
アプリのコードをそのまま使ってテストしたかったので、テストのためだけにアプリを変えなくて済む「usernameで分ける」方法にしました。usernameはそのままURLに入るので、iOS側は何も変えずに404などを試せます。
//go:embed fixtures/user_detail.json
var userDetailJSON []byte
// ...(search_users.json・repos.json も同じ)
func routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /search/users", handleSearchUsers)
mux.HandleFunc("GET /users/{username}", handleUserDetail)
mux.HandleFunc("GET /users/{username}/repos", handleRepos)
return mux
}
func handleUserDetail(w http.ResponseWriter, r *http.Request) {
if status, ok := errorStatusFor(r.PathValue("username")); ok {
http.Error(w, http.StatusText(status), status)
return
}
writeJSON(w, userDetailJSON)
}
// errorStatusFor は、エラーにする名前ならステータスコードと true を返す
func errorStatusFor(name string) (int, bool) {
switch name {
case "notfound":
return http.StatusNotFound, true
case "ratelimited":
return http.StatusForbidden, true
case "servererror":
return http.StatusInternalServerError, true
}
return 0, false
}
//go:embed を使うと、JSONファイルの中身をビルド時にプログラムに埋め込めます。
iOSからスタブサーバーにつなぐ
接続先を差し替えられるようにする
接続先(scheme・host・port)を APIServer という型にまとめて、外から渡せるようにしました。
struct APIServer {
let scheme: String
let host: String
let port: Int?
static let github = APIServer(scheme: "https", host: "api.github.com", port: nil)
}
init(client: APIClient = APIClient(), server: APIServer = .github) {
self.client = client
self.server = server
}
デフォルトが .github なので、アプリの画面は今までどおりGitHub APIにつながり、テストのときだけ接続先を変えられます。
なお、スタブサーバーは http ですが、Info.plist のATS設定を変えずに localhost へ接続できました。
テストを書く
スタブサーバーの接続先はテストでしか使わないので、テストのファイルに置きました。また、スタブサーバーが起動していないとテストが失敗するので、環境変数 STUB_SERVER があるときだけ実行するようにしています。
extension APIServer {
static let stub = APIServer(scheme: "http", host: "localhost", port: 8081)
}
@Suite(.enabled(if: ProcessInfo.processInfo.environment["STUB_SERVER"] != nil, "環境変数STUB_SERVERがあるときだけ実行する"))
struct StubServerIntegrationTests {
@Test("スタブサーバーからユーザー詳細を取得できる")
func fetchUserDetail() async throws {
let repository = GitHubAPIRepository(client: APIClient(session: URLSession.shared), server: .stub)
let detail = try await repository.fetchUserDetail(username: "swiftlang")
#expect(detail.login == "swiftlang")
}
// ...(検索・リポジトリ一覧も同じ形)
@Test(
"特定のusernameのとき、対応するNetworkErrorになる",
arguments: [
("notfound", NetworkError.notFound),
("ratelimited", NetworkError.rateLimited),
("servererror", NetworkError.serverError(statusCode: 500))
]
)
func fetchUserError(username: String, expectedError: NetworkError) async throws {
let repository = GitHubAPIRepository(client: APIClient(session: URLSession.shared), server: .stub)
await #expect(throws: expectedError) {
_ = try await repository.fetchUserDetail(username: username)
}
}
}
期待する結果は、モックのテストと同じにしています。
実行する
ターミナルを2つ使います。1つ目でスタブサーバーを起動します。
cd github-user-search-api
go run ./stub
2つ目で、環境変数を付けてテストを実行します。
cd github-user-search-ios
TEST_RUNNER_STUB_SERVER=1 xcodebuild test \
-project github-user-search-ios.xcodeproj \
-scheme github-user-search-ios \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-only-testing:github-user-search-iosTests/StubServerIntegrationTests
コードでは STUB_SERVER を見ているのに、コマンドでは TEST_RUNNER_STUB_SERVER を指定しています。これは誤記ではありません。xcodebuild test では、TEST_RUNNER_ で始まる環境変数が、プレフィックスを外した名前でテストの実行環境に渡されます。
実行結果
テストを1件ずつ増やしながら確かめて、最終的に6ケース(成功3つ、エラー3つ)すべて通りました。
本当にスタブサーバーと通信しているのかを確かめるため、スタブサーバーを止めて同じテストを実行すると、次のエラーで失敗しました。
Caught error: Error Domain=NSURLErrorDomain Code=-1004 "Could not connect to the server."
NSErrorFailingURLKey=http://localhost:8081/users/swiftlang
サーバーがないと失敗するので、成功していたときは本当にスタブサーバーと通信していたことがわかりました。
実行時間は、モックのテストもスタブサーバーのテストもコマンド全体で40秒前後でしたが、ほとんどはシミュレーターの起動時間で、今回の規模では体感できる差はありませんでした。
やってみて気づいたこと
接続できないときのエラー処理が抜けていた
上のエラーは、アプリの NetworkError に変換されず、URLSession のエラーのまま返ってきていました。
アプリでは、サーバーから返ってきたステータスコードは NetworkError に変換していましたが、そもそもサーバーに接続できなかったときの変換は用意していませんでした。そのため、画面では「検索に失敗しました」のような汎用のメッセージになり、接続の問題だとユーザーに伝わりません。
この場面は、モックでも次のように再現できます。
struct FailingURLSession: URLSessionProtocol {
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
throw URLError(.cannotConnectToHost)
}
}
問題は、自分の MockURLSession が「必ずレスポンスを返す」形で、「サーバーに届かない」場面をそもそも想定していなかったことでした。モックは、自分が想定した場面しか再現しません。本物の通信で試したことで、テスト設計の抜けに初めて気づけました。この対応は、別のIssueとして進める予定です。
サーバーを起動してからテストする手順に慣れが必要だった
- スタブサーバーを起動し忘れて、すべてのテストが接続エラーで失敗しました
- テストのコマンドを、スタブサーバーを起動したターミナル(別のリポジトリ)で実行してしまい、次のエラーになりました
xcodebuild: error: 'github-user-search-ios.xcodeproj' does not exist.
同じことを繰り返さないように、READMEに実行手順とこのエラーのことを書いておきました。
学んだこと
- Goで、決まったJSONを返す簡単なスタブサーバーを作れるようになった
- モックは手軽だが、自分が想定した場面しか試せない。本物の通信で試すと、その想定の抜けに気づける
-
xcodebuild testでは、TEST_RUNNER_を付けた環境変数でテストに値を渡せる
今後やりたいこと
- 接続できないときのエラーを、アプリで正しく扱えるようにする(モックでもこの場面をテストする)
- スタブサーバーでも、400・422や壊れたJSONを返せるようにする
- 検索・リポジトリ一覧でも、エラーの場面をスタブサーバーで確かめる
- お気に入りのAPIサーバーのデータをDBで保存できるようにする(今はメモリに持っているだけで、再起動すると消える)
- 接続エラーをユーザーにどう伝えるかを考える中で、エラーの意味づけをiOSとサーバーのどちらが持つべきかが気になった。iOSとサーバーの役割分担を、これから探っていきたい
おわりに
初めてGoでサーバーを立てて、iOSのテストからつないでみました。同じようにモックだけで通信テストを書いている方の参考になればうれしいです。
なお、この記事のあと、お気に入りのAPIサーバーに登録(POST)と削除(DELETE)を追加し、iOSからも使えるようにしました。その内容は別の記事で書く予定です。