見出し画像

OHHTTPStubsとSwiftGenを使ったUnitTestの実装

こんにちは、新米エンジニアの山口です。
今日は、OHHTTPStubsSwiftGenをつかって、ちょっといい感じにAPIリクエスト周りのテストが実装できたので、その方法を紹介しようと思います!

以下のようなメリットがあるのでぜひ、やってみてください!

  • APIリクエストのテストが簡単に書ける

  • 並列のAPIリクエストのテストも簡単に書ける

  • APIリクエストのモックファイル(.json)の管理がスマートになる

OHHTTPStubsとは

OHHTTPStubs

OHHTTPStubsはAPIリクエスト処理のテストを実装するときによく使われているライブラリ。
テスト実装は基本的にネットワークに依存しないことが前提になると思いますが、OHHTTPStubsはアプリ内でのなんらかの通信にフックしてAPI通信をする代わりに指定したjsonデータを返してくれます。

SwiftGenとは

SwiftGen

SwiftGenの説明はこちらのサイトがわかりやすかったです!
要は、プロジェクト内のリソース(画像や色など)を呼び出すためのコードを自動生成してくれて、ハードコーディングを回避して、より型安全にリソースを記述できるというものです。

今回はOHHTTPStubsで指定するMock(.jsonファイル)をSwiftGenで管理して、APIリクエスト周りの実装をスマートに実装していこうと思います!

主な実装流れは以下のような感じです。
今回は、OHHTTPStubsやSwiftGenの導入については割愛します!

  1. .jsonファイルをSwiftGenで管理する

  2. OHHTTPStubs周りのを実装

.jsonファイルをSwiftGenで管理する

SwiftGenの記述は以下のような感じにしました。
番号をつけているので順番に説明します。

files: # ①
  inputs:
  - {$PROJECT_NAME}/Resources/StubResources # ②
  outputs:
    templateName: structured-swift5 # ③
    output: {$PROJECT_NAME}/Resources/Files.swift # ④
  1. 今回はファイルを管理するので、filesのキーを使用します。

    • Assetsのリソースを管理する場合はxcassets、ローカライズの文字列のリソースを管理する時はStringといった感じで使い分けます。

  2. inputsには.jsonファイルが配置されているディレクトリパスを記載します。

  3. templateNameはコードの形式を指定します。

    • 今回はSwift5で、且つ、Structのコードを生成したいので、structured-swift5を指定します。

  4. outputは生成するコードの配置先を指定します。

上記の設定でapi_success_200.jsonのファイルをSwiftGenで自動生成すると以下のような感じになります。
構造体で定義されているFileは、ファイル名(name)や拡張子(ext)などのプロパティを持っており、列挙型で定義されているFilesは.jsonファイルごとにFileをインスタンス化したstaticなプロパティを持っています。

今回のOHHTTPStubs周りの実装では、Mockの.jsonファイルをファイル名と拡張子で指定するので、nameextを使っていきます。

public enum Files {
  public enum StubResource {
    /// api_success_200.json
    public static let apiSuccess200Json = File(name: "api_success_200", ext: "json", relativePath: "", mimeType: "application/json")
  }
}

public struct File {
  public let name: String
  public let ext: String?
  public let relativePath: String
  public let mimeType: String

  public var url: URL {
    return url(locale: nil)
  }

  public func url(locale: Locale?) -> URL {
    let bundle = BundleToken.bundle
    let url = bundle.url(
      forResource: name,
      withExtension: ext,
      subdirectory: relativePath,
      localization: locale?.identifier
    )
    guard let result = url else {
      let file = name + (ext.flatMap { ".\($0)" } ?? "")
      fatalError("Could not locate file named \(file)")
    }
    return result
  }

  public var path: String {
    return path(locale: nil)
  }

  public func path(locale: Locale?) -> String {
    return url(locale: locale).path
  }
}

private final class BundleToken {
  static let bundle: Bundle = {
    #if SWIFT_PACKAGE
    return Bundle.module
    #else
    return Bundle(for: BundleToken.self)
    #endif
  }()
}

ちなみに、api_success_200.jsonの中身は以下のような感じです。(仮で作っているので、意味はないです。)

{
  "user_id" : "1111",
  "user_name" : "yamada taro",
}

OHHTTPStubs周りの実装

OHHTTPStubsの基礎知識

OHHTTPStubsはstub(condition: , response: )のメソッドを使ってstubの設定を行います。

  • condition

    • 戻り値がBoolのクロージャーを指定します。

    • 条件がtrueの時だけ、responseで指定したデータを返します。

  • response

    • 戻り値がOHHTTPStubsResponseのクロージャーを指定します。

    • OHHTTPStubsResponseはモックとして、HTTPリクエスト後に返すレスポンス情報を指定できます。

実際に使ってみると以下の感じ。

