2
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Moyaを使って通信部分のUnitテストを書く

Last updated at Posted at 2021-02-13

はじめに

現場で既存のプロジェクトの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を用意します。

HogeProviderLoadable.swift
protocol ReservationProviderLoadable {
    func load() -> MoyaProvider<ReservationAPI>
}
DefaultHogeProvider.swift
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側は以下のようになります。

hogeUsecase.swift

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)
            }
        })
    }
}

使う側は以下のようになります。

HogeViewModel.swift

// 普通に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です。

HogeUsecaseTests.swift
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テストを導入するのが初めてでそちらの知識も乏しく。。
まだまだ勉強しないとなと思いました。(小並感)

説明が拙い&詳細じゃないところが多いですが、時間があるときに編集していきます。

2
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?