はじめに
今更感ありますが, Xcode 7
から登場したiOSのUITestを簡単なアプリを作成して試してみました.
iOSではユニットテストをすることは可能で, カバレッジはそれなりに出せるかとは思いますが, 実機でポチポチやるテストは手動でやる必要がありました.
View
をデバッグするとき, 軽微な変更なのにいちいちアプリ立ち上げて, ダメだったらまた書きなおして...の反復をやっていると手動では辛くなってきますよね(´・ω・`)
そこで, Xcode 7
からはUITestが可能になり, 自動的に画面を操作してくれて見た目や挙動を確認できる仕組みが整備されました.
(WebアプリのUIテストツールselenium
みたいなやつ!)
企業では, QA(Quality Assurance)の人がテスト試験項目書作成して挙動が正しいかどうかをチェックすることがあると思うのですが, UITestによってこの作業も軽減することが出来ると思います**(人件費も削減できますね!).**
ということで, XcodeのUITestについて例を示しながら説明していきます.
ちなみに自分はテストコードを一切書いたことがありませんのでご了承ください(Swift
ではなくRails
のチュートリアルで書いたくらい).
ソースコード
丁寧に作れませんでした> <
とりあえず挙動だけ知りたい人は, cloneしてプロジェクト開いてcmd + u
すればよいです.
必須環境
- Mac OS X 10.11.4以降
- Xcode 8.0 beta
- Swift 3.0
※ Swift 3.0
で書かれていることに注意してください! 2.2からそれなりに違いますし, Xcode 8.0 beta
以降でないと動きません!
というかこれを期にのり換えてください
ちなみにXcode 8.0 beta, 結構クラッシュしますw (個人の環境によると思いますが笑)
実装したもの
自分が思いつくままに適当な機能を詰め込みました.
- ログイン(嘘)
- 機能選択UI
- 2乗&根号計算機能
- 郵便番号検索機能
また, UITestの実装が大体つかめるように, 使う必要もないような部品も置いています.
画面の説明を以下に記載していきます.
トップ画面
こちらはログインボタンのみが存在する画面です.
ここではUIButton
がちゃんと存在しているかをテストします.
WebViewでのログイン認証画面
強制的にログインが成功するリンクのみ置いています.
次の画面に遷移 = ログイン成功として, ログインが成功することをテストします.
WebView
を使っているので, WebView
の部品取得も少し紹介します.
また, どの機能が選択されたのかもここでテストすることにします.
モーダルで出現する機能選択画面
2つの機能のどちらかを選択してもらう画面です.
ここではcellがちゃんと2つあることと, 機能選択したら画面が消えることをテストします.
Unwind segue
で戻るので, 上に書いたように機能選択テストはログイン画面でおこないます.
2乗と根号の計算画面
textField
に数値を入力・計算ボタンを押すと, その数値の2乗した値とルートの値を算出する機能が使える画面です.
ちゃんと計算が合っているかをテストします.
郵便番号で地域検索画面
textField
に郵便番号を入力・検索ボタンを押すと, その郵便番号に該当する都道府県名・市区町村名・町域名が表示される画面です.
検索結果が合っているかを確認, 加えて非同期処理におけるテストを書いています.
といったラインナップで解説していきます!
テストコードの説明
トップ画面
ログインボタン存在テスト
ログインボタンが存在しているかのテストで, コードは以下の通りです.
func testIsExistsLoginButton() {
let app = XCUIApplication()
let loginButton = app.buttons["LoginButton"]
XCTAssertTrue(loginButton.exists)
}
まず, XCUIApplication()
とはアプリケーションのプロキシ(代理)のことです.
アプリケーションの情報を持っていて部品を操作することが可能, そしてアプリケーションの起動と終了もしてくれます.
勝手に画面を動かせるのはこのクラスのお陰なのではないでしょうか.
このクラスはElement
を持っています.
指定のUIButton
を取得したいときは上記のように,
app.buttons["accessibility identifier"]
と書きます.
ここで, accessibility identifier
ってなんぞや? という人のために設定方法を紹介します.
対象画面のStoryboard(Interface Builder)
を開いて, 対象のボタンを選択します.
今回はMain.storyboard
のLoginButton
を選択します.
画面右のUtilities
の中にあるIdentity inspector
(新聞のアイコンみたいなやつ)を選択すると, 下の方にAccessibility
という項目があります.
ここのIdentifier
に, 部品の識別子を設定しましょう.
このようにして部品を取得することが可能です.
XCTAssertTrue
はテストでお馴染みのメソッドで, 引数にした値がtrue
であればテストが成功になります.
ということで, このテストコードはログインボタンが存在すればテスト成功ということになります.
テスト実行
それではWelcomeViewControllerUITests.swift
のfunc testIsExistsLoginButton()
の横にある菱型◇を押してみましょう. (もしくは, クラスを定義した一番最初の行あたりにある菱型◇)
ビルドに成功してテストが走るとシミュレータの画面が立ち上がります.
今回のテストは動いているように見えませんが, 本来勝手に動いて操作してくれます.
ログインボタンが存在していることが確認できたということで, loginButton.exists
はtrue
となり, テストが通るはずです.
ログイン画面
ログイン成功テスト
まずはログインが成功することをテストするコードです.
func testLoginSuccess() {
let app = XCUIApplication()
let selectFeatureTableView = app.tables["SelectFeatureTableView"]
login()
// 画面遷移時間考慮
// 機能選択画面への遷移をログイン成功とみなす
expectation(for: Predicate(format: "exists == true"), evaluatedWith: selectFeatureTableView, handler: nil)
waitForExpectations(withTimeout: 1, handler: nil)
}
まずは最初のテストと同様に,
StoryBoard
で対象のtableView
にaccessibility adentifier
を設定すればいいのですが, tableView
の Identity inspector
にはAccessibility
の項目が見つかりません.
そんな時は下記画像にあるように, User Defined Runtime Attributes
に追加すればいいのです.
+ボタンを押下して以下の設定をします.
- Key Path = accessibilityIdentifier
- Type = String
- Value = SelectFeatureTableView
これでapp.tables["SelectFeatureTableView"]
とすると, 対象のtableView
が取得できます.
続いてその下のlogin()
というメソッドですが, これはXCTestCase
をextension
したことで追加したメソッドになります.
extension XCTestCase {
func login() {
let app = XCUIApplication()
let loginButton = app.buttons["LoginButton"]
let loginSuccessLink = app.links["強制的にログイン成功"]
loginButton.tap()
loginSuccessLink.tap()
}
}
ログイン処理などがあるアプリで, 画面ごとにXCTestCase
を継承したクラスを作成したいとなった時, いちいち書くのは面倒なので共通化しておきます.
ちなみにapp.links["強制的にログイン成功"]
というのがwebView
のリンクを取得する処理になります.
このwebView
は, プロジェクト内のindex.html
を読み込んだもので, その中のリンクは以下のように設定されています.
...
<p><a href="native://myapp/login;param?statusCode=200&status=success">強制的にログイン成功</a></p>
...
accessibility identifier
として, aタグで囲んだ文章("強制的にログイン成功")を指定すると取得できます.
そして最後に, 普段コードを書いているうえでは見慣れない表記がありますね.
// 画面遷移時間考慮
// 機能選択画面への遷移をログイン成功とみなす
expectation(for: Predicate(format: "exists == true"), evaluatedWith: selectFeatureTableView, handler: nil)
waitForExpectations(withTimeout: 1, handler: nil)
このテストコードが意味するところは, **「1秒間待ってselectFeatureTableViewが存在していればテスト成功」**といった感じです.
expectation()
はその名の通り, 期待する動作を定義するためのメソッドです.
Predicate
(NSPredicate)とは, CoreData
などで利用され, 検索条件などを設定するためのクラスのようです.
NSArray
にもfitered(using: Predicate)
というメソッドが用意されています.
この点に関しての詳細は今回スコープ外とさせて頂きますので, 調べてみておいてください.
そして, evaluatedWith
には, Predicate
の対象となる部品を指定します.
最後に, waitForExpectations(withTimeout: 1, handler: nil)
で, expectation()
を1秒間待ち, 条件を判定します.
画面遷移を挟んでいる部分なので, これじゃないとテストが通らないことがあったためにこうしました.
非同期の処理にも使える優れものですね!
テスト実行
それではfunc testLoginSuccess()
の横の菱型◇を押してテストを実行してみましょう.
すると今度こそ勝手に画面が動くはずです.
ログインボタンを押して画面遷移
→「強制的にログイン成功」リンクを押して機能選択画面がモーダルで表示される
みたいな感じで動いてくれます.
そこに機能選択のtableView
= selectFeatureTableView
があるのでテストは成功します.
計算機能選択テスト
上でも述べたように「強制的にログイン成功」リンクを押すと, 下から機能選択のためのTableViewController
がモーダルで出てきます.
文字が入っているcell
をタップすると, TableViewController
が消えて, ログイン画面から次の画面に遷移します.
まずは1番目のcell
をタップした時のテストです.
func testSelectedCalculator() {
let app = XCUIApplication()
let cell = app.cells.matching(.cell, identifier: "ReuseIdentifier").element(boundBy: 0)
let calculatorTitle = app.staticTexts["CalculatorTitle"]
login()
cell.tap()
expectation(for: Predicate(format: "exists == true"), evaluatedWith: calculatorTitle, handler: nil)
waitForExpectations(withTimeout: 1, handler: nil)
}
まずはcell
の取り方です.
cell
はIdentity inspector
にてaccessibility identifier
が設定できますので, 識別子を入力しておきます.
app.cells.matching(.cell, identifier: "ReuseIdentifier")
で対象のcell
を全て取得できるのですが, 全部はいらないので
.element(boundBy: 0)
で0番目のcell
を取ってきます.
続いて, app.staticTexts["CalculatorTitle"]
ですが, これは画面に設置したタイトル専用のlabel
を指します.
app.labels["CalculatorTitle"]
とかで出来そうな雰囲気ですが, そもそもapp.labels
がありません.
label
を取るときはとりあえずこれで良いと思います.
最後の3行はログイン成功テストと同様に, 「1秒間待ってcalculatorTitle
が存在すればテスト成功」となります.
テスト実行
func testSelectedCalculator()
の横の菱型◇を押してみましょう.
ログインボタンを押して画面遷移
→「強制的にログイン成功」リンクを押して機能選択画面がモーダルで表示
→ 1つめのcell
をタップして機能選択画面が消える
→ 計算画面に遷移
といった感じで動いてくれて, calculatorTitle
がそこにあるのでテストは成功します.
郵便番号で地域検索機能選択テスト
次に, 機能選択画面の2番目のcell
をタップした時のテストコードです.
func testSelectedPostalCodeSearch() {
let app = XCUIApplication()
let cell = app.cells.matching(.cell, identifier: "ReuseIdentifier").element(boundBy: 1)
let postalCodeSearchTitle = app.staticTexts["PostalCodeSearchTitle"]
login()
cell.tap()
expectation(for: Predicate(format: "exists == true"), evaluatedWith: postalCodeSearchTitle, handler: nil)
waitForExpectations(withTimeout: 1, handler: nil)
}
まー, 計算機能選択テストとほぼ一緒なんで説明は省略しますw
機能選択画面
cell
数一致テスト
続いて, 機能選択画面にあるcell
の数が合っているかを確認するテストです.
ここからはさらっといきます笑
func testSelectFeatureTableViewCellCounts() {
let app = XCUIApplication()
let cells = app.cells.matching(.cell, identifier: "ReuseIdentifier")
login()
// 画面遷移時間を考慮
expectation(for: Predicate(format: "count == 2"), evaluatedWith: cells, handler: nil)
waitForExpectations(withTimeout: 1, handler: nil)
}
見た感じもわかりやすいですね.
Predicate(format: "count == 2")
ということで, cell
の数が2個であればテストが通ります.
テスト実行
省略
画面遷移確認テスト
cell
をタップすると本当に画面が消えるのかどうかをテストします.
func testDismissSelectFeatureTableView() {
let app = XCUIApplication()
let cell = app.cells.matching(.cell, identifier: "ReuseIdentifier").element(boundBy: 1)
login()
cell.tap()
expectation(for: Predicate(format: "exists == false"), evaluatedWith: cell, handler: nil)
waitForExpectations(withTimeout: 1, handler: nil)
}
他のテストとちょっと違っているのは,
expectation(for: Predicate(format: "exists == false"), evaluatedWith: cell, handler: nil)
のところで, 画面遷移した後のことなので, cell
が存在してないということになり, テストは通ります.
テスト実行
省略
計算画面
計算結果一致テスト
textField
に数値を入力して, その値のルート計算結果と2乗計算結果が, 予想される答えと一緒ならテストが通ります.
func testCalculation() {
let app = XCUIApplication()
let cell = app.cells.matching(.cell, identifier: "ReuseIdentifier").element(boundBy: 0)
let textField = app.textFields["NumberTextField"]
let calculateButton = app.buttons["Calculate"]
let rootResult = app.staticTexts["RootResult"]
let squareResult = app.staticTexts["SquareResult"]
login()
cell.tap()
textField.tap()
textField.typeText("256")
calculateButton.tap()
XCTAssertTrue(rootResult.label == "16.0")
XCTAssertTrue(squareResult.label == "65536.0")
}
新しくapp.textFields
が出てきました.
とはいえこれも同様に, accessibility identifier
を設定すれば取ってこれます.
textField.typeText("256")
のところで, textField
に256という数値をタイピングする操作がおこなわれます.
計算は即時反映なので, expectation()
とwaitForExpectations()
を使わなくても, XCTAssertTrue()
で十分です.
テスト実行
省略
郵便番号検索画面
検索結果一致テスト
textField
に郵便番号を入力して, 出力された検索結果と予想される検索結果が一緒ならテストが通ります.
ここでは非同期処理がおこなわれます.
func testPostalCodeSearch() {
let app = XCUIApplication()
let navigationBar = app.navigationBars["PostalCodeSearchNavigationBar"]
let cell = app.cells.matching(.cell, identifier: "ReuseIdentifier").element(boundBy: 1)
let textField = app.textFields["PostalCodeTextField"]
let searchButton = app.buttons["Search"]
let searchResult = app.staticTexts["SearchResult"]
login()
cell.tap()
textField.tap()
textField.typeText("1600022")
searchButton.tap()
XCTAssertFalse(navigationBar.exists)
// 非同期処理を挟むので3秒くらい余裕をとる
expectation(for: Predicate(format: "label == '東京都新宿区新宿'"), evaluatedWith: searchResult, handler: nil)
waitForExpectations(withTimeout: 3, handler: nil)
}
ちょっと関係ないのですが, 計算画面とこの画面ではnavigationBar
が隠されるので, そのテストもおこなっています. 参考までに.
非同期通信が入った場合でも, expectation()
とwaitForExpectations()
を駆使すれば簡単にテストできます.
今回は検索結果が出るまで少々時間があるので, 3秒間待つことにしています.
テスト実行
テストが動いている様子をgif
でお送りします.
※ マウスカーソルがないところを見ると, 本当に勝手に動いているのがわかりますね!
番外編
Record UI Test
ここまでUITestで部品を取得したり, 操作手順などを考えて書いてきました.
実はもうちょっと楽にできる方法がありまして, それがRecord UI Test
というやつです.
下の画像の一番下に赤い丸が見えますよね.
そしてfunc testRecordUITest()
が作成されてますが, 中には何も書かれていません.
そこで, func testRecordUITest()
のスコープの中(47行目)にフォーカスを当てて赤い丸を押すと, アプリのビルド&起動が始まります.
そのままアプリを操作すると, なんと! アクションを起こしたUIの処理が自動生成されます.
どういうことかというと, 例えばログインボタンを押下すると
XCUIApplication().buttons["LoginButton"].tap()
みたいなのが自動生成されるということです.
ちょっとXcode 8.0 beta
の調子が悪くて, webView
でバグってしまうので操作をお見せすることが出来ないのですが, 生成されるテストコード例は以下のとおりです.
func testRecordUITest() {
let app = XCUIApplication()
XCUIApplication().buttons["LoginButton"].tap()
app.links["強制的にログイン成功"].tap() // ここらへんは手動で書きました.
app.tables["SelectFeatureTableView"].staticTexts["Postal Code Search"].tap()
let postalcodetextfieldTextField = app.textFields["PostalCodeTextField"]
postalcodetextfieldTextField.tap()
postalcodetextfieldTextField.typeText("1160002")
app.buttons["Search"].tap()
}
命名がアレですが, 部品の取得と操作が自動生成されました.
メリットはあるのですが, アプリのView
が増えてきたときにこれを実行すると, 結構無駄なネストで部品を取得しようとします
(app.windows.matching(identifier: "hoge").element(boundBy: 0).buttons["hoge"]...
みたいな感じ).
コードの可読性も求めるなら手動でやっていくのも有りかと思います.
webView
などは部品が取りにくいので, 一度Record UI Test
を実行してタップなどをしてみたりすると良いかもしれませんね!
Dispatch
**「x秒後に処理を走らせたい!」**なんてことはよくありますよね!
Swift 2.2
までは以下の記述で可能でした(参考).
let delay = 1.0 * Double(NSEC_PER_SEC)
let time = dispatch_time(DISPATCH_TIME_NOW, Int64(delay))
dispatch_after(time, dispatch_get_main_queue(), {
// 1秒後実行
})
Swift 3.0
ではこの書き方はエラーが起きて, Dispatch~
というクラスが多数作成されているのでそちらを使うことになります. 以下に同じ挙動の例を示します.
DispatchQueue.main.after(when: .now() + 1.0, execute: {
// 1秒後実行
})
スッキリ!
今後Dispatch~
の中身について調べていこうかと思います.
おわりに
ちょっと説明が長くなってしまって申し訳ないです> <
でもiOSにおけるUITestのイメージが大体つかめたんじゃないかと思います.
ユニットテストだけでは出せないカバレッジも出せると思うので, アプリの品質向上のためにもなりますし, 人がやる作業を減らすためにも, UITestをぜひ始めましょう!
また, アドバイスがありましたらコメントよろしくお願いいたします!
それでは〜〜〜