post Image
UIViewControllerをXCTestでUnitテストする

はじめに☀

こんにちは、本年度より新卒でiOSエンジニアをしているはるふ(@_ha1f)です。
2年連続のSwiftアドベントカレンダー📆 で、嬉しいです😌

テストを書くのは大変です。特にUIのテスト🛠は大変ですが、たとえ対象がUIViewControllerであっても、Unitテスト🔨でできる範囲は意外と楽にかけます👨‍🎓

UIテストを使うと UITextField に入力するなど、より実際の操作に近く、複雑なシミュレーションを実現できますが、今回はあくまで小さな処理が正しく動いていることを確認するのに焦点を当てます🔍🔍

※本記事ではViewModelやPresenterの責務とかは特に区別せず、すべてViewControllerのテストとして書きます。

UnitTestはSimulatorの上で動く📱

タイトルの通り、UnitTestを実行した時、iOS Simulatorで動いています。
よって、普段の UIViewController のpresentなども通常通り、動きます💪

表示・レイアウト等もちゃんと実行されます👌👌👌

ViewControllerのテスト📝

テストの対象🤛

どんなテストが必要でしょうか🤖

もちろん単純なのは、各メソッド呼び出しなどで各プロパティ等が変わることを確かめれば良いです👀

ViewController関連のイベントは、以下の3つに分類できると思います。

  • UIイベント -> イベントの発生👆
  • イベントの発生 -> 状態の変化🖖
  • 状態の変化 -> UIへの反映👏

Unitテストの役割はこれら それぞれ が正しいことを保証することだと思います。左側をモック化して、右の変化を確かめます。以下の章では、テストの内容よりも、それらの実装のためのテクニックについて書きます👨‍💻

もちろん、2つ以上またがってのIntegrationテストを実施するのも良いと思います。第一、UIイベント -> イベントの発生は、UIKitを普通に使っている限り、つながらないことはほぼありえないので🔫

UIへの反映のテストは

  • 正しくローカライズされているか🇺🇸🇬🇧🇷🇺🇩🇪🇯🇵🇮🇹
  • 正しく 表示🌝 / 非表示🌚 が切り替えられているか
  • 正しく位置・サイズが変化しているか👻

等があると思います。

TestableなViewController🎳

ViewControllerの処理の中で通信などを行っていると、テストコード側で完了などを知る必要があり、非常にテストしにくいです👨‍✈️🚓

ViewModelを用意して、それをviewにバインドするようにしていると、モックのViewModelを注入して表示に反映させることで、viewModel -> viewのバインドの正しさを確認できます。

通信やDBアクセスといった非同期処理は、clientをモックにすることで、通信 -> viewModelのバインドの正しさを確認できます。

あまり良くない手法かもしれませんが、Testコードの中にテスト対象のViewControllerを継承したクラスを作って、通信処理などをoverrideして書き換えて、そっちをテストするという方法もあります😈😈😈⚡️

ライフサイクルをトリガする👶👦👨👱👴⚰👼🌟

ライフサイクルイベントをコードからトリガすることで、初期化処理などを実行させることができます。

以下の説明において、わかりやすさのため、サンプルとしてライフサイクルイベントをprintするViewControllerを用いています。
実装はGistを参照下さい -> Gist:PrintingViewController.swift

viewのloadとlayoutをトリガする

loadView, viewDidLoad, viewWillLayoutSubviews, viewDidLayoutSubviews に関しては以下のように呼び出すと実行されます。

let viewController = PrintingViewController()

print("call loadView")
// _ = viewController.view でもよい
viewController.loadViewIfNeeded()

print("call layoutIfNeeded")
viewController.view.layoutIfNeeded()

call loadView
lifecycle loadView()
lifecycle viewDidLoad()
call layoutIfNeeded
lifecycle viewWillLayoutSubviews()
lifecycle viewDidLayoutSubviews()
lifecycle deinit

注意点としては、 viewController.loadView() を呼び出すと viewDidLoad() は実行されません🤦‍♀️

また、loadViewviewDidLoad は初めて viewController.view にアクセスするタイミングで実行されます。
これは iOS: The One Weird Trick For Testing View Controllers in Swift などでも紹介されています👩‍🏫

例えば前半をなくすと以下のような挙動になります。

let viewController = PrintingViewController()
print("call layoutIfNeeded")
viewController.view.layoutIfNeeded()

call layoutIfNeeded
lifecycle loadView()
lifecycle viewDidLoad()
lifecycle viewWillLayoutSubviews()
lifecycle viewDidLayoutSubviews()
lifecycle deinit

appearまでトリガする💡

Appear等を実行するには一工夫必要です。よく AppDelegate に書くように、UIWindow を制御します👨‍🎨

let viewController = PrintingViewController()

