LoginSignup
13
18

More than 5 years have passed since last update.

[iOS] [Swift] HTTPサーバーライブラリ"Swifter"を使って、WebViewをUITestする

Last updated at Posted at 2018-11-19

前置き

iOSアプリの開発において、
「サーバーサイドの開発状況に左右されず、実装やユニットテストを進めたい」
というのは、どんなアプリであっても共通課題かと思います。

URLRequestのレスポンスをスタブ化するには、OHHTTPStubsという定番ライブラリがあります。

  • レスポンスbodyとして、Xcodeリソースのjsonファイルを返却してくれたり
  • レスポンスbodyとして、jsonオブジェクト([String: Any]型のオブジェクト)を返却してくれたり
  • レスポンスheaderを設定できたり
  • ステータスコードを設定できたり

通信スタブとして必要なことは概ね揃っている、素晴らしいライブラリです。
README.mdには明記されていませんが、Xcode 10 & Swift 4でも一応利用できています(採用を検討される際はご自身で確認してくださいね!)。

しかし残念ながら、OHHTTPStubsはWebViewの通信のスタブ化には対応していないようです。
WKWebView or UIWebView support? #227

そんな訳で、WebView機能、特にWebView内での特定の操作(例えばURLスキームとか)を制御しなければならない場合、
・サーバーサイドが出来上がらないと実装できないとか
・XCUITestが使えずに手動テストになってしまったりとか

そんな課題を抱えてていました。

本題

色々悩んでたどり着いたのがこちらのOSSライブラリです。
Swifter
https://github.com/httpswift/swifter

  • iOS上にHTTP Serverを立ててくれるライブラリ
  • レスポンスとして、XcodeリソースのHTMLファイルを返却してくれたり
  • レスポンスとして、Swiftで書いたHTMLオブジェクトを返却してくれたり

XcodeリソースのHTMLファイルを返却させることで、サーバーサイドに影響されずにWebView機能の実装/XCUITestが可能になります。

サンプルコード

環境
Xcode 10.1
Swift 4.2

テスト対象コードのサンプル

WKWebViewにWebページを表示し、あるURLスキームがリクエストされた場合はAlertを表示するという、シンプルなものです(実際にそんなことをする必要があるかどうかはさておき)。

サーバーサイドなしではXCUITestを書くことは難しいと思います。

ViewController.swift
import UIKit
import WebKit

class ViewController: UIViewController, WKUIDelegate {

    lazy var webView: WKWebView = {
        let webConfiguration = WKWebViewConfiguration()
        let webView = WKWebView(frame: .zero, configuration: webConfiguration)
        webView.translatesAutoresizingMaskIntoConstraints = false
        webView.uiDelegate = self
        webView.navigationDelegate = self
        return webView
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(webView)
        // WebViewのページロード
        guard let url = URL(string: AppDelegate.urlString) else {
            return
        }
        webView.load(URLRequest(url: url))
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        webView.topAnchor.constraint(equalTo: view.topAnchor, constant: 0.0).isActive = true
        webView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0.0).isActive = true
        webView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0.0).isActive = true
        webView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0.0).isActive = true
    }
}

extension ViewController: WKNavigationDelegate {
    /// ページ遷移
    func webView(_ webView: WKWebView,
                 decidePolicyFor navigationAction: WKNavigationAction,
                 decisionHandler: (WKNavigationActionPolicy) -> Void) {
        guard let naviUrl = navigationAction.request.url else {
            decisionHandler(.cancel)
            return
        }
        if naviUrl.scheme?.contains("hoge") ?? false {
            // URLスキームの場合:例えばAlertを表示してみる
            let alert = UIAlertController(title: "",
                                          message: "URLスキームのリンクがtapされました。",
                                          preferredStyle: .alert)
            let action = UIAlertAction(title: "OK", style: .cancel, handler: nil)
            alert.addAction(action)
            self.present(alert, animated: true, completion: nil)
            decisionHandler(.cancel)
        } else {
            decisionHandler(.allow)
        }
    }
}

