10/3に開催された俺コン Vol.1 Day.2にて発表した資料です。
UIテストの辛み
- いきなり初期画面が起動する
- 毎回要素を検索するのが面倒
- 遅い
今回は「いきなり初回画面が起動する」点と「毎回要素を検索するのが面倒」な点を改善したいなと思いました。シミュレーターが遅い問題はBluepill等を利用したりお金で解決するのが良いかと思います。
UIテストを導入する為にやった事
- アプリ起動時に文字列で
ViewController
を指定 - UIテストから
ViewController
を指定してテスト - ページの要素を整理
- UIテストからモックを渡してテスト
下記に具体的に書いていきます。
1. アプリ起動時に文字列でViewController
を指定
アプリ起動時に文字列でViewControllerを指定して起動出来る様にしました。こちらの方法はiOSDC 2016の@dealforestさんの発表を参考にさせて頂きました。
まず初期画面をStoryboardから開く設定を削除
↓ViewControllerを指定するキーとオプションを指定するキーを決める
struct LaunchKeys {
static let viewController: String = "LAUNCH_VIEW_CONTROLLER"
static let userInfo: String = "LAUNCH_USER_INFO"
}
ViewControllerを指定するキーとオプションを指定するキーを決めておきます。これはアプリ側のターゲットとUIテストのターゲットそれぞれに追加しておく必要があります。
スキームのEnvironment Variables
を編集
Environment Variablesに先ほど作成したキーを追加して、起動したいViewControllerの名前を書いていきます。ここで便利なのは、予め値を登録しておくと、スキームのチェックをON/OFFするだけで起動時のViewControllerが切り替えられる事です。深い画面遷移を辿る必要がなかったり、なかなか遭遇しない画面のテストなんかにも使えるのでかなり便利です。
Creatable
プロトコルを作成
protocol Creatable {
func create<T>(_ identifier: String, userInfo: [AnyHashable: Any]?) -> T?
}
特定の文字列とオプション用のuserInfoからインスタンスを作成出来るCreatableというプロトコルを作成しました。
Creatable
プロトコルを適合したCreator
を作成
struct Creator: Creatable {
func create<T>(_ identifier: String, userInfo: [AnyHashable : Any]?) -> T? {
switch identifier {
case "ViewController":
return viewController() as? T
default:
return nil
}
}
}
extension Creator {
func viewController() -> ViewController {
let storyboard = UIStoryboard(name: "Main", bundle: Bundle.main)
return storyboard.instantiateInitialViewController() as! ViewController
}
}
先ほど作成したCreator
を適合したCreator
というstruct
を作成しました。具体的なインスタンスの作成はここで都度行なっていきます。Identifier
の文字列をif文で毎度振り分けるのも大変なので内部的にString
のenum
を作るのがおすすめです。また、このCreator
を作っておくと、インスタンスの作成方法に迷わなくなります。この画面はStoryboardだったかな、この画面はコードでの生成だったかな、この画面はどのプロパティが必須だったかな、と迷う事があるかと思いますが、そういう事をまとめておく事が出来ます。UIテストに関係無く導入はおすすめ出来ます。いわゆるfactoryデザインパターンです。
Launcher
の作成
struct Launcher {
var creator: Creatable
init(with creator: Creatable) {
self.creator = creator
}
func launch<T>() -> T? {
guard let viewControllerName: String = ProcessInfo.processInfo.environment[LaunchKeys.viewController] else {
return nil
}
var userInfo: [AnyHashable: Any]? = nil
if let userInfoString: String = ProcessInfo.processInfo.environment[LaunchKeys.userInfo],
let userInfoData: Data = userInfoString.data(using: .utf8) {
userInfo = (try? JSONSerialization.jsonObject(with: userInfoData, options: [])) as? [AnyHashable : Any]
}
return creator.create(viewControllerName, userInfo: userInfo)
}
}
ProcessInfo
からViewController
名とオプションの情報を取得してCreatable
に渡してインスタンスを作成します
AppDelegate.swift
でwindow
のrootViewController
を変更
let creator = Creator()
let rootViewController: UIViewController
#if DEBUG
if let viewController: UIViewController = Launcher(with: creator).launch() {
rootViewController = viewController
} else {
rootViewController = creator.rootViewController()
}
#else
rootViewController = creator.rootViewController()
#endif
window?.rootViewController = rootViewController
最後にAppDelegate.swift
でwindow.rootViewController
に渡すViewController
を開発中かそうじゃないかで判別して設定しました。これでスキームからViewController
という文字列を受け取ればViewController
が作成されて起動する処理を書く事が出来ました。
2. UIテストからViewController
を指定してテスト
UIテストからViewControllerを指定してテスト出来る様にしました。簡単にいうと上記のスキームから起動する仕組みをUIテストからキックする事でUIテストから好きな画面を表示出来る様にしたものです。
テスト側のターゲットにもLauncher
を作成
import XCTest
struct Launcher {
var viewControllerName: String
var userInfo: [AnyHashable: Any]?
init(viewControllerName: String, userInfo: [AnyHashable: Any]? = nil) {
self.viewControllerName = viewControllerName
self.userInfo = userInfo
}
var env: [String: String] {
var result: [String: String] = [LaunchKeys.viewController: viewControllerName]
if let userInfo: [AnyHashable: Any] = userInfo {
if let data: Data = try? JSONSerialization.data(withJSONObject: userInfo, options: []),
let userInfoString: String = String(data: data, encoding: .utf8) {
result[LaunchKeys.userInfo] = userInfoString
}
}
return result
}
func launch() -> XCUIApplication {
let app: XCUIApplication = XCUIApplication()
app.launchEnvironment = env
app.launch()
return app
}
}
アプリ側のターゲットにもあるのでややこしいですが別物です。
肝はXCUIApplication
のlaunchEnvironment
に渡すenv
プロパティを作る所です。ここでアプリ側に渡す値の整理を行なっています。
3. ページの要素を整理
UIを貰った段階である程度の要素を整理します。これは何というラベルにしようとか何というボタンにしようとかそういう事ですね。その要素をテストコード側で再現する為にPage Object Design Patternを参考にして要素のリストを作成しました。Page Object Design PatternについてはiOS Test Night #4にて根本さんとPoohSunnyさんが発表してますのでそちらも参照して頂ければと思います。
PageObjectsRepresentable
protocol PageObjectsRepresentable {
var app: XCUIApplication
init(app: XCUIApplication)
}
PageObjectsRepresentable
というプロトコルを作成しました。プロトコルだけだとXCUIApplication
を保持しているだけに見えます。
PageObjectsRepresentable
を適合
struct ViewControllerPage: PageObjectsRepresentable {
var app: XCUIApplication
init(app: XCUIApplication) {
self.app = app
}
var label: XCUIElement {
return app.staticTexts["label"]
}
}
先ほど作成したPageObjectsRepresentable
を適合したViewControllerPage
というstruct
を作成しました。今回はただのlabel
というプロパティがあるだけですが、本来なら更にたくさんのラベルやボタン、イベント等が並ぶと思います。
テストに落とし込む
import XCTest
class LunchViewControllerTests: XCTestCase {
var viewControllerName: String {
return "ViewController"
}
func testLunchLabel() {
let launcher = Launcher(viewControllerName: viewControllerName)
let app = launcher.launch()
let page = ViewControllerPage(app: app)
XCTAssertTrue(page.label.exists)
XCTAssertEqual(page.label.label, "Lunch")
}
}
まずLauncher
を作成してアプリを起動します。起動したらアプリを受け取るので、このアプリをViewControllerPage
に渡してPage Objects
を作成します。テスト自体はこのPage Objects
のプロパティのみに注力すれば良いのです。UIテストは最初何をすれば良いのか難しい所があるのですが、要素の有無のチェックやラベルやボタンのテキスト程度ならテスト出来るのでは無いでしょうか?出来る事から始めて少しずつテストを厚くしていけば良いと思います。
4. UIテストからモックを渡してテスト
適当なモデルの定義
struct HogeModel {
var hoge: String
}
適当なモデルの定義をします。
ViewControllerにモデルを保持
class HogeViewController: UIViewController {
@IBOutlet weak var hogeLabel: UILabel!
var hogeModel: HogeModel {
didSet {
hogeLabel.text = hogeModel.hoge
}
}
}
ViewController
にモデルのインスタンスを持たせて、モデルが渡されたらラベルに値を表示する様なイメージのコードを書きました。
Creatorを拡張
struct Creator: Creatable {
func create<T>(_ identifier: String, userInfo: [AnyHashable : Any]?) -> T? {
switch identifier {
case "HogeViewController":
guard let data = (userInfo?["MOCK_JSON"] as? String)?.data(using: .utf8),
let json: [String: String] = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: String],
let hoge = json["hoge"] else {
return nil
}
let model = HogeModel(hoge: hoge)
return hogeViewController(with: model) as? T
default:
return nil
}
}
}
extension Creator {
func hogeViewController(with hoge: HogeModel) -> HogeViewController {
let viewController = HogeViewController()
viewController.hogeModel = hoge
return viewController
}
}
先ほど作ったCreator
を拡張してuserInfo
で受け取った値からモデルを作成して一緒にViewController
を作成します。
UIテストからJSON文字列をアプリに渡してテスト
func testModel() {
let launcher = Launcher(viewControllerName: viewControllerName,
userInfo: ["MOCK_JSON": "{\"hoge\": \"fuga\"}"])
let app = launcher.launch()
let page = HogeViewControllerPage(app: app)
XCTAssertTrue(page.hogeLabel.exists)
XCTAssertEqual("fuga", page.hogeLabel.label)
}
最後にUIテスト側からJSON文字列をアプリに渡す事で、アプリはこのモックを表示させる事が出来ます。
ここまで作ってみてふと思ったんです。**ライブラリ化出来るんじゃない?**という事で
Lunch
というライブラリを作成しました🎉
https://github.com/fromkk/Lunch
Launcher
Creatable
LaunchKeys
ViewControllerTestable
PageObjectsRepresentable
のみ定義されている小さいライブラリです。使い方はLunchSample
を見ればここまで見て頂いている方なら分かるかと思います。
よかったらスター⭐️ください🙇🏻
UIテストを導入してみて
UIテストを導入してみて感じた事ですが、一気に全部をテストする事はやはり難しいなと感じました。確実に出来る事から少しずつテストを厚くしていくのが良いと思います。また、優先度の高い画面からテストを書いて行くのが良さそうかなと思います。Page objectデザインパターンを利用すればUIテストをTDD的にも書きやすくなるかと思いますのでそういうアプローチも良いと思います。
まとめ
UIテストへのハードルが少し下がりました。UIテストで要素を共通化する事で使い回しが簡単になりました。特定のViewControllerだけ開きたい時にスキーム編集だけでいいのでとても簡単になりました。副次的にインスタンス化する処理を共通化出来ました。
よかったらLunch使ってさらによかったらスター⭐️下さい😇