iOS
XCUITest
WireMock

WireMockを使ったXcode UI Testing

大晦日ですね。

それはおいといて、

UIテストなどを書いている際、画面遷移をする上で通信が必要となる場面が多いかと思います。
しかしその時点でサーバが準備されてない、またはサーバが安定しないためテスト自動化で失敗するケースが増えるのを避けたいなどの理由からローカル環境にモックサーバを立てて開発・テストする事もあるかと思います。今回はそんな時に使うと便利なモックサーバ構築用のツールであるWireMockについて簡単な例を元に説明していこうと思います。

WireMockとは

HTTPベースのAPIのシミュレータ、モックサーバーとして便利な機能を揃えています。
http://wiremock.org
https://github.com/tomakehurst/wiremock

WireMockの起動

WireMockは一つのjarファイルで提供されており、起動についてはこれを実行するだけです。
http://repo1.maven.org/maven2/com/github/tomakehurst/wiremock-standalone/2.12.0/wiremock-standalone-2.12.0.jar

$ java -jar wiremock-standalone-2.12.0.jar

これでhttp://localhost:8080でモックサーバーにアクセスできます。

※Javaの実行環境が必要になりますのでインストールしておいてください。

公式HP
http://wiremock.org/docs/running-standalone/

クライアントライブラリ

Wiremock自身のAPIを叩くためのクライアント実装としてはWiremockClient(https://github.com/mobileforming/WiremockClient) を使用しています。

WiremockClientの使い方

レスポンスのマッピング

Wiremockは特定のリクエストに対して指定したレスポンスを返す設定を行うことが出来ます。

まずWiremockClientpostMappingメソッドを使ってスタブのマッピングを行います。
以下の例では/api/v1/event/?keyword=&start=1&count=10がリクエストされた時のレスポンスとしてテストバンドルに登録されたpage1.jsonが読み込まれて設定されます。

    override func setUp() {
        super.setUp()
        let bundle  = Bundle(for: type(of: self))
        WiremockClient.postMapping(stubMapping: StubMapping
            .stubFor(requestMethod: .GET, urlMatchCondition: .urlEqualTo, url: "/api/v1/event/?keyword=&start=1&count=10")
            .willReturn(ResponseDefinition().withLocalJsonBodyFile(fileName: "page1", fileBundleId: bundle.bundleIdentifier!, fileSubdirectory: nil)))

        ...
        app.launch()
    }

ちなみに殆どの場合、設定するレスポンスは長めにになると思いますが、テストコードで直接JSONを文字列を与える事も可能です。

マッピング内容の破棄

tearDownで以下のようにWiremockClient.reset()を実行しておけば、登録した内容は破棄されます。

    override func tearDown() {
        super.tearDown()
        WiremockClient.reset()
    }

登録内容の確認

Wiremockに登録されているレスポンスなどについて確認したい場合は
ブラウザでlocalhost:8080/__admin/docsを開きSwagger UIの管理画面へアクセスできるので、GET /__admin/mappingsで確認出来ます。
Screen Shot 2017-12-31 at 16.13.34.png

使用例

今回、Connpass APIを使ったサンプルコードを書き試してみました。
Connpass API リファレンス
https://connpass.com/about/api/

アプリが取得するデータは以下の3つ
1. Connpass APIのレスポンス
2. ConnpassのイベントページのHTML
3. イベントページに設定されているOGP画像

※2.と3.についてはOGP画像の取得のため行なっています。

通信の内容は以下の通り
Screen Shot 2017-12-31 at 16.54.15.png

アプリを普通に触ってると以下のような感じで動きます。
起動→イベント一覧取得→セルタップでイベント詳細画面へ遷移

Dec-31-2017 15-17-10.gif

APIのレスポンスのみモックしてオフラインで実行してみる

レスポンスとなるjsonを準備して、Wiremockに登録し、UIテストを実行します。

今回私はテストデータの作成にはCharlesを使いましたが、WiremockにはRecodingの機能もあるため、そちらを試してみるのも良いでしょう。

import XCTest
import WiremockClient

class ConnpassViewerUITests: XCTestCase {

    override func setUp() {
        super.setUp()
        let bundle  = Bundle(for: type(of: self))
        WiremockClient.postMapping(stubMapping: StubMapping
            .stubFor(requestMethod: .GET, urlMatchCondition: .urlEqualTo, url: "/api/v1/event/?keyword=&start=1&count=10")
            .willReturn(ResponseDefinition().withLocalJsonBodyFile(fileName: "page1", fileBundleId: bundle.bundleIdentifier!, fileSubdirectory: nil)))

        WiremockClient.postMapping(stubMapping: StubMapping
            .stubFor(requestMethod: .GET, urlMatchCondition: .urlEqualTo, url: "/api/v1/event/?keyword=&start=20&count=10")
            .willReturn(ResponseDefinition().withLocalJsonBodyFile(fileName: "page2", fileBundleId: bundle.bundleIdentifier!, fileSubdirectory: nil)))

        continueAfterFailure = false
        let app = XCUIApplication()
        app.launchArguments.append(contentsOf: ["wiremock"])
        app.launch()
    }

    override func tearDown() {
        super.tearDown()
        WiremockClient.reset()
    }

    func testExample() {
        XCTContext.runActivity(named: "Select first event") { _ in
            let app = XCUIApplication()
            let expectation = self.expectation(description: "wait until to display cell")
            app.cells.element(boundBy: 0).tap()
            expectation.fulfill()
            waitForExpectations(timeout: 30) { (error) in
                XCTAssertNotNil(app.cells.element(boundBy: 0), "cell is not found")
            }
        }
        XCTContext.runActivity(named: "Open event url") { _ in
            let app = XCUIApplication()
            let expectation = self.expectation(description: "wait until to display cell")
            app.cells["event_url"].tap()
            expectation.fulfill()
            waitForExpectations(timeout: 3) { (error) in
                XCTAssertNotNil(app.cells["event_url"], "event url cell is not found")
            }
            app.buttons["Done"].tap()
        }
    }
}

UI Testの実行時のみWiremockのAPIを叩きたいので、app.launchArguments.append(contentsOf: ["wiremock"])で起動時の引数としてwiremockを与え、アプリがAPIをコールする際この引数が与えられていたらhttp://localhost:8080へホストを変更する処理をアプリには加えてあります。

以下のような形です。

if ProcessInfo.processInfo.arguments.contains("wiremock") {
// WiremockのAPIを叩く
} else {
// Connpass APIを叩く
}

こんな感じのイメージですね。
Screen Shot 2017-12-31 at 16.54.18.png

実行結果
api_only.gif

はい、APIのレスポンスは返却されているので画面遷移は問題なく行えました。
しかしOPGの画像が取得でいていないため画面はほぼほぼ真っ白です😅

JSON以外のレスポンスを設定する

では次はOPG画像の表示のためにHTMLと画像ファイルを返すように設定します。
Wiremockは起動時に__filesmappingsディレクトリにあるファイルを読み込むため、その設定を行なっていきましょう。

以下のようにファイルを配置しておくとブラウザでhttp://localhost:8080/event/71358/index.htmlへアクセスする事が出来るようになります。

.
├── __files
│   ├── event
│   │   ├── 12345
│   │   │   └── index.html
│   │   └── 67890
│   │       └── index.html
│   └── thumbs
│       ├── 12
│       │   └── 4e
│       │       └── 124ea69e92f84e570c6bdc1736906cb6.png
│       └── 34
│           └── f2
│               └── 34f2dcb0e2f2b6b23ab08597fee230fa.png
├── mappings
└── wiremock.jar

用意したhtmlファイルと画像についてはJSONレスポンスと同様にCharlesで取得し、ホスト名をhttp://localhost:8080に一括置換して配置しました。

こんなイメージです。
Screen Shot 2017-12-31 at 16.54.21.png

実行結果
mocked_ogp.gif

OPG画像がちゃんと表示されましたね。
これでオフライン時でもきちんと画像がロードされた画面を作ることが出来るようになりました😃

SwaggerによるAPIモックとの比較

最近はSwaggerを使ってのモックサーバの構築の話もよく聞くので簡単に比べてみました。
Swaggerは定義ファイルによってモックサーバとクライアント実装を自動生成してくれるため、Swaggerがサポートするトップダウンの開発時は非常にうまく機能すると思うのですが、今回の例に挙げたような外部APIをモックする場合、Swaggerのドキュメントなどでいわれるボトムアップによるやり方が難しいような気がしていて、そのような場合はWiremockを使うと楽になりそうな気がしています。

雑談

そもそもモックサーバを使ってのUIテストをやろうとしたのは以前好んで使っていたios-snapshot-test-case(https://github.com/facebookarchive/ios-snapshot-test-case )がアーカイブされ、Xcode9から使えなくなった事、XCode9からXCTestが明示的なスナップショットに対応した事などを受け、FBSnapshotTestCaseをforkして使うのも考えたのですが、XCode UI TestingでUIを確認する方法へ切り替えてみようかな?と思った事がきっかけでした。そしてその時イメージしたモックサーバの動きがiOSの単体テストライブラリであるOHHTTPStubs(https://github.com/AliSoftware/OHHTTPStubs) やMockingjay(https://github.com/kylef/Mockingjay) などのようにsetupでモックするレスポンスの設定を行い、teardownでそれを破棄するという形にできないものかと考えて自分でVaporで書き始めてたのですが、そもそもありそうな気がして調べて見つけたのが今回紹介したWireMockでした。概ね求めていた通りの動きでよかったのですが、複数のドメインに対して1つのWiremockサーバーでは対応しきれないという事もわかりましたが、まぁこれについてはモックサーバーの役割がそもそも自前のAPIの代替として使ったりするわけですから当然ですね。(例えばConnpassではグループごとにサブドメインが切られるのでHTMLファイルのモックするため、--files/event/配下に各イベントのHTMLファイルを移動させてあったりします。)

Wiremockについてはまだ多くの機能があるのでこの記事の評判良ければまたなんか書いてみようと思います。

参考文献

https://marcosantadev.com/run-swift-ui-tests-mock-api-server/
https://dev.classmethod.jp/etc/wiremock-practice/
https://kazucocoa.wordpress.com/2014/12/28/mockサーバ比較とwiremockメモ/
https://engineering.linecorp.com/ja/blog/detail/92