はじめに
現場で既存のプロジェクトのUnitテストを行うようにしようとなり、まずはAPI通信部分のテストコードを書きました。
大規模なプロジェクトではないですが、その導入までの流れを記述していきます。
前提情報
- iOSアプリの開発で言語はSwiftを使用。
- プロジェクトのアーキテクチャは MVC + CleanArchitecture。
- 画面関係なくアプリ全体で使われるAPI通信部分をUsecaseにまとめています。
- そこから画面固有のロジッククラスであるViewModelで呼び出し、利用しています。
- 通信にはMoyaを使います。
- テストはXCTTestを使用します。
今回はこのUsecase部分のテストコードを書いていきます。
※注意
現状結果を書いていますが、まだまだ未熟ですのでより良い実装方法や気になる点あればコメントいただけるとありがたいです🙇♂️
Moya
Moyaについてはいくつも記事が出ています。
自分はこちらの海外の方のチュートリアルを翻訳した記事が分かりやすかったです。
MoyaはProviderというオブジェクトを介してAPI通信を行います。
そして、Providerは初期化時にTargetを指定します。
このTargetはサービスごとに作成します。
例えば、天気詳細取得APIに対して1つ、駅情報取得APIに対して1つといった形です。
現在はREST APIが多いと思うのですが、その場合は1つのTargetでpathを分岐させます。
今回はMoyaの説明ではないので、とりあえずこの辺で。
MoyaのendpointClosureとstubClosureを利用する
通信部分のテストでHTTPステータスが200,400系などの場合の挙動やレスポンスのマッピング・パースのUnitテストを書くと思います。
その際に、MoyaのendpointClosureとstubClosureを利用しています。
endpointClosureにresponseのステータスや内容を詳細に設定することができます。
let customEndpointClosure = { (target: APIService) -> Endpoint in
return Endpoint(url: URL(target: target).absoluteString,
sampleResponseClosure: { .networkResponse(403 , /* data relevant to the auth error */) },
method: target.method,
task: target.task,
httpHeaderFields: target.headers)
}
stubClosureはプロバイダーが実際の応答を返すか、名前の通りスタブされた応答を返すかを構成する役割を果たします。
StubBehavior
クラスの列挙型のcaseを用途によって指定することでそれに応じた挙動になります。
-
.never
応答をスタブしないようにします。 -
.immediate
応答をすぐにスタブします。 - .
delayed(seconds: TimeInterval)
特定の遅延後の応答をスタブ化する(実際のネットワーク呼び出しの遅延をシミュレートするため)。
そしてimmediateを利用すると、Targetの変数の一つであるSampleDataのDataを返してくれます。
これを利用して、テストデータを返すようにします。
let stubbingProvider = MoyaProvider<GitHub>(stubClosure: MoyaProvider.immediatelyStub)
実際の実装
Providerの初期化時にカスタムできるというところで、今回はProviderをUsecaseの初期化時に渡して設定するようにしました。
そのために
必要なProviderを種類ごとに作成できるようにそのためのprotocolとstructを用意します。
protocol ReservationProviderLoadable {
func load() -> MoyaProvider<ReservationAPI>
}
struct DefaultProvider: HogeProviderLoadable {
func load() -> MoyaProvider<HogeAPI> {
return MoyaProvider<HogeAPI>(session: DefaultAlamofireManager.sharedManager,
plugins: MoyaPlugins.verbose)
}
}
// SampleDataを返すものと、404を返すもの
struct StubHogeProvider: HogeProviderLoadable {
func load() -> MoyaProvider<HogeAPI> {
return MoyaProvider<HogeAPI>(stubClosure: MoyaProvider.immediatelyStub,
session: DefaultAlamofireManager.sharedManager,
plugins: MoyaPlugins.verbose)
}
}
struct StubHogeProviderStatus403: HogeProviderLoadable {
func load() -> MoyaProvider<ReservationAPI> {
// 擬似的に403のステータスを返す
let customEndpointClosure = { (target: HogeAPI) -> Endpoint in
Endpoint(url: URL(target: target).absoluteString,
sampleResponseClosure: { .networkResponse(404, Data()) },
method: target.method,
task: target.task,
httpHeaderFields: target.headers)
}
return MoyaProvider<HogeAPI>(endpointClosure: customEndpointClosure,
stubClosure: MoyaProvider.immediatelyStub,
session: DefaultAlamofireManager.sharedManager,
plugins: MoyaPlugins.verbose)
}
}
HogeProviderLoadable
に適応させて、それをUsecase初期化時に渡せるようにします。
ただ、そのままインスタンスを作成するクラス側でStubHogeProviderStatus403()
などを記述するのではなく、列挙型で指定するようにします。
enum ReservationProvider {
case `default`
case stub200
case stub404
var provider: hogeProviderLoadable {
switch self {
case .default:
return DefaultHogeProvider()
case .stub200:
return StubHogeProvider()
case .stub404:
return StubHogeProviderStatus404()
}
}
}
Usecase側は以下のようになります。
final class HogeUsecase {
private let provider: HogeProviderLoadable
init(provider: HogeProvider) {
// ここのprovider.providerが気に食わないけど。。
self.provider = provider.provider
}
func requestToGetHogeList(completion: @escaping ((_ reservations: [Reservation]?, _ error: Error?) -> Void)) {
let hogeProvider = provider.load()
hogeProvider.request(.list(), completion: { (result) in
switch result {
case let .success(response):
let jsonDecoder = JSONDecoder()
jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
do {
let data = try response.filterSuccessfulStatusCodes().data
let hoge = try jsonDecoder.decode([HogeEntity].self, from: data)
completion(hoge, nil)
} catch let error {
// Error
}
case let .failure(error):
completion(nil, Error)
}
})
}
}
使う側は以下のようになります。
// 普通にAPI通信する場合はdefaultを
final class HogeViewModel {
private let usecase: ReservationUsecase = ReservationUsecase(provider: .default)
private(set) var hoge: [Hoge] = []
func requestToGetHogeList(completion: @escaping ((_ error: Error?) -> Void)) {
let today: String = Date().string(format: .slashyyyymmdd)
usecase.requestToGetHogeList() { [weak self](reservations, error) in
if let error = error {
completion(error)
return
}
if let hoge = hoge {
self.hoge = hoge
}
completion(nil)
}
}
}
そして、XCTestでは以下のように想定したケースの設定にしてあるProviderを渡せばOKです。
class HogeUsecaseTests: XCTestCase {
func testRequestToGetReservationListStatus404() {
let expectation = self.expectation(description: "wait for testRequestToGetReservationListStatus404")
XCTContext.runActivity(named: "HttpStatus404") { _ in
let usecase = ReservationUsecase(provider: .stub404)
usecase.requestToGetReservationList { (reservations, error) in
let appError = ApplicationError.translate(from: error!)
XCTAssertEqual(APIError.otherClient(404).description, appError.description)
XCTAssertNil(reservations)
expectation.fulfill()
}
}
wait(for: [expectation], timeout: 60.0)
}
}
実装してみて
今回通信部分のUnitテストを行うために、スタブデータを使えるようにしましたが、
まだ対向先のAPIが利用できなかったり、UIを確認する上で特定の状態のデータが欲しい場合にも便利でした。
というか実際の業務で自分が0からUnitテストを導入するのが初めてでそちらの知識も乏しく。。
まだまだ勉強しないとなと思いました。(小並感)
説明が拙い&詳細じゃないところが多いですが、時間があるときに編集していきます。