コネヒト開発者ブログ

コネヒト開発者ブログ

APIクライアントをAPIKit+RxSwiftからURLSession+Combineにしました(後編)

コネヒト株式会社でiOSエンジニアをやっています ohayoukenchan です。

APIクライアントをAPIKit+RxSwiftからURLSession+Combineにしたお話の後編にあたります。

前回までのお話はこちらをご参照ください。

今回は中編で定義したprotocol ApiServiceに準拠したテストを書いていきます。 前提としてNimble, Quickを使ってユニットテストを行っているのでNimble, Quickを使ったテストの書き方になっていますのでご了承ください。

おさらい

まずはApiServiceがどんなProtocolだったかおさらいしておきます。

protocol ApiService {
    func call<Request>(from request: Request) -> AnyPublisher<Request.Response, APIServiceError>
    where Request: APIRequestType
}

ApiServiceAPIRequestTypeに準拠した型を引数にもつcall(from Request)という関数が、返り値としてAnyPublisher<Request.Response, APIServiceError>を返すことを制約としていました。 APIRequestTypeについては 前編を参考にしてください。

APIと通信する際はエンドポイント毎にAPIRequestTypeに準拠した構造体を用意し、実装したApiServicecall(from:)にてURLRequestを作成してdataTaskPublisher(for: urlRequest)を叩くことでレスポンスを受け取っていました。テストを書く際もこのプロトコルに準拠させることで可読性の高いテストを書いていきたいと思います。

HTTPリクエストのテスト

まずHTTPリクエストのテストについて考えます。

リクエストの成功、失敗

HTTPリクエスト自体をテストしたいケースとしては以下のようなものが考えられます。

  • HTTPリクエストがなんらかの理由で失敗した場合、レスポンスとして返ってきた情報を正しく操作できているか確認したい。
  • HTTPリクエストが成功した場合、パース処理にdataが渡っていることを確認したい。

パース処理の確認

正常にレスポンスが渡ってきた場合でも、パースに失敗した場合クライアント側にデータを渡す訳にはいかないので、こちらも正しくエラーハンドリングできているか確認したいので、パース結果が適切かもテストしていきたいと思います。

どのようにテストを書きたいか

どんなテストが書きたいかを再確認したところで、どのように書きたいかを考えていきます。 当初自分の頭の中にあったイメージはこのようなものでした。

シナリオ

  1. レスポンス対象となるjson文字列を作成
  2. リクエストを作成
  3. 作成したデータをアダプターみたいなもので注入
  4. 注入したデータがパースされていてテストが通る

もう少し具体的にコードを交えて書くとこのような感じです。

describe("AppleAttach") {
    it("AppleID連携できる") {

        let data = try? JSONSerialization.data(
            withJSONObject: [
                "user_linked_accounts": [
                    "apple": true,
                ]
            ],
            options: []
        ) // 1. json文字列に変換

        let request = UserLinkedAccountsAttachRequest.AppleAttach(
            authorizationCode: "123"
        ) // 2. リクエスト作成

        3. 作成したデータをアダプターみたいなもので注入
        // apiService.adapter = dataみたいな感じ

        waitUntil(timeout: .milliseconds(100)) { done in
            apiService.call(from: request)
                .sink { completion in
                    switch completion {
                    case .finished:
                        break
                    case .failure(_):
                        fail()
                    }
                } receiveValue: { response in
                    expect(response.userLinkedAccounts.apple).to(beTrue())
                    done()
                }
                .store(in: &cancellables)
        } // 4. 注入したデータがパースされてuserLinkedAccountsになっていてテストが通る
    }
}

3作成したデータをアダプターみたいなもので注入をどのように実現するのかイマイチよく分かっていなかったのでどうしようかなと調べていたら、URLProtocolを使用して、サーバーの応答を直接モックすることができることが分かりURLProtocolにモックする方法で書くことにしました。

URLProtocolとは

Appleのリファレンスには

An abstract class that handles the loading of protocol-specific URL data.

プロトコル固有のURLデータのロードを処理する抽象クラスとあります。

abstract class? それならprotocolでいいのでは?と思いつつマニュアルどおりにmockURLProtocolを実装するとこのような感じになりました。詳しくは wwdc2018 | Testing Tips & Tricks をご確認ください。ほぼ同じ実装です。

