iOSのWebViewでUIテストが安定しない - Bitrise / Firebase Test Labで検証してみた

ANDPADでモバイルアプリエンジニアをしている @kanari3333 です。

初日にGitHubのパスワードをど忘れして、社内では本名で活動していましたが、これを機に覚えて頂ければと思います。GitHubも移行していきたいです (規約的にも)

ANDPADのアプリ開発では、UnitTestはもちろんUITestにも力を入れています。
今回は、iOSのUITestを実装していく中で遭遇した辛い事案を紹介したいと思います。

概要

  • iOSのWebViewのUITestは、要素が取得できないことがある
  • Simulatorはテストが安定しない
  • 特に最新のOSで顕著

この記事では検証用のサンプルアプリを作成し、BitriseでSimulator / Firebase Test Labの実機テストを回し検証していきます。

まえおき

ANDPADのiOSアプリでは、CI/CDとしてBitriseをメインに利用しており、
UITestにはFirebase Test Labの実機テストを利用しています。

ある日、社内の検証端末を使いテストを実装し、ローカルで問題無かったのでpushしました。
しかし、Bitriseのテストは何度回しても失敗していました。
ログを追ったり、Firebase Test Labの動画を追ったりしたところ、どうやらWebViewを利用した画面では、iOSのversionによってUIテスト時に要素が正しく取得できないバグがあることが分かりました。

この場合は、iPhoneX (iOS 13.3) では「送信」ボタンが押せるのに、
iPhone11 Pro (iOS 13.1.3) では「送信」ボタンが見つからずテストが落ちるという現象が発生していました。

この件に触れた記事は見つからなかったので、頭の片隅に入れておいて頂ければ幸いです。

影響範囲

UITest時にElementsが参照できないバグがありますが、実際の利用では正しく動作するようです。
テストの信頼性は落ちますが、プロダクトへのダイレクトな影響はないと思います。
(手動テストが必要という意味では影響大ですが…)

原因

XCUITestでは、対象アプリとは別に、XCTRunnerアプリからUIテストを実行しています。
アプリ内部に直接アクセスできない為、画面要素が取得できたり、できなかったりという現象が発生していると考えられます。
iOS SDKのバグですね。versionが上がると直るというより、上がることで不安定になることもある印象です。
AppleはWebViewのUITestを重要視していないということでしょうか。

また、iOS 13.3.1ではiOS 13.3.0で発生していたUITestのバグが修正されました。
developer.apple.com

テストフレームワークの信頼性がないとどうなるか

動的に生成される画面

種別により表示する項目が違う場合というのは多々あります。
テストしたいWebViewの画面が動的に生成されるページだと、想定の要素が表示されていないのか、テストが不安定なのか分からず目視で確認することになります。
(Visual Regression Testでスクリーンショットを比較するという方法もあります)

同じ要素が複数ある

例えば、項目別に「編集」ボタンが表示されていて、ページ内に複数同じボタンが存在するという場合があります。
このとき、app.webviews.buttons["編集"]では要素が特定できません。
app.webviews.buttons["編集"].element(boundBy index) を使うことが多いのですが、
テスト側で正しくボタンが取得できていないと、違う項目の編集画面に飛ぶことになります。


それでは、XcodeやiOSのversionが変化したとき、どの画面要素で差異が出るのかを把握する為にサンプルアプリを作成していきます。

これまでWebViewのUITestを走らせていて失敗することがあると感じた要素は以下の通りです。

Button

ボタン押下テストで使用

TextField

入力文字列の編集テストで使用

TextView

基本はTextFieldと同じですが、複数行対応によりキーボードの確定が改行になり、閉じるのが厄介だったりします。

StaticText

WebViewのチェックボックスは、チェック横の文字列をStaticTextで参照することでtap()で選択可能です。

これらを検証可能なアプリを作成します。

調査範囲

WebView

WKWebViewを対象にします。
UIWebViewはリジェクト対象になったので除くことにします。(2020.5月から)

iOS

WKWebViewの対応がiOS 11.0からなので、iOS 11.0以降を対象にします。
2020.8月段階ではiOS 14.0までテストできそうです。

