Posted at

3年以上運用しているアプリにUIテストを導入した #orecon_ios #fastlane_study_jp

More than 1 year has passed since last update.

10/3に開催された俺コン Vol.1 Day.2にて発表した資料です。



UIテストの辛み


  • いきなり初期画面が起動する

  • 毎回要素を検索するのが面倒

  • 遅い

今回は「いきなり初回画面が起動する」点と「毎回要素を検索するのが面倒」な点を改善したいなと思いました。シミュレーターが遅い問題はBluepill等を利用したりお金で解決するのが良いかと思います。



UIテストを導入する為にやった事


  1. アプリ起動時に文字列でViewControllerを指定

  2. UIテストからViewControllerを指定してテスト

  3. ページの要素を整理

  4. UIテストからモックを渡してテスト

下記に具体的に書いていきます。



1. アプリ起動時に文字列でViewControllerを指定

launch_from_string.png

アプリ起動時に文字列でViewControllerを指定して起動出来る様にしました。こちらの方法はiOSDC 2016の@dealforestさんの発表を参考にさせて頂きました。



まず初期画面をStoryboardから開く設定を削除

has_main.png



no_main.png



ViewControllerを指定するキーとオプションを指定するキーを決める

struct LaunchKeys {

static let viewController: String = "LAUNCH_VIEW_CONTROLLER"
static let userInfo: String = "LAUNCH_USER_INFO"
}

ViewControllerを指定するキーとオプションを指定するキーを決めておきます。これはアプリ側のターゲットとUIテストのターゲットそれぞれに追加しておく必要があります。

targets.png



スキームのEnvironment Variablesを編集

scheme.png

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文で毎度振り分けるのも大変なので内部的にStringenumを作るのがおすすめです。また、この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.swiftwindowrootViewControllerを変更

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.swiftwindow.rootViewControllerに渡すViewControllerを開発中かそうじゃないかで判別して設定しました。これでスキームからViewControllerという文字列を受け取ればViewControllerが作成されて起動する処理を書く事が出来ました。



2. UIテストからViewControllerを指定してテスト

launch_from_xctest.png

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
}
}

アプリ側のターゲットにもあるのでややこしいですが別物です。

肝はXCUIApplicationlaunchEnvironmentに渡すenvプロパティを作る所です。ここでアプリ側に渡す値の整理を行なっています。



3. ページの要素を整理

elements.png

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テストからモックを渡してテスト

launch_with_mock_xctest.png


適当なモデルの定義

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というライブラリを作成しました🎉

Logo.png

https://github.com/fromkk/Lunch


  • Launcher

  • Creatable

  • LaunchKeys

  • ViewControllerTestable

  • PageObjectsRepresentable

のみ定義されている小さいライブラリです。使い方はLunchSampleを見ればここまで見て頂いている方なら分かるかと思います。

よかったらスター⭐️ください🙇🏻



UIテストを導入してみて

UIテストを導入してみて感じた事ですが、一気に全部をテストする事はやはり難しいなと感じました。確実に出来る事から少しずつテストを厚くしていくのが良いと思います。また、優先度の高い画面からテストを書いて行くのが良さそうかなと思います。Page objectデザインパターンを利用すればUIテストをTDD的にも書きやすくなるかと思いますのでそういうアプローチも良いと思います。



まとめ

UIテストへのハードルが少し下がりました。UIテストで要素を共通化する事で使い回しが簡単になりました。特定のViewControllerだけ開きたい時にスキーム編集だけでいいのでとても簡単になりました。副次的にインスタンス化する処理を共通化出来ました。

よかったらLunch使ってさらによかったらスター⭐️下さい😇