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 さんです。