7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

第二のドワンゴAdvent Calendar 2020

Day 5

PageObject パターンを使って XCUITest を書こう

Last updated at Posted at 2020-12-04

dwango2 Advent Calendar 5日目は、昨日に引き続き @daichiro です。
iOS のプロジェクトで UITest を書いてみよう、という趣旨の記事です。

昨日の記事 を読まなくてもなんとなくわかるようになっていますが、できればそちらを読んでからの方がより理解が深まると思いますので、是非。

わかりやすい UITest にしよう

UITest には大きく分けて2つの軸が存在します。

  • ユーザーの操作
  • 表示されている画面

両方を同じドメインに記述すると冗長になり可読性が落ち、何をやっているか分かりづらいテストになってしまいます。
そこで、これらを分けることとします。

今回は PageObject パターンを利用してやってみましょう。

まずは、今回使用するプロダクトコードをご覧ください。

サンプルアプリのコード

import SwiftUI

struct LoginView: View {
    @State private var email: String = ""
    @State private var password: String = ""
    @State private var isLoginTapped = false
    var body: some View {
        NavigationView {
            VStack {
                TextField("e-mail", text: $email)
                    .accessibility(identifier: "cloud.mokumoku.sample.login.email")
                SecureField("password", text: $password)
                    .accessibility(identifier: "cloud.mokumoku.sample.login.password")
                Button("Login") { isLoginTapped = true }
                    .accessibility(identifier: "cloud.mokumoku.sample.login.button")
                NavigationLink(
                    destination: ContentView(email: email),
                    isActive: $isLoginTapped,
                    label: { EmptyView() })
            }
            .padding()
            .navigationTitle("Login")
        }
    }
}

struct ContentView: View {
    @State private var toDetailView = false
    private let email: String
    var body: some View {
        NavigationView {
            VStack {
                Text(email)
                    .accessibility(identifier: "cloud.mokumoku.sample.content.email")
                NavigationLink(
                    destination: DetailView(),
                    isActive: $toDetailView,
                    label: { EmptyView() })
            }
            .navigationBarTitle("Content")
        }
    }

    init(email: String) {
        self.email = email
    }
}

簡単に、入力フォームとボタンがある LoginView、そして LoginView から渡された値を表示するラベルがある ContentView があります。
今回は LoginView から ContentView への画面遷移を確認するテストを書きます。

PageObject パターン

このパターン自体はよく知られているものですので、他に解説されている記事を参照されると良いと思います。

このパターンは UITest と相性が良いと思います。その理由として

  • 今見えているスクリーンを1ページと定義するので、XCUIApplication と関連性が高い
  • UITest の手順をメソッドにすれば、仕様書とメソッド名を関連づけやすい

などがあると思います。

さて、実際にコードを見ていきましょう。
まずは、リファクタリング前のテストコードです。

final class LoginViewUITests: XCTestCase {
    func testLogin() {
        let app = XCUIApplication()
        XCTContext.runActivity(named: "Fill text fields and login") { _ in
            let emailTextField = app.textFields["cloud.mokumoku.sample.login.email"].firstMatch
            emailTextField.tap()
            emailTextField.typeText("hoge@example.com")
            let passwordSecureTextField = app.secureTextFields["cloud.mokumoku.sample.login.password"].firstMatch
            passwordSecureTextField.tap()
            passwordSecureTextField.typeText("password")
            let loginButton = app.buttons["cloud.mokumoku.sample.login.button"].firstMatch
            loginButton.tap()
            XCTAssertTrue(app.staticTexts["Content"].firstMatch.exists, "Login failed")
            XCTAssertTrue(app.staticTexts["cloud.mokumoku.sample.content.email"].firstMatch.exists, "Login failed")
        }
        XCTContext.runActivity(named: "Check content page elements") { _ in
            XCTAssertEqual(app.staticTexts["cloud.mokumoku.sample.content.email"].firstMatch.label, "hoge@example.com", "e-mail is not displayed")
        }
    }
}

まぁこれでもいいと言えばいいのですが、要素へのアクセスが冗長です。
app.cells.children(matching: .staticText).matching(identifier: "hoge.huga").matching(NSPredicate(format: "label = piyo")).firstMatch みたいな地獄のようなクエリを発行する時にはもう大変です。
また、app.buttons["identifier"].tap() という操作は何の意図を持って行われるのかわかりづらいです。
今回はボタン一つタップしたら画面遷移が行われますが、もし今後の変更で遷移する前に余計なダイアログが出たら、それを消す処理を書くためにもっと分かりづらくなってしまいます。

