Xcode
Swift
UITest
Swift3.0

Xcode 8.0 beta & Swift 3.0 を試しながらiOSのUITestに入門

More than 1 year has passed since last update.


はじめに

今更感ありますが, Xcode 7から登場したiOSのUITestを簡単なアプリを作成して試してみました.

iOSではユニットテストをすることは可能で, カバレッジはそれなりに出せるかとは思いますが, 実機でポチポチやるテストは手動でやる必要がありました.

Viewをデバッグするとき, 軽微な変更なのにいちいちアプリ立ち上げて, ダメだったらまた書きなおして...の反復をやっていると手動では辛くなってきますよね(´・ω・`)

そこで, Xcode 7からはUITestが可能になり, 自動的に画面を操作してくれて見た目や挙動を確認できる仕組みが整備されました.

(WebアプリのUIテストツールseleniumみたいなやつ!)

企業では, QA(Quality Assurance)の人がテスト試験項目書作成して挙動が正しいかどうかをチェックすることがあると思うのですが, UITestによってこの作業も軽減することが出来ると思います(人件費も削減できますね!).

ということで, XcodeのUITestについて例を示しながら説明していきます.

ちなみに自分はテストコードを一切書いたことがありませんのでご了承ください(SwiftではなくRailsのチュートリアルで書いたくらい).


ソースコード

iOS-UITestSampleApp

丁寧に作れませんでした> <

とりあえず挙動だけ知りたい人は, 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に郵便番号を入力・検索ボタンを押すと, その郵便番号に該当する都道府県名・市区町村名・町域名が表示される画面です.

検索結果が合っているかを確認, 加えて非同期処理におけるテストを書いています.


といったラインナップで解説していきます!


テストコードの説明


トップ画面


ログインボタン存在テスト

ログインボタンが存在しているかのテストで, コードは以下の通りです.


WelcomeViewControllerUITests.swift

func testIsExistsLoginButton() {

let app = XCUIApplication()

let loginButton = app.buttons["LoginButton"]

XCTAssertTrue(loginButton.exists)
}


まず, XCUIApplication()とはアプリケーションのプロキシ(代理)のことです.

アプリケーションの情報を持っていて部品を操作することが可能, そしてアプリケーションの起動と終了もしてくれます.

勝手に画面を動かせるのはこのクラスのお陰なのではないでしょうか.

このクラスはElementを持っています.

指定のUIButtonを取得したいときは上記のように,

app.buttons["accessibility identifier"]

と書きます.

ここで, accessibility identifierってなんぞや? という人のために設定方法を紹介します.

LoginButton.png

対象画面のStoryboard(Interface Builder)を開いて, 対象のボタンを選択します.

今回はMain.storyboardLoginButtonを選択します.

画面右のUtilitiesの中にあるIdentity inspector(新聞のアイコンみたいなやつ)を選択すると, 下の方にAccessibilityという項目があります.

ここのIdentifierに, 部品の識別子を設定しましょう.

このようにして部品を取得することが可能です.

XCTAssertTrueはテストでお馴染みのメソッドで, 引数にした値がtrueであればテストが成功になります.

ということで, このテストコードはログインボタンが存在すればテスト成功ということになります.


テスト実行

それではWelcomeViewControllerUITests.swiftfunc testIsExistsLoginButton()の横にある菱型◇を押してみましょう. (もしくは, クラスを定義した一番最初の行あたりにある菱型◇)

TestStart.png

ビルドに成功してテストが走るとシミュレータの画面が立ち上がります.

今回のテストは動いているように見えませんが, 本来勝手に動いて操作してくれます.

ログインボタンが存在していることが確認できたということで, loginButton.existstrueとなり, テストが通るはずです.


ログイン画面


ログイン成功テスト

まずはログインが成功することをテストするコードです.


LoginViewControllerUITests.swift

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で対象のtableViewaccessibility adentifierを設定すればいいのですが, tableViewIdentity inspectorにはAccessibilityの項目が見つかりません.

そんな時は下記画像にあるように, User Defined Runtime Attributesに追加すればいいのです.

SelectFeatureTableView.png

+ボタンを押下して以下の設定をします.


  • Key Path = accessibilityIdentifier

  • Type = String

  • Value = SelectFeatureTableView

これでapp.tables["SelectFeatureTableView"]とすると, 対象のtableViewが取得できます.

続いてその下のlogin()というメソッドですが, これはXCTestCaseextensionしたことで追加したメソッドになります.


LoginViewControllerUITests.swift

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を読み込んだもので, その中のリンクは以下のように設定されています.


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をタップした時のテストです.


LoginViewControllerUITests.swift

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の取り方です.

cellIdentity inspectorにてaccessibility identifierが設定できますので, 識別子を入力しておきます.

app.cells.matching(.cell, identifier: "ReuseIdentifier")

で対象のcellを全て取得できるのですが, 全部はいらないので

.element(boundBy: 0)で0番目のcellを取ってきます.

続いて, app.staticTexts["CalculatorTitle"]ですが, これは画面に設置したタイトル専用のlabelを指します.

CalculatorTitle.png

app.labels["CalculatorTitle"]とかで出来そうな雰囲気ですが, そもそもapp.labelsがありません.

labelを取るときはとりあえずこれで良いと思います.

最後の3行はログイン成功テストと同様に, 「1秒間待ってcalculatorTitleが存在すればテスト成功」となります.


テスト実行

func testSelectedCalculator()の横の菱型◇を押してみましょう.

ログインボタンを押して画面遷移

→「強制的にログイン成功」リンクを押して機能選択画面がモーダルで表示

→ 1つめのcellをタップして機能選択画面が消える

→ 計算画面に遷移

といった感じで動いてくれて, calculatorTitleがそこにあるのでテストは成功します.


郵便番号で地域検索機能選択テスト

次に, 機能選択画面の2番目のcellをタップした時のテストコードです.


LoginViewControllerUITests.swift

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の数が合っているかを確認するテストです.

ここからはさらっといきます笑


SelectFeatureTableViewControllerUITests.swift

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をタップすると本当に画面が消えるのかどうかをテストします.


SelectFeatureTableViewControllerUITests.swift

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乗計算結果が, 予想される答えと一緒ならテストが通ります.


CalculatorViewControllerUITests.swift

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に郵便番号を入力して, 出力された検索結果と予想される検索結果が一緒ならテストが通ります.

ここでは非同期処理がおこなわれます.


PostalCodeSearchViewControllerUITests.swift

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でお送りします.

※ マウスカーソルがないところを見ると, 本当に勝手に動いているのがわかりますね!

TestPostalCodeSearch.gif


番外編


Record UI Test

ここまでUITestで部品を取得したり, 操作手順などを考えて書いてきました.

実はもうちょっと楽にできる方法がありまして, それがRecord UI Testというやつです.

下の画像の一番下に赤い丸が見えますよね.

そしてfunc testRecordUITest()が作成されてますが, 中には何も書かれていません.

RecordUITest.png

そこで, 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をぜひ始めましょう!

また, アドバイスがありましたらコメントよろしくお願いいたします!

それでは〜〜〜