前置き
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を書くことは難しいと思います。
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として使用する、という決め事にしてみました。
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かと思います。
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リソースのサンプル
<!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