つまり、テストの意図と実際の操作を切り分けたいのです。
そのために PageObject パターンを利用しましょう。

Page プロトコルの作成

ついでなのでテストをする時に便利なプロトコルを作成しておきましょう。
今回はこんな感じで書いてみました。


protocol Page {
    var app: XCUIApplication { get }
    var pageTitle: XCUIElement { get }

    var exists: Bool { get }
}

extension Page {
    var exists: Bool {
        pageTitle.exists
    }
}

まず必要なのは app ですね。
要素にアクセスするために必須です。

次に pageTitle。これはない場合もあるかもしれませんが、今どのページなのかを判断するために最頻出のプロパティです。exists はそれのラッパーで、もしページを表現するのに必須な要素が pageTitle 以外にあれば、個別に実装することができます。

このプロトコルを適用したページを作成してみました。

import XCTest

final class LoginPage: Page {
    let app: XCUIApplication = XCUIApplication()

    var pageTitle: XCUIElement {
        app.navigationBars["Login"].firstMatch
    }

    private var emailTextField: XCUIElement {
        app.textFields["cloud.mokumoku.sample.login.email"].firstMatch
    }

    private var passwordSecureTextField: XCUIElement {
        app.secureTextFields["cloud.mokumoku.sample.login.password"].firstMatch
    }

    private var loginButton: XCUIElement {
        app.buttons["cloud.mokumoku.sample.login.button"].firstMatch
    }

    func typeEmail(_ text: String) -> Self {
        emailTextField.tap()
        emailTextField.typeText(text)
        return self
    }

    func typePassword(_ text: String) -> Self {
        passwordSecureTextField.tap()
        passwordSecureTextField.typeText(text)
        return self
    }

    func login() -> ContentPage {
        loginButton.tap()
        return ContentPage()
    }
}

final class ContentPage: Page {
    let app: XCUIApplication = XCUIApplication()

    var pageTitle: XCUIElement {
        app.navigationBars["Content"].firstMatch
    }

    private var emailLabel: XCUIElement {
        app.staticTexts["cloud.mokumoku.sample.content.email"].firstMatch
    }

    var exists: Bool {
        pageTitle.exists && emailLabel.exists
    }

    var email: String {
        emailLabel.label
    }
}

LoginPage から ContentPage へ遷移するテストを書きたいので、この2ページ分作りました。

各要素へのラッパーを private で宣言し、外向きにはページを操作するメソッドか、要素の有無を判断するプロパティを実装します。
各メソッドは何かしらの Page を返すと後で便利になります。
(@discardableResult を適宜使用してワーニングを回避するのも重要ですね)

これらを使って先ほどのテストを書き直します。

final class LoginViewUITests: XCTestCase {
    func testLogin() {
        XCUIApplication().launch()

        let loginPage = LoginPage()
        let contentPage = ContentPage()
        XCTContext.runActivity(named: "Fill text fields and login") { _ in
            let contentPage = loginPage
                .typeEmail("hoge@example.com")
                .typePassword("password")
                .login()
            XCTAssertTrue(contentPage.exists, "Login failed")
        }
        XCTContext.runActivity(named: "Check content page elements") { _ in
            XCTAssertEqual(contentPage.email, "hoge@example.com", "e-mail is not displayed")
        }
    }
}

すっきりしたのが一目瞭然ですね。
これを実行すると正しくテストが行われます。

また、メソッドで Page を返すようにすると、


loginPage
  .typeEmail("hoge@example.com")  // e-mail を入力して
  .typePassword("password")       // パスワードを入力して
  .login()                        // ログインをする

のように、操作をチェインして書くことができます。
これはテストの仕様書の手順をトレースしやすく、後々にテスト手順が変わったとしてもどこを修正すれば良いかわかりやすいのではないでしょうか。
もちろん、仕様書作成時に自動化することを考えて QA(品証) とすり合わせておくことは必須です。

まとめ

言うまでもなく、プロジェクトにより PageObject パターンがうまくいく場合とうまくいかない場合があるのでよく考えて導入する必要があります。
しかし個人的には iOS 開発においてなかなか効果的だと思ったので今回紹介をしました。

iOS の UITest は圧倒的に情報が足りなく、導入には大きなハードルがあると思いますがぜひ頑張ってやってみてください。

dwango2 Advent Calendar の枠と自分の時間が余っていたら運用編も書くかもしれません

明日は @binzume さんです。

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?