はじめに
こんにちは。会員向けアプリ「マイ ニフティ」の開発運用をしている村松です。
現在、マイ ニフティでは現在iOS・Androidアプリにユニットテストを追加しています。
その中で、iOSアプリでのAPIと通信する部分のユニットテストをサードパーティのMockライブラリなどを使わずに簡単に書くことができたので、紹介したいと思います。
AppleのWWDC18のセッションの「Testing Tips & Tricks」で紹介されている、URLProtocolを利用したMockのやり方を参考にしています。
背景
マイ ニフティ iOSアプリでは、通信処理ではAlamofireを利用しています。APIからデータ取得する部分のユニットテストを追加する際にどのようにしていくか、調査すると、以下のような手段がありました。
- Mockライブラリを使う
- DockerなどでAPIサーバーを立てる
しかし、通信処理で複雑なことはしていないため、Mockライブラリを追加するほどではなく、また、DockerなどでAPIサーバーを立てるのは管理コストやテスト実行時間の点で、採用しにくいなと感じていました。
そんな中、URLProtocolを利用する方法だと、自分たちで簡単にMockを作成できそうだったので、採用してみました。
通信処理のMockの実装
URLSessionはURLProtocolのサブクラスで実際の処理を行うため、通信処理のMockをURLProtocolのサブクラスで実装し、URLSessionで利用するようにすれば、Mockを実現することができます。
URLProtocolのサブクラスで実装すべきメソッドは以下の4つあり、startLoading で通信処理のMockを実装します。
- canInit
- canonicalRequest
- startLoading
- stopLoading
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public class LoginAPIMockURLProtocol: URLProtocol { override open class func canInit(with request: URLRequest) -> Bool { return true } override open class func canonicalRequest(for request: URLRequest) -> URLRequest { return request } // 通信開始時に呼ばれるメソッド // Mock処理を実装する override open func startLoading() { } // 通信停止時に呼ばれるメソッド override open func stopLoading() { } } |
Mockの実装は求められるテスト粒度に応じて、実装していく流れになり、細かくやれば、
- HTTPメソッドが正しいか
- リクエストパラメーターが正しいか
- APIで想定されているエラーを出し分ける
などといったMockを実装することができます。
URLProtocolの実装ではURLProtocolClientのclientプロパティで、クライアント側に通信結果を伝えます。
通信成功時は
- urlProtocol(_:didReceive:cacheStoragePolicy:)
- urlProtocol(_:didLoad:)
- urlProtocolDidFinishLoading(_:)
を呼び出します。
1 2 3 |
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didLoad: responseData) client?.urlProtocolDidFinishLoading(self) |
通信失敗時は
- urlProtocol(_:didFailWithError:)
を呼び出します。
1 2 3 4 5 6 7 |
self.client?.urlProtocol( self, didFailWithError: APIMockError( errorDomain: "APIMockError.LoginAPIMockProtocol", errorUserInfo: [NSLocalizedDescriptionKey: "HTTPメソッドがPOSTではありません"] ) ) |
以上を踏まえて、実装したコードが以下のコードとなります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 |
import Foundation import XCTest struct APIMockError: CustomNSError { var errorDomain: String var errorCode = 1 var errorUserInfo: [String: Any] } public class LoginAPIMockURLProtocol: URLProtocol { override open class func canInit(with request: URLRequest) -> Bool { return true } override open class func canonicalRequest(for request: URLRequest) -> URLRequest { return request } // 通信開始時に呼ばれるメソッド // Mock処理を実装する override open func startLoading() { guard let originalRequest = task?.originalRequest else { self.client?.urlProtocol( self, didFailWithError: APIMockError( errorDomain: "APIMockError.LoginAPIMockProtocol", errorUserInfo: [NSLocalizedDescriptionKey: "リクエストを取得できませんでした"] ) ) return } guard let method = originalRequest.httpMethod, method == "POST" else { self.client?.urlProtocol( self, didFailWithError: APIMockError( errorDomain: "APIMockError.LoginAPIMockProtocol", errorUserInfo: [NSLocalizedDescriptionKey: "HTTPメソッドがPOSTではありません"] ) ) XCTFail("HTTP Method is not POST") return } guard let httpBody = originalRequest.httpBody else { self.client?.urlProtocol( self, didFailWithError: APIMockError( errorDomain: "APIMockError.LoginAPIMockProtocol", errorUserInfo: [NSLocalizedDescriptionKey: "リクエストボディが空です"] ) ) XCTFail("Request Body is empty") return } // リクエストJSONのデコード let jsonDecoder = JSONDecoder() jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase jsonDecoder.dateDecodingStrategy = .iso8601 let loginUser: LoginUserAPIModel do { loginUser = try jsonDecoder.decode(LoginUserAPIModel.self, from: httpBody) } catch { self.client?.urlProtocol(self, didFailWithError: error) XCTFail("Request JSON is Invalid") return } let response: HTTPURLResponse var responseData: Data let jsonEncoder = JSONEncoder() jsonEncoder.keyEncodingStrategy = .convertToSnakeCase // 引数でMockの処理を変更している switch loginUser.password { case "200": // 正常の場合 let token = TokenAPIModel(accessToken: "accessToken") response = HTTPURLResponse( url: originalRequest.url!, statusCode: 200, httpVersion: "HTTP/2", headerFields: ["Content-Type": "application/json"] )! guard let data = try? jsonEncoder.encode(token) else { self.client?.urlProtocol( self, didFailWithError: APIMockError( errorDomain: "APIMockError.LoginAPIMockProtocol", errorUserInfo: [NSLocalizedDescriptionKey: "TokenAPIModel構造体のエンコードに失敗しました"] ) ) return } responseData = data case "401": // エラーの場合 let userAPIErrorResponse = APIErrorResponse(error: "401 error") response = HTTPURLResponse( url: originalRequest.url!, statusCode: 401, httpVersion: "HTTP/2", headerFields: ["Content-Type": "application/json"] )! guard let data = try? jsonEncoder.encode(userAPIErrorResponse) else { self.client?.urlProtocol( self, didFailWithError: APIMockError( errorDomain: "APIMockError.LoginAPIMockProtocol", errorUserInfo: [NSLocalizedDescriptionKey: "APIErrorResponse構造体のエンコードに失敗しました"] ) ) return } responseData = data default: self.client?.urlProtocol( self, didFailWithError: APIMockError( errorDomain: "APIMockError.LoginAPIMockProtocol", errorUserInfo: [NSLocalizedDescriptionKey: "レスポンス処理が設定されていないパスワードです"] ) ) return } // レスポンス client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didLoad: responseData) client?.urlProtocolDidFinishLoading(self) } // 通信停止時に呼ばれるメソッド override open func stopLoading() { } } |
通信処理をMockに差し替えて、テストを作成
Mock自体の実装は完了したので、次は通信処理をMockに差し替えます。
通信処理はURLSessionで行われるので、URLSessionの設定である URLSessionConfiguration でMockに差し替える設定をします。
URLSessionConfiguration には protocolClasses という配列のプロパティが存在し、このプロパティにURLProtocolのサブクラスを設定することで、Mockに処理を差し替えることができます。
テストの setUpWithError でURLSessionConfigurationの設定をして、APIと通信するテスト対象の関数にURLSessionを渡すことで、Mockを利用したテストを実現できました。
1 2 3 4 5 |
override func setUpWithError() throws { let configuration = URLSessionConfiguration.af.default configuration.protocolClasses[0] = [LoginAPIMockURLProtocol.self] self.loginAPI = LoginAPI(session: Session(configuration: configuration)) } |
以下のコードが今回のテスト対象です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
import Foundation import Combine import Alamofire struct LoginAPIModel: Codable { let username: String let password: String } struct TokenAPIModel: Codable, Equatable { let accessToken: String } struct APIError: Error { let statusCode: Int let errorResponse: APIErrorResponse } struct APIErrorResponse: Codable, Equatable { let error: String } class LoginAPI { private let session: Session init(session: Session = AF) { self.session = session } func login(username: String, pw: String) -> Future<TokenAPIModel, APIError> { let loginUser = LoginAPIModel(username: username, password: pw) let jsonEncoder = JSONEncoder() jsonEncoder.keyEncodingStrategy = .convertToSnakeCase let parameterEncoder = JSONParameterEncoder(encoder: jsonEncoder) let headers: HTTPHeaders = [.contentType("application/json"), .accept("application/json")] let jsonDecoder = JSONDecoder() jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase return Future { promise in self.session .request( loginAPIURL, method: .post, parameters: loginUser, encoder: parameterEncoder, headers: headers ) .validate() .responseDecodable(of: TokenAPIModel.self, decoder: jsonDecoder) { response in switch response.result { case .success(let userToken): promise(.success(userToken)) case .failure: guard let statusCode = response.response?.statusCode else { promise(.failure(APIError( statusCode: 0, errorResponse: APIErrorResponse(error: "network error") ))) return } guard let errorResponse = try? jsonDecoder.decode(UserAPIErrorResponse.self, from: response.data!) else { promise(.failure(APIError( statusCode: statusCode, errorResponse: APIErrorResponse(error: "json decode error") ))) return } promise(.failure(APIError( statusCode: statusCode, errorResponse: errorResponse ))) } } } } } |
テストコードは以下になります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
import XCTest import Combine import Alamofire final class LoginAPITests: XCTestCase { private var loginAPI: LoginAPI! private var cancellables: Set<AnyCancellable> = [] // テストのセットアップで通信処理をMockに差し替え override func setUpWithError() throws { let configuration = URLSessionConfiguration.af.default configuration.protocolClasses[0] = [LoginAPIMockURLProtocol.self] self.loginAPI = LoginAPI(session: Session(configuration: configuration)) } override func tearDownWithError() throws { } func testLoginAPI() throws { let expectation = XCTestExpectation() expectation.expectedFulfillmentCount = 2 loginAPI.login(username: "username", pw: "200") .sink(receiveCompletion: { completion in switch completion { case .finished: break case .failure: XCTFail("LoginAPI 200 Test Fail") } }, receiveValue: { token in XCTAssertEqual( token, TokenAPIModel(accessToken: "accessToken"), "LoginAPI 200 Test is not Expected") expectation.fulfill() }).store(in: &cancellables) loginAPI.login(username: "username", pw: "401") .sink(receiveCompletion: { completion in switch completion { case .finished: break case let .failure(error): XCTAssertEqual( error, APIError( statusCode: 401, errorResponse: APIErrorResponse(error: "401 error") ), "LoginAPI 401 Test is not Expected" ) expectation.fulfill() } }, receiveValue: { _ in XCTFail("LoginAPI 401 Test Fail") }).store(in: &cancellables) wait(for: [expectation], timeout: 1) } } |
おわりに
実際に、Mockを簡単に実装できました。状況に応じて、Mockを細かく実装できるのが嬉しいですね。簡単にできるので、利用してみてください。