🧪

URLProtocol を使って URLSession の stub を作る

2022/01/31に公開

API サーバのクライアントを書いているとき、以下のように URLSession を使用してクライアントコードを作ることがあるかと思います。(説明のためにだいぶ簡略化してますが。)

public struct FooAPIClient {
    let baseURL: String
    let session: URLSession

    public init(
        baseURL: String,
        session: URLSession = URLSession.shared
    ) {
        self.baseURL = baseURL
        self.session = session
    }

    public func execute(with request: URLRequest) async throws -> T {
        return try await withCheckedThrowingContinuation { continuation in
            let task = session.dataTask(with: request) { data, response, error in
		let result = .... // (省略) JSON 受け取って struct にしたり。

	        continuation.resume(returning: result)
            }
            task.resume()
        }
    }
}

この API クライアントのテストを書きたい場合、session 引数として与えられる URLSession を抽象化した Protocol を作る方法でも可能ではありますが、 URLProtocol という仕組みを使うことで実装コードには変更を加えずに HTTP 通信をスタブすることが可能です。

作り方は簡単で、 URLProtocol を継承しいくつかのメソッドを override するだけです。

typealias RequestHandler = ((URLRequest) throws -> (HTTPURLResponse, Data?))

class StubURLProtocol: URLProtocol {
    static var requestHandler: RequestHandler?

    override class func canInit(with request: URLRequest) -> Bool {
        return true
    }
    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }
    override func startLoading() {
        guard let handler = Self.requestHandler else {
            fatalError("Handler is unavailable")
        }
        do {
            let (response, data) = try handler(request)
            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
            if let data = data {
                client?.urlProtocol(self, didLoad: data)
            }
            client?.urlProtocolDidFinishLoading(self)
        } catch let e {
            client?.urlProtocol(self, didFailWithError: e)
        }
    }
    override func stopLoading() {}
}

テスト中での使い方。

func testURLSessionStub() async throws {
    // sample.json の中身をレスポンスとして返すようにしている
    StubURLProtocol.requestHandler = { request in
        let response = HTTPURLResponse(
            url: request.url!, statusCode: 200, httpVersion: "HTTP/2", headerFields: [:])!
        guard
            let jsonDataURL = Bundle.module.url(forResource: "sample", withExtension: "json"),
            let jsonData = try? String(contentsOf: jsonDataURL)
        else {
            fatalError("sample.json not found")
        }
        let data = jsonData.data(using: .utf8)
        return (response, data)
    }

    // URLSession を作る
    let config = URLSessionConfiguration.default
    config.protocolClasses = [StubURLProtocol.self]
    let session = URLSession(configuration: config)
    
    // APIClient を作る
    let client = FooAPIClient(baseURL: "http://localhost:3000/", session: session)
    
    // request 投げる
    let request = URLRequest(url: URL(string: "https://example.com/")!)
    let response = try await client.execute(with: request)
    
    // response に対して何かテスト
    dump(response)
}

参考

Discussion