print("call loadView")
viewController.loadViewIfNeeded()

print("set rootViewController")
UIApplication.shared.keyWindow!.rootViewController = viewController

// viewDidAppearまでには時間がかかるので、少し待つ🐣
let exp = expectation(description: "test")
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
    exp.fulfill()
}

wait(for: [exp], timeout: 3.0)

call loadView
lifecycle loadView()
lifecycle viewDidLoad()
set rootViewController
lifecycle viewWillAppear
lifecycle viewWillLayoutSubviews()
lifecycle viewDidLayoutSubviews()
lifecycle viewDidAppear

rootViewController にセットした時点で、loadView が呼ばれるので、viewDidLoad をテストしたければ先に書く必要があります✈️

layoutSubviews も特段何もしなくても自動的に呼ばれます🐒

viewDidAppear までには時間がかかるので、少し待っています。待たないと、viewDidAppear よりも前にテストが終了します。値に深い意味はありません🙊

dismissまでトリガする⚰️

dismissまでテストするには、present等をする必要がありますので、以下のようにすると実現できます🐶

func testExample() {
    let parentViewController = UIViewController()

    let viewController = PrintingViewController()

    print("set rootViewController")
    UIApplication.shared.keyWindow!.rootViewController = parentViewController

    let exp = expectation(description: "test")
    DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
        parentViewController.present(viewController, animated: false, completion: nil)
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
            print("call dismiss")
            viewController.dismiss(animated: false) {
                exp.fulfill()
            }
        }
    }

   wait(for: [exp], timeout: 3.0)
}

set rootViewController
lifecycle loadView()
lifecycle viewDidLoad()
lifecycle viewWillAppear
lifecycle viewWillLayoutSubviews()
lifecycle viewDidLayoutSubviews()
lifecycle viewDidAppear
call dismiss
lifecycle viewWillDisappear
lifecycle viewDidDisappear
lifecycle deinit

UIパーツの探索🔬

HTMLをスクレイピングする時みたいに、適当に取得します。もちろん公開propertyなら直接使えば良いですが、privateにしたいことも多いと思うので🍕

let button = viewController.view.subviews
    .flatMap { $0 as? UIButton }
    .first(where: { button in
        return button.titleLabel?.text == "OK"
    })!

もちろんtitleなどを使うと簡単ですが、localizeなどで変わってしまうので、特有の型とかが良いと思います🔐

見つからなかったらそれも問題だと思うので、force-unwrapが良いと思います💣

見つけてから、以下のように、色々なテストができますね。もちろん見つからないことのテストもできますね。

// 見つからない🙅
XCTAssertNil(button)

// 見つかる🙆
XCTAssertNotNil(button)

// 非表示🤦‍♀️
XCTAssertTrue(button.isHidden)

// ローカライズが正しい💁
// viewModelとのbindingが正しい
XCTAssertEqual(button.titleLabel?.text, "OK")

その他イベントの発生

UIButton🖲

UIButtonに .touchUpInside などのアクションを送って、イベントをトリガすることができます🎲

button.sendActions(for: .touchUpInside)

UITextField📠

カーソルを当てたり外したりもできます🎯

textField.becomeFirstResponder()

textField.resignFirstResponder()

画面遷移のテスト🔮

以下のコードで、前面にあるViewControllerを取得できます👑👑

extension UIViewController
    static func topViewController(from vc: UIViewController = UIApplication.shared.keyWindow!.rootViewController) -> UIViewController {
        if let tab = vc as? UITabBarController {
            let selectedVC = (tab.selectedViewController ?? tab.viewControllers!.first!)
            return topViewController(from: selectedVC)
        } else if let nav = vc as? UINavigationController {
            return topViewController(from: nav.topViewController!)
        } else if let presented = vc.presentedViewController {
            return topViewController(from: presented)
        } else {
            return vc
        }
    }
}

これで取得できる画面が切り替わっていることを確かめられれば、画面遷移したことを確認できます🎰


let exp = expectation(description: "testTransition")

XCTAssertTrue(UIApplication.topViewController() is FooViewController)

// 何かしらで画面遷移をトリガする

DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
    XCTAssertTrue(UIApplication.topViewController() is BarViewController)
    exp.fulfill()
}

waitForExpectations(timeout: 4.0, handler: nil)

おわりに⛄️

上でも書きましたが、待ち時間は適当な値です。低速なPCなどで実行すると画面遷移などの時に、waitForExpectations がtimeoutしてしまってtestが失敗する可能性もあるかもしれませんが、今までそういうことが問題になったことはないです。少し長めに取るだけで解決できると思うので🐉

UIテストほど障壁は高くないと思います!是非、新年からは、書いてみてください!🎅🏻🎄


『 Swift 』Article List