class MockURLProtocol: URLProtocol {

    static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data?))?

    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 = MockURLProtocol.requestHandler else {
            assertionFailure("Received unexpected request with no handler set")
            return
        }

        do {
            let (response, data) = try handler(request)
            guard let data = data else {
                assertionFailure("Unexpected Null value given")
                return
            }
            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
            client?.urlProtocol(self, didLoad: data)
            client?.urlProtocolDidFinishLoading(self)
        } catch {
            client?.urlProtocol(self, didFailWithError: error)
        }
    }

    override func stopLoading() {
        // 何もしないが上書きする必要がある
    }

}

requestHandler は、後でカスタムサーバーの応答を渡します。

下記4つの関数はoverrideする必要があります

  • canInit(with request: URLRequest) -> Bool
  • canonicalRequest(for request: URLRequest) -> URLRequest
  • stopLoading()
  • startLoading()

MockURLProtocolを指定したURLSessionを作成する

MockURLProtocolを使うためにURLProtocolを指定する必要があるので実装を追加していきます。

final class MockAPIService {

    var urlSession: URLSession

    init() {
        let configuration = URLSessionConfiguration.ephemeral
        configuration.protocolClasses = [MockURLProtocol.self]
        urlSession = URLSession(configuration: configuration)
    }
}

URLSessionConfiguration.ephemeralはキャッシュ、Cookie、またはクレデンシャルに永続ストレージを使用しないセッション構成となります。標準は.default これで、URLsessionMockURLProtocolを指定してMockURLProtocolを使用する準備が出来ました。

requestHandlerを使ったデータ注入

MockURLProtocolに定義したrequestHandlerを使ってMockURLProtocolにダミーデータを送る準備をしていきます。 まず、MockURLProtocolにデータを注入できることをprotocolを使って表現します。 ここが自分が分かっていなかった3のデータの注入の部分です。

protocol DataInjectable {
    var urlSession: URLSession { get }

    func injectingToMockURLProtocol(using data: Data?)
}

URLSession型のurlSessionというオブジェクトとinjectingToMockURLProtocol(data:)という関数を持つprotocolを定義しました。 続いてinjectingMockURLProtocolをextensionを使って標準実装します。

extension DataInjectable {
    func injectingToMockURLProtocol(using data: Data?) {
        MockURLProtocol.requestHandler = { request in
            return (HTTPURLResponse(), data)
        }
    }
}

injectingToMockURLProtocol(data:)は外からデータを受け取ってMockURLProtocol.requestHandlerにデータを渡す仕事を目的とします。 これでDataInjectableの実装が終わったので、MockAPIServiceDataInjectableに準拠させます。

こうなりました。

final class MockAPIService: DataInjectable {

    var urlSession: URLSession

    init() {
        let configuration = URLSessionConfiguration.ephemeral
        configuration.protocolClasses = [MockURLProtocol.self]
        urlSession = URLSession(configuration: configuration)
    }
}

標準実装しているのでただ準拠させているだけです。ただこのままだとMockAPIServiceはリクエストできないのでprotocol ApiServiceにも準拠させます。

final class MockAPIService: ApiService, DataInjectable {

    var urlSession: URLSession

    init() {
        let configuration = URLSessionConfiguration.ephemeral
        configuration.protocolClasses = [MockURLProtocol.self]
        urlSession = URLSession(configuration: configuration)
    }

    func call<Request>(from request: Request) -> AnyPublisher<Request.Response, APIServiceError> where Request: APIRequestType {
           // なにかかく
    }
}

このままだとfunc call<Request>(from request: Request)の返り値がないので中身を実装していきます。といっても中編で定義した処理内容とほとんどかわりません。

let request: URLRequest = request.buildRequest()
let decorder = JSONDecoder()
decorder.dateDecodingStrategy = .iso8601
decorder.keyDecodingStrategy = .convertFromSnakeCase