Xcode

同じiOSのversionでも、使用するXcodeにより動作が違うのでは…という感覚があったので確認します。
ローカルではできるだけ最新のXcodeを使っていきたいので、これは払拭しておきたいです。
2020.8月の段階ではXcode12 beta5が出ており、BitriseでもXcode12.0に対応しています。
Xcode10, 11, 12を対象とします。

端末種別

端末のモデルの違いは要素の検出には影響を感じたことが無いので対象外とします。
記事中でテストにより、モデルが違う場合がありますが、使用できるiOSの種類で仕方なく変更しています。

検証用アプリ作成

先程の点を踏まえて以下のようなアプリを作成します。

f:id:kanari3333:20200821105000p:plain:w300
確認したい要素が並んでいるだけのアプリになります。

サンプルコードは以下に置いてありますのでご覧ください。
github.com

アプリ実装

Xcode10ベースでプロジェクトを作成します。
Xcode11から登場したSceneDelegateがあるとXcode10でビルドできない為です。
実は、この辺の互換性維持で2回プロジェクトをつくりなおしました 😇

WKWebViewを実装し、アプリ内のHTMLをロードします。

import UIKit
import WebKit

class ViewController: UIViewController {
    @IBOutlet weak var webView: WKWebView!

    oversionride func viewDidLoad() {
        super.viewDidLoad()

        if let html = Bundle.main.path(forResource: "WebViewSource", ofType: "html") {
            let url = URL(fileURLWithPath: html)
            let request = URLRequest(url: url)
            webView.load(request)
        }
    }
}

ロードするHTMLは以下のようにしました。

<!DOCTYPE html>
<html>
<head>
  <meta name="viewport" content="width=device-width">
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
  <link rel="stylesheet" type="text/css" href="css.css">
  <meta name="viewport" content="width=device-width,initial-scale=1">
</head>

<body>
  iOS WebView UITest
  <form>
    <p>
      StaticText
    </p>
    <p>
      <input type="button" value="button1">
    </p>
    <p>
      <button type="button">button2</button>
    </p>
    <p>
      <input type=text size="40" value="TextField1">
    </p>
    <p>
      <input type=text size="40" value="TextField2">
    </p>
    <p>
      <textarea cols="50" rows="3">TextArea1</textarea>
    </p>
    <p>
      <textarea cols="50" rows="3">TextArea2</textarea>
    </p>
  </form>
</body>
</html>

Schemeの追加

UITest用のSchemeを作成します。
f:id:kanari3333:20200821104714p:plain

UIテストの実装

import XCTest

class WebviewUITestsCheckerUITests: XCTestCase {

    let app = XCUIApplication()

    oversionride func setUp() {
        continueAfterFailure = false
        app.launch()
    }

    func testTextFields() {
        let textFields = app.webViews.textFields.count
        XCTAssertEqual(2, textFields)
    }

    func testButtons() {
        let buttons = app.webViews.buttons.count
        XCTAssertEqual(2, buttons)
    }

    func testTextView() {
        let textViews = app.webViews.textViews.count
        XCTAssertEqual(2, textViews)
    }

検証用アプリ実行 (ローカル)

各アイテム数のカウントを取得していきます。
手元では、Xcode11.6 iOS12.4だと全てのテストがpassしましたが、
iOS13.3のSimulatorはTextFieldのカウントが0になり失敗しました。

f:id:kanari3333:20200821112053p:plain

うっすら闇が見えてきました。

一応、画面表示後すぐに取得できない場合があるので
XCUIElementに対してwaitForExistence()で見つからなくても5秒は待つようなテストも併設しました。

    func testTextFieldsWithWait() {
        let textFields = app.webViews.textFields.element(boundBy: 1).waitForExistence(timeout: 5)
        XCTAssertTrue(textFields)
    }

    func testButtonsWithWait() {
        let buttons = app.webViews.buttons.element(boundBy: 1).waitForExistence(timeout: 5)
        XCTAssertTrue(buttons)
    }

