コネヒト株式会社でiOSエンジニアをやっています ohayoukenchan です。
APIクライアントをAPIKit+RxSwiftからURLSession+Combineにしたお話の後編にあたります。
前回までのお話はこちらをご参照ください。
- APIクライアントをAPIKit+RxSwiftからURLSession+Combineにしました(前編)
- 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 }
ApiService
はAPIRequestType
に準拠した型を引数にもつcall(from Request)
という関数が、返り値としてAnyPublisher<Request.Response, APIServiceError>
を返すことを制約としていました。
APIRequestType
については 前編を参考にしてください。
APIと通信する際はエンドポイント毎にAPIRequestType
に準拠した構造体を用意し、実装したApiService
のcall(from:)
にてURLRequest
を作成してdataTaskPublisher(for: urlRequest)
を叩くことでレスポンスを受け取っていました。テストを書く際もこのプロトコルに準拠させることで可読性の高いテストを書いていきたいと思います。
HTTPリクエストのテスト
まずHTTPリクエストのテストについて考えます。
リクエストの成功、失敗
HTTPリクエスト自体をテストしたいケースとしては以下のようなものが考えられます。
- HTTPリクエストがなんらかの理由で失敗した場合、レスポンスとして返ってきた情報を正しく操作できているか確認したい。
- HTTPリクエストが成功した場合、パース処理にdataが渡っていることを確認したい。
パース処理の確認
正常にレスポンスが渡ってきた場合でも、パースに失敗した場合クライアント側にデータを渡す訳にはいかないので、こちらも正しくエラーハンドリングできているか確認したいので、パース結果が適切かもテストしていきたいと思います。
どのようにテストを書きたいか
どんなテストが書きたいかを再確認したところで、どのように書きたいかを考えていきます。 当初自分の頭の中にあったイメージはこのようなものでした。
シナリオ
- レスポンス対象となるjson文字列を作成
- リクエストを作成
- 作成したデータをアダプターみたいなもので注入
- 注入したデータがパースされていてテストが通る
もう少し具体的にコードを交えて書くとこのような感じです。
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
とは
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
これで、URLsession
にMockURLProtocol
を指定して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
の実装が終わったので、MockAPIService
をDataInjectable
に準拠させます。
こうなりました。
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パターンのこれらを満たしている必要があります
- 初期化時にViewModelにAPIを渡すことができる(Dependency Injection)
- ViewModelの外から渡された値を元にプロパティを変更できる(ViewModelのInput)
- ViewModelの外から監視しているプロパティが内部ロジックを経て購読できる(ViewModelのOutput)
以上を踏まえた上で今回のケースではAppleIDとの連携について考えたいと思います。
このようなアカウントの連携画面があって、AppleIDがすでに連携済みである場合、ViewModel内のisAppleLoggedInResult
というプロパティがtrueになっていることをテストしていきます。
シナリオはこのような感じです
- ViewModelを初期化するタイミングで
isAppleLoggedInResult
はtrue
になっている
これをテストしていきます。
通信の結果を置き換える
前述の通り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) } }
ApiService
はcall(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 { // ここになにかかく } }
このMockAPIService
がcall(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含め新たな挑戦をどんどんしていくので、興味を持っていただけたら是非こちらからエントリーいただくか 私までご連絡ください!お待ちしております。