return urlSession.dataTaskPublisher(for: request)
    .tryMap { (data, response) -> Data in
        if let urlResponse = response as? HTTPURLResponse {
            switch urlResponse.statusCode {
            case 200..<300:
                return data
            default:
                throw try decorder.decode(ApiErrorResponse.self, from: data)
            }
        }
        return data
    }
    .mapError { error in
        APIServiceError.responseError(error)
    }
    .flatMap {
        Just($0)
            .decode(type: Request.Response.self, decoder: decorder)
            .mapError { error in
                return APIServiceError.parseError(error)
            }
    }
    .eraseToAnyPublisher()

これでURLProtocolにデータを注入したテストがかけるようになりました。 テストの全体的な流れはこのようになります。

class UserLinkedAccountsAttachRequestSpec: QuickSpec {

    override func spec() {
        let apiService = MockAPIService()
        var cancellables: [AnyCancellable] = []

        beforeSuite {
            Nimble.AsyncDefaults.timeout = TestConstants.timeout
            Nimble.AsyncDefaults.pollInterval = TestConstants.pollInterval
        }

        beforeEach {
            cancellables = []
        }

        describe("AppleAttach") {
            it("AppleID連携できる") {
                let data = try? JSONSerialization.data(
                    withJSONObject: [
                        "user_linked_accounts": [
                            "au": false,
                            "apple": true,
                            "google": false,
                        ]
                    ],
                    options: []
                ) // 1. json文字列に変換

                let request = UserLinkedAccountsAttachRequest.AppleAttach(
                    authorizationCode: "123"
                ) // 2. リクエスト作成

                apiService.injectingToMockURLProtocol(using: data)
                // 3. 作成したデータをアダプターみたいなもので注入

                waitUntil(timeout: .milliseconds(100)) { done in
                    apiService.call(from: request)
                        .sink { completion in
                            switch completion {
                            case .finished:
                                break
                            case .failure(_):
                                fail()
                            }
                        } receiveValue: { response in
                            expect(response.userLinkedAccounts.apple).to(beTrue())
                            done()
                        }
                        .store(in: &cancellables)
                }  // 4. 注入したデータがパースされてuserLinkedAccountsになっていてテストが通る
            }
        }
    }
}

HTTPリクエストのテストはこれでうまく行きそうです。

ViewModelのテスト

ViewModelのテストも先程実装したMockAPIServiceでいけるかなと自問自答したときに、ViewModelの関心はAPIの処理が適切にハンドリングされていることではないはずで、URLProtocolを使ってモックするやりかたではないなと思いました。

そのため、MockAPIServiceとは別のサービスクラスを用意しました。先程のサービスクラスはDataInjectableMockAPIServiceに改名しました。

ViewModelのテストシナリオ

テストシナリオを満たす前にMVVMパターンのこれらを満たしている必要があります

  1. 初期化時にViewModelにAPIを渡すことができる(Dependency Injection)
  2. ViewModelの外から渡された値を元にプロパティを変更できる(ViewModelのInput)
  3. ViewModelの外から監視しているプロパティが内部ロジックを経て購読できる(ViewModelのOutput)

以上を踏まえた上で今回のケースではAppleIDとの連携について考えたいと思います。

このようなアカウントの連携画面があって、AppleIDがすでに連携済みである場合、ViewModel内のisAppleLoggedInResultというプロパティがtrueになっていることをテストしていきます。 f:id:ohayoukenchan:20211228000821p:plain

シナリオはこのような感じです

  1. ViewModelを初期化するタイミングでisAppleLoggedInResulttrueになっている

これをテストしていきます。

通信の結果を置き換える

前述の通りViewModelのテストはAPIの通信結果のハンドリングには関心を持たせたくないので通信結果を準備したデータに置き換えていきます。 実際に通信するわけではなく通信結果の代わりにMockAPIServiceに置き換えたデータを渡してそれを返してあげれば良さそうです。

MockAPIServiceにstub(type: Request.Type, response: @escaping ((Request) -> AnyPublisher<Request.Response, APIServiceError>)) where Request: APIRequestTypeを定義してArrayに追加できるようにします。ここではAnyPublisher<Request.Response, APIServiceError>なAnyPublisherが追加される想定です。

final class MockAPIService: ApiService {
    var stubs: [Any] = []