    func testStaticTextsWithWait() {
        let staticTexts = app.webViews.staticTexts["StaticText"].waitForExistence(timeout: 5)
        XCTAssertTrue(staticTexts)
    }

    func testTextViewWithWait() {
        let textViews = app.webViews.textViews.element(boundBy: 1).waitForExistence(timeout: 5)
        XCTAssertTrue(textViews)
    }

これで「待てば取得できるがすぐには表示されない可能性がある」場合を検出できるようになりました。

f:id:kanari3333:20200821121915p:plain

この結果の場合、withWaitの方は全て成功しているので成功とみなしてOKです。
想定通りの動作なので、テストから待たない場合を削除しておきます。

Bitrise

f:id:kanari3333:20200821105538p:plain

なぜCIを使うのか?

既にCI/CDが定着し過ぎて、具体的な理由が漠然としているのではないでしょうか。一般的な視点は(つ´∀`)つ置いといて、今回の観点に絞りあらためておさらいします。

Xcode

Xcodeには起動可能なOSの制限があります。
例えばmacOS 10.15.6 Catalinaでは、Xcode9.4.1や10.0は起動しませんでした。

SSDの容量を圧迫する

Xcodeは11系で16GB、12beta4で27GB使っていました。細かいversion違いまではさすがに入れておけないです。
Simulatorのversionもイメージをダウンロードするとそれなりの容量になるのでBitrise上で気軽に試せるのは助かります。

所有していない端末で検証可能

端末を多く揃えるのはコストが掛かります。
BitriseはFirebase Test Labと連携して実機テストを行うことができます。
Firebase Test Labは、ローカルからも実行できるので用途に応じて使い分けています。

誰でも検証できる

個人の環境に影響されずにテストが実施可能です。
これが一番大きいですね。

Workflow

ベーシックに組み、デバイスの選択の箇所以外を共通化しました。

f:id:kanari3333:20200821105832p:plain:w300

BitriseのUITestのセットアップは以下がリファレンスとなります。
https://devcenter.bitrise.io/jp/testing/running-xcode-tests/

まずは、「Xcode Test for iOS」を使用し、Simulatorでテストします。

f:id:kanari3333:20200821111117p:plain


しかし、このステップだと、どのversionのiOSが選択可能かが分かりません.

f:id:kanari3333:20200821105943p:plain

そこでScriptステップに以下を記載しておきます。

# xcodeのversion (xcode-testステップでも出力されます)
xcodebuild -version
# 利用可能なSimulator一覧を出力
instruments -s devices

Xcode11.3のSimulator一覧にはiOS11.0.1, 12.0が含まれていますが、
Xcode11.6では13.0以降しか含まれていなかったりするので毎回出しておくと便利です。

出力はこのようになります。

+ xcodebuild -versionsion
Xcode 10.3
Build versionsion 10G8
+ instruments -s devices
Known Devices:
iPhone 8 (11.0.1) [B7A24D53-33DC-42D1-B12F-74F70DE6FED5] (Simulator)
iPhone 8 (11.1) [F22CA5BB-2A45-4D3E-BAD9-F330DBE8229B] (Simulator)
iPhone 8 (11.2) [E951C4B7-3D18-40E8-A546-0EF4E557F87D] (Simulator)
iPhone 8 (11.3) [E97F64EC-1D09-41E4-958F-0CC87DDE9CA6] (Simulator)
iPhone 8 (11.4) [A2F0B36C-C4BB-473A-BF30-27195DED221B] (Simulator)
iPhone 8 (12.0) [E6636952-E492-45D1-BF08-6A8B4E001764] (Simulator)
iPhone 8 (12.1) [F17B5890-3A35-4A72-B401-7D3BDB32D3D0] (Simulator)
iPhone 8 (12.2) [E9102C7A-BA16-4D26-A388-0B2466F9306C] (Simulator)
iPhone 8 (12.4) [C55C04CE-55C6-4242-BE9B-0E243E80B3AF] (Simulator)

Simulatorのテスト結果

失敗したテストのみを抽出した表が以下になります。失敗したテストのみ2回チェックしています。
※視認性が悪くなったのでPASSは記載せず、N/AとNGのみ記載してあります。

  • 縦軸:Xcodeのversion
  • 横軸:SimulatorのiOS version

f:id:kanari3333:20200823190529p:plain

確認できたこと

  • Xcodeのversionにより差があるように見える (そんなことないと思いたいのですが...)
  • iOS 13.3はテストが通らない可能性が高い、全てのテストで失敗していました。
  • Xcode12.0とiOS12.1の組み合わせについて、規則性が無く、StaticText / Button / TextFieldでランダムに失敗していた。(このパターンが一番厄介そうです)

手元のメモだとiOS 12.1がほとんど通らないとあったのですが、今回確認したところそこまで結果は悪くありませんでした。
また、この段階で正常系のみで153個テストを回したのでいくら自動とはいえ疲弊しました。

Bitriseのプラン

個人のBitriseのFreeプランの上限である200ビルドを超えたので、$40のDeveloperプランにしたのですが、アップグレードの誘導ではなくPrice一覧からたどれば「14日間お試し」のリンクが存在していることに気付きました 😇
www.bitrise.io

実機テスト

f:id:kanari3333:20200821111814p:plain

Firebase Test Lab等で、実機テストを行います。

「iOS Device Testing」ステップを利用します。
これはBitriseからFirebase Test Labで実機テストを実行してくれるステップで
本来必要なTokenなどの認証を設定しなくても良い便利なものです。料金も無料です。

f:id:kanari3333:20200821112717p:plain
「Xcode Build for testing for iOS」ステップと組み合わせて使用します。

なぜFirebase Test Labを使うのか

Bitriseの「Xcode Test for iOS」だと、Stackで選択したXcodeに含まれているSimulatorしか選択できない為、
Firebase Test Labだと、ビルドに使用するXcodeのversionに関係なくデバイスファーム側で用意されている実機を指定できます。
また、Simulatorと実機の動作の違いがないか確認したい気持ちもあります。

f:id:kanari3333:20200821112808p:plain

こちらはどのデバイスが選択可能かステップから見ることができます。

しかし... @tarappo さんからのコメント
「そのデバイス一覧はハードコードだからホントとは限らないよ」

最新の状況が知り合い場合は、gcloudコマンドから以下で確認できます。

gcloud firebase test ios models list

実機テストの結果

テスト結果は以下の通りです。
errはInternal errorもしくはtimeoutで、非対応等の理由でテストが実施できなかったものになります。
f:id:kanari3333:20200824004556p:plain

  • 利用可能なiOSが少ないのでテストは楽でした。(iOS 11.2、11.4、12.0、12.1、12.3、13.2、13.3)
  • 最新版への対応では、iOS13.2がiPhone XRで、iOS13.3がiPhone 11 / Proで利用可能になっています。
  • Xcodeによるテスト結果への影響はなさそうです。
  • Xcode 12.0とiOS 13.2の組み合わせでは、TextViewが取得できない場合がありました。
  • iOS 13.3はXcode12.0でしか動作しませんでしたが、全てのテストが失敗していました。

まとめ

WebViewのUITestが期待通りに動かない場合がある。ということが分かりました。
傾向としては、最新のOSだと多い印象です。

ただ、Simulatorで上手く動作しなくても実機では動作するパターンが多いということが分かったので、Firebase Test Lab等で検証しておけば、ひとまずそちらを正として良さそうです。
また、iOS 13.3については、Simulator / 実機共にWebViewのUITestは正しく動作しない。と考えてください。

UITestは、複数台の端末で動かしておきたい場合というのが多々あると思うので、
テストが失敗する場合に思い出して頂ければ幸いです。

補足

実は、WebView以外も不安定

ANDPAD技術顧問の @tarappo さんが、ios_ui_test_sandboxというリポジトリを作成されています。カウントだけでなく、Exists・Hittableの検証も行っているので興味がある方は見てください。
github.com

さいごに

ANDPADでは、一緒に働く仲間を募集中です!
engineer.andpad.co.jp