stub(condition: isPath({"APIリクエストのエンドポイント"})) { _ in

        let stubJSONPath = OHPathForFileInBundle({".jsonファイルのファイル名", {ファイルのバンドル})
        return OHHTTPStubsResponse(fileAtPath: stubJSONPath!,
                                   statusCode: {レスポンスのステータスコード},
                                   headers: ["Content-Type": "application/json"])
}

conditionの条件式はライブラリ側で用意されているものがいくつかあり、
isPathはAPIリクエストのpathが指定したものと一致するときにtrueを返します。

OHPathForFileInBundleはMockのレスポンスとして返す.jsonファイルのファイル名とバンドルをして、OHHTTPStubsResponsefileAtPathに指定できます。
ここで、先述のSwiftGenで生成したコードを使っていきます。

StubManagerの実装

まずは汎用的に使えるようにしたいので、stubを設定する時の諸々の値をまとめたStubParameterを実装していきます。
ざっと、stub設定で必要な情報は以下。

  • endPoint

  • .jsonのファイル名(SwiftGenのFileが使える)

  • .jsonファイルのバンドル

  • statusCode

実装してみると以下のような感じ。
jsonFileはSwiftGenで生成したFilesのインスタンスをそのまま受け取れるように、File型に指定してます。
endPointstatusCodeは必ずしも必須ではないので、initで初期値を決めてます。

struct StubParameter {
    let endPoint: String?
    let jsonFile: File
    let bundle: Bundle
    let statusCode: Int32

    public init(endPoint: String? = nil, jsonFile: File, bundle: Bundle, statusCode: Int32 = 200) {
        self.endPoint = endPoint
        self.jsonFile = jsonFile
        self.bundle = bundle
        self.statusCode = statusCode
    }
}

あとはstubを設定するメソッドを実装すればいいんですが、
今回は複数のAPIリクエストがあるメソッドのテストも想定したいので、単一のStubParameterを受け取るものと、複数のStubParameterを受け取る2パターンの処理を実装していきます。
複数のパラメーターを受け取る場合は、conditionendPointが一致するかどうかの条件式を入れようと思います。(つまり、「このエンドポイントの時はこのMockレスポンスを返す」みたいにします。)

実装してみると以下のような感じ。

final class StubManager {

    static let shared = StubManager()

    // ①
    private func stubResponseBlock(parameter: StubParameter) -> OHHTTPStubsResponseBlock {
        return { _ in
            let stubJSONPath = OHPathForFileInBundle("\(parameter.jsonFile.name).\(parameter.jsonFile.ext!)", parameter.bundle)
            return OHHTTPStubsResponse(fileAtPath: stubJSONPath!,
                                       statusCode: parameter.statusCode,
                                       headers: ["Content-Type": "application/json"])
        }
    }

    func setStub(parameter: StubParameter) {
        stub(condition: {_ in true}, response: stubResponseBlock(parameter: parameter))
    }

    func setStub(parameters: [StubParameter]) {
        parameters.forEach { parameter in
            // ②
            stub(condition: isPath(parameter.endPoint ?? ""), response: stubResponseBlock(parameter: parameter))
        }
    }

    // ③
    func removeAllStubs() {
        OHHTTPStubs.removeAllStubs()
    }
}

実装のポイントは以下のような感じ。

  1. stub(condition: , response: )responseの指定は共通化できそうだったので、private関数に共通化。

  2. 複数のStubParameterが指定される場合は、endPointが一致するかどうかの条件式を入れてます。endPointが一致しない場合は指定したレスポンスは返されません。

  3. 利用する側でremoveができるようにしてます。XCTestだとtearDownで呼び出すとよさそう。


これで、実装は完了です!
実際に、複数のAPIリクエストのある処理のテストは以下のような感じ。
例えば、ユーザ情報(user)とチャットデータ(chatData)をfetchする以下のような処理のテストを考えます。

// APIリクエスト(ViewModel)

func fetchData() async {
    async let fetchUser = repository.fetchUser()
    async let fetchChatData = repository.fetchChatData()
    do {
        user = try await fetchUser
        chatData = try await fetchChatData
    } catch let error {
        self.error = error
    }
}

// テストコード

override func tearDown() {
    super.tearDown()
    StubManager.shared.removeAllStubs()
}

func testSuccessFetchData() async {
    let roomInfoStubParameter = StubParameter(endPoint: "api/user/xxxx", jsonFile: Files.StubResource.apiUserSuccessJson, bundle: .init(for: type(of: self)))
    let chatDataStubParameter = StubParameter(endPoint: "api/chat/xxxx", jsonFile: Files.StubResource.apiChatDataSuccessJson, bundle: .init(for: type(of: self)))
    StubManager.shared.setStub(parameters: [roomInfoStubParameter, chatDataStubParameter])

    let userMock = {.jsonで指定したデータ}
    let chatDataMock = {.jsonで指定したデータ}
    await viewModel.fetchData()
    XCTAssertEqual(viewModel.user, userMock)
    XCTAssertEqual(viewModel.chatData, chatDataMock)
    XCTAssertNotNil(viewModel.error)
}


最後に

いかがだったでしょうか。
SwiftGenとOHHTTPStubsを合わせて、テストコードはかなりスッキリかけてるんじゃないかなと思います。
テストの実装にあまり時間をかけるのも良くないですし、割とこの辺りのコードが古いままだったので、思い切ってリファクタリングしてみました!
すでにSwiftGenを導入している会社であれば、実装も簡単ですし、よかったら参考にしてみてください!


SHOWROOM株式会社では、エンジニア職を絶賛募集中です。
・ライブ配信サービス「SHOWROOM」
・バーティカルシアターアプリ「smash.」

以下の求人内容をご確認の上、ご興味あればご応募ください。
心よりご応募お待ちしております。

■求人は以下になります
SHOWROOM株式会社採用情報

■その他SHOWROOM株式会社の情報
note(代表前田取材記事です!)
社員インタビュー記事
SHOWROOM
smash.
SHOWROOM最新記事一覧(PRTimes)


この記事が気に入ったらサポートをしてみませんか?