    func stub<Request>(
        for type: Request.Type,
        response: @escaping ((Request) -> AnyPublisher<Request.Response, APIServiceError>)
    ) where Request: APIRequestType {
        stubs.append(response)
    }
}

ApiServicecall(from Request)を実装しなければいけないので追加します。

final class MockAPIService: ApiService {
    var stubs: [Any] = []

    func stub<Request>(
        for type: Request.Type,
        response: @escaping ((Request) -> AnyPublisher<Request.Response, APIServiceError>)
    ) where Request: APIRequestType {
        stubs.append(response)
    }

    func call<Request>(from request: Request) -> AnyPublisher<Request.Response, APIServiceError>
    where Request: APIRequestType {
        // ここになにかかく
    }
}

このMockAPIServicecall(request:)されたときに追加したstubがAnyPublisher<Request.Response, APIServiceError>で返ってくるようにcall(request:)の中身を実装していきます。 最終的には下記のようになりました。

final class MockAPIService: ApiService {
    var stubs: [Any] = []

    func stub<Request>(
        for type: Request.Type,
        response: @escaping ((Request) -> AnyPublisher<Request.Response, APIServiceError>)
    ) where Request: APIRequestType {
        stubs.append(response)
    }

    func call<Request>(from request: Request) -> AnyPublisher<Request.Response, APIServiceError>
    where Request: APIRequestType {

        let response =
            stubs.compactMap { stub -> AnyPublisher<Request.Response, APIServiceError>? in
                let stub = stub as? ((Request) -> AnyPublisher<Request.Response, APIServiceError>)
                return stub?(request)
            }
            .last

        return response
            ?? Empty<Request.Response, APIServiceError>()
            .eraseToAnyPublisher()
    } // Arrayに登録されている最後のデータを返す。もしくは空のPublisherを返す
}

MockAPIServiceを利用する

最終的にテストケースはこのようになりました。

final class ProviderLoginSettingViewModelSpec: QuickSpec {
    override func spec() {
        var apiService = MockAPIService()
        var cancellables: [AnyCancellable] = []

        // outputs
        var isAppleLoggedInResult: [Bool] = []

        beforeEach {
            apiService = MockAPIService()
            cancellables = []

            isAppleLoggedInResult = []
        }

        func bindVM(_ vm: ProviderLoginSettingViewModel) {
            vm.$isAppleLoggedIn
                .sink {
                    isAppleLoggedInResult.append($0)
                }
                .store(in: &cancellables)
        }

        describe("init") {

            context("apple id連携済みのとき") {
                it("isAppleLoggedInが設定される") {

                    apiService.stub(for: UserLinkedAccountsAttachRequest.Me.self) { _ in
                        Record<UserLinkedAccountsResponse, APIServiceError> {
                            promise in
                            promise.receive(
                                UserLinkedAccountsResponse(
                                    userLinkedAccounts: UserLinkedAccounts(
                                        au: false,
                                        apple: true,
                                        google: false
                                    )
                                )
                            )
                        }
                        .eraseToAnyPublisher()
                    } // 通信結果を置き換える

                    let vm = ProviderLoginSettingViewModel(
                        referrer: nil,
                        apiService: apiService 
                    ) // ViewModelの初期化時にMockAPIServiceをDependency Injectionする

                    bindVM(vm) // viewModelのbinding

                    expect(isAppleLoggedInResult).to(equal([true]))
                }
            }
        }
    }
}

expect(isAppleLoggedInResult).to(equal([true]))UserLinkedAccountsResponseの値に置き換えられていることが分かります。 これでViewModelのテストもうまくかけそうです。

最後に

今回はAPIのテストとViewModelのテストを書く部分についてお伝えしました。 Combine使ってみたいけどテストどうやって書くのかな?とかAPIのテストの書き方など少しでもお役に立てたら幸いです。

URLProtocolのモックやAPIクライアント自作によるURLRequestの生成などで普段エンドポイント作成しているだけでは分からないことが少し分かった気になりました。 余談ですが、初実装ではbodyパラメータの実装がまんま抜けてて、後で気づいて冷や汗を書きました。

ママリはこれからもSwiftUIやCombine含め新たな挑戦をどんどんしていくので、興味を持っていただけたら是非こちらからエントリーいただくまでご連絡ください!お待ちしております。