Swifterを制御するクラスのサンプル

環境変数に"STUB"というkeyが存在する場合、Swifterを起動し、valueをURLとして使用する、という決め事にしてみました。

StubServer.swift
import Foundation
import Swifter

class StubServer {
    static let shared = StubServer()
    private init() {}

    /// Stubサーバー(Swifter)のインスタンス
    let server = HttpServer()

    /// Stubサーバーを必要としているかの判定と、必要とする場合はStubのURLを取得する
    func getStubInfo() -> (needsStub: Bool, url: String) {
        let env = ProcessInfo.processInfo.environment
        if let value = env["STUB"] {
            // 環境変数に"STUB"というkeyがある場合、STUBサーバーを使う
            return (needsStub: true, url: value)
        }
        return (needsStub: false, url: "")
    }

    /// Stubサーバー(Swifter)を起動する
    func startStubServer() {
        guard let resourcePath = Bundle.main.resourcePath else {
            fatalError("ResourcePath could not get!!")
        }
        server["/:path"] = shareFilesFromDirectory(resourcePath)
        do {
            try server.start(9080)
        } catch {
            fatalError("Swifter could not start!!")
        }
    }

    /// Stubサーバー(Swifter)を停止する
    func stopStubServer() {
        server.stop()
    }
}

AppDelegateでStubサーバーを制御するサンプル

環境変数によって、Swifterを起動させます。

URLの切り替えについては、今回は面倒臭いのでAppDelegateにURL文字列をプロパティとして宣言してしまいました。
plistを環境ごとに用意して、それを管理するclassを定義するなど、もう少し設計を工夫した方がbetterかと思います。

AppDelegate.swift
import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    /// Stubと本番でURLを動的に変更
    class var urlString: String {
        let stubInfo = StubServer.shared.getStubInfo()
        if stubInfo.needsStub {
            return stubInfo.url
        } else {
            return "https://www.apple.com/"
        }
    }

    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Stubサーバー(Swifter)の起動
        if StubServer.shared.getStubInfo().needsStub {
            StubServer.shared.startStubServer()
        }
        return true
    }

    func applicationWillTerminate(_ application: UIApplication) {
        // Stubサーバー(Swifter)の停止
        if StubServer.shared.getStubInfo().needsStub {
            StubServer.shared.stopStubServer()
        }
    }
}

ダミーのHTMLリソースのサンプル

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>WebViewスタブ</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
    <div align="center">
        <h1>スタブのHTMLです。</h1>
        <p><a href=hoge://>URLスキーム</a></p>
    </div>
</body>
</html>

XCUITestのサンプル

環境変数に"STUB"というkeyにURLを設定することで、Swifterを使用するようにしています。

import XCTest

class SwifterResearchUITests: XCTestCase {
    let app = XCUIApplication()

    override func setUp() {
        continueAfterFailure = false
        app.launchEnvironment = ["STUB": "http://localhost:9080/"]
        app.launch()
    }

    override func tearDown() {
    }

    func testExample() {
        XCTContext.runActivity(named: "トップページがWebViewで表示されること") { (activity) in
            let webViewTitle = app.webViews.staticTexts["スタブのHTMLです。"]
            XCTAssertEqual(waitAppearance(for: webViewTitle), .completed)
        }
        XCTContext.runActivity(named: "URLスキームをtapしたらalertが表示されること") { (activity) in
            app.webViews.links["URLスキーム"].tap()
            let alert = app.alerts.staticTexts["URLスキームのリンクがtapされました。"]
            XCTAssertEqual(waitAppearance(for: alert), .completed)
        }
    }
}

extension XCTestCase {
    /// elementの出現を待つ
    func waitAppearance(for element: Any) -> XCTWaiter.Result {
        let exp = expectation(for: NSPredicate(format: "exists == true"), evaluatedWith: element, handler: nil)
        let result = XCTWaiter.wait(for: [exp], timeout: 3.0)
        return result
    }
}

GitHub

13
18
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
13
18