🍎

iOSネイティブアプリにDetoxによるE2Eテストを導入

に公開

こんにちは、QAの木下です。
この記事では、ネイティブモバイルアプリのE2Eテストの自動化に挑戦した話について、紹介します。

モバイルアプリに対して、テストの自動化を目指した経緯

テスト自動化の現状と課題

ウェルスナビでは、会社の成長とともに、取り扱うプロダクトの数が増加しています。
QAチームは、プロダクト数の増加を受けて、チームメンバー増強の採用活動を強化するとともに、テストの省力化・効率化を目的とした自動化に注力してきました。
現在は、Webサイトに対するE2Eテストを行えるテストツールPlaywrightを導入し、膨大なテストケースを省力かつ高速に実行し、不具合を早期に検知でき、テスト実行の自動化の効果を実感しています。
一方、モバイルアプリは、これまで商用サービスのE2Eテストツールを採用していたものの、すべての業務に対するテストケースを作成できず、またテスト実行でflakyさが発生し、テストの省力化・効率化に寄与できていませんでした。
モバイルアプリのテストは、従来通り、人の手によるテストを継続していたため、プロダクト数増加に耐えられる課題が残ったままでした。

QAチームのさらなる成長

それとは別に、ウェルスナビのQAチームは、社員の増加とテスト自動化の技術力が向上し、Playwrightのテストケース編集を複数名で行える体制へと成長しました。
その結果、より難易度が高い案件に着手できる体力が蓄積され、モバイルアプリのテスト省力化・効率化のために、新たにテスト自動化プロジェクトを開始しました。

モバイルアプリのE2Eテストツールの検証

まず、モバイルアプリのE2Eテストツールの選定を行いました。選定条件の一つとして、Playwright採用時と同じく、ベンダーロックインとならないツールを列挙して比較検証しました。
検証の結果、 Detox を採用しました。 今回比較検証したツールの一覧を以下に示します。

E2Eツールの比較表

Detoxを採用した決め手

Detoxは、以下の6点の強みを持ち合わせ、高速かつ効率的、安定的にテスト実行できる点が非常に優良と考えました。

ネイティブアプリのUIテストフレームワークによる安定実行

Detoxは、Googleが長年開発と改善を進めてきたUIテストフレームワークのEspressoとEarlGreyのアーキテクチャを利用しているため、堅牢性や保守性に優れています。
EspressoやEarlGreyは、モバイルアプリのUIの状態に応じて非同期処理を行え、操作のタイミングずれによるテスト失敗を削減できます。またEarlGreyやEspressoは、強力なUI要素検索を兼ね備え、MatcherがIDによる検索だけでなく、文言や型、親子関係による特定も実現しています。

WebSocketによる高速通信

Detoxは、WebDriverによる一方向通信ではなくWebSocketを利用した双方向通信が行え、モバイルアプリとホストであるDetox間でリアルタイムにデータを送受信できます。
そのためDetoxは、非同期イベントの待機にポーリングが不要で、非同期イベントが解決された最適なタイミングで端末操作を行えます。

グレーボックステストによるエラー原因特定の容易さ

Detoxは、グレーボックステストを採用しています。そのためテスト実行では、モバイルアプリの内部状態を追跡でき、エラーが発生した際にUIまたはロジックのどちらに原因があるか特定できます。
またグレーボックステストは、ユーザー視点でのUI操作と開発者視点での内部状態の両方に焦点を当てたテストケースを作成できるため、エンドユーザーの体験を重視しながら効率的に不具合を特定できます。

スクリプト言語によるテストケースの柔軟性の高さ

Detoxは、スクリプト言語としてJavaScriptやTypeScriptを採用しているため、Detoxにない機能を自由に追加拡張できます。
そのためDetoxは、制約なくテストケースを作成でき、テストケースのカバレッジの拡大につながり、不具合の検出件数を向上できます。

Playwrightと同じ概念による学習コストの低さ

Detoxは、要素検索、要素操作やアサーションの概念がPlaywrightと同じであるため、Playwrightの習得で得た経験を活かせます。
そのためDetoxは、新たな言語習得の負荷が少なく、Webとモバイルアプリ両方でテストケースの作成、運用保守を低負荷で行えます。

BitriseによるCI/CDの実現容易さ

Detoxは、BitriseによるCIが標準で用意されています。そのためウェルスナビでは、モバイルアプリのCI/CDで利用しているBitriseの環境をそのまま流用できることが利点でした。
ウェルスナビでは、ステージング環境への配信前にCIとしてテスト実行することで、QAチームでの統合テスト実施前に不具合の検出が行え、QAチームの工数を削減できます。

Detoxの導入

それでは、ウェルスナビのネイティブiOSアプリを対象とした、Detox導入までの流れについて紹介します。

プロジェクトの構成

まず、公式サイトに従い、XCodeのリポジトリにDetox関連のファイルを用意します。
ウェルスナビにおける、Detoxのテストケースを含むディレクトリ構成は、以下のとおりです。

.
├── .detoxrc.js #Detoxの設定
├── <XCodeのワークスペース名>.xcworkspace #テスト対象のワークスペース
├── node_modules #パッケージ管理システムnpmの`--save-dev`オプションでインストールされたパッケージ
├── Build #テスト対象のビルドされたiOSモジュール
│   └── Products
│       └── Debug-iphonesimulator
│               └── <プロジェクト名>.app
└── e2e #Detoxのテスト対象
    ├── jest.config.js #jestの設定
    ├── model
    ├── page #POMで取り扱う画面単位のテストケース
    │   ├── _abstract_common_page.ts
    │   ├── _abstract_hardware.ts
    │   └── <業務名>_<画面名>_page.ts
    ├── service #ビジネスロジック
    │   └── device.ts
    └── tests #業務単位でのテストシナリオ
        └── <業務名>.test.ts

TypeScript関連のパッケージをインストール

次に、DetoxのテストケースをTypeScriptで作成できるよう、公式サイトの記事を元に、TypeScriptts-jestのパッケージをインストールします。

プロジェクトファイルの設定

そして、以下のとおり、Detoxとjestの設定ファイルを用意します。

./.detoxrc.js と ./e2e/jest.config.js
./.detoxrc.js
/** @type {Detox.DetoxConfig} */
module.exports = {
  // テストランナーの設定
  testRunner: {
    args: {
      '$0': 'jest',
      config: 'e2e/jest.config.js'
    },
    jest: {
      setupTimeout: 120000
    }
  },
  // モバイルアプリのビルド設定
  apps: {
    'ios.debug': {
      type: 'ios.app',
      binaryPath: 'DerivedData/Build/Products/Debug-iphonesimulator/<プロジェクト名>.app',
      build: 'xcodebuild -workspace ./<ワークスペース名>.xcworkspace -scheme <スキーム名> -configuration Debug -derivedDataPath DerivedData -clonedSourcePackagesDirPath SourcePackages -destination "generic/platform=iOS Simulator" CODE_SIGN_STYLE=Manual -skipPackagePluginValidation TARGET_DEVICE_PLATFORM_NAME=iphonesimulator clean build "CODE_SIGN_IDENTITY=Apple Distribution: <証明書ID>" | xcbeautify',
    }
  },
  // シミュレーターの指定
  devices: {
    simulator: {
      type: 'ios.simulator',
      device: {
        type: 'iPhone 17'
      }
    }
  },
  // レポーターの設定
  artifacts: {
    rootDir: 'artifacts',
    plugins: {
      instruments: {
        enabled: false,
      },
      log: {
        enabled: true,
      },
      uiHierarchy: 'enabled',
      screenshot: {
        shouldTakeAutomaticSnapshots: true,
        keepOnlyFailedTestsArtifacts: true,
        takeWhen: {
          testStart: false,
          testDone: true
        }
      },
      video: 'all',
    }
  },
  // モバイルアプリのモジュールと端末の指定
  configurations: {
    'ios.sim.debug': {
      device: 'simulator',
      app: 'ios.debug'
    }
  }
};
./e2e/jest.config.js
/** @type {import('@jest/types').Config.InitialOptions} */
module.exports = {
  preset: 'ts-jest',
  rootDir: '..',
  testMatch: ['<rootDir>/e2e/**/*.test.js', '<rootDir>/e2e/**/*.test.ts'],
  testTimeout: 120000,
  maxWorkers: 1,
  globalSetup: 'detox/runners/jest/globalSetup',
  globalTeardown: 'detox/runners/jest/globalTeardown',
  reporters: ['detox/runners/jest/reporter'],
  testEnvironment: 'detox/runners/jest/testEnvironment',
  verbose: true,
};

Detoxでテストケース作成

共通処理の準備

Detoxでは、Detoxに用意されている機能だけでも多くのテストケースを作成でき、安定的に実行できます。 一方、Detoxを補完する機能を用意することで、テストケース作成の省力化と、テスト実行のより安定化を実現できます。
その機能の一部について、以下に紹介します。

シミュレーターの起動・終了処理

./e2e/service/device.ts
./e2e/service/device.ts
/**
 * シミュレーターを起動します
 */
export async function launchSimulator(): Promise<void> {
    await device.launchApp({
        delete: true,
        newInstance: true,
        languageAndLocale: {
            language: "jp-JP",
            locale: "ja-JP",
        },
        launchArgs: {
            /**
             以下のエラーが表示されアプリが進行しないとき、URLが計測系の外部APIやstaticファイルなど、アプリの挙動に直接影響がない場合にblack list入りさせる
             // i ws-client:APP_STATUS The app is busy with the following tasks:
             // • The event "Network Request" is taking place with object: "URL: “https://hogehoge.com/foo”".
             // • There are X work items pending on the dispatch queue: "Main Queue (<OS_dispatch_queue_main: com.apple.main-thread>)".
             // • Run loop "Main Run Loop" is awake.
             */
            detoxURLBlacklistRegex: '\\('
                + '"https://firebaseremoteconfigrealtime.googleapis.com/.*"'
                + ', "https://hogehoge.com/.*"'
                + ', "https://fugafuga.com/.*"'
                + '\\)',
            detoxDebugVisibility: 'YES',
        },
        permissions: {
            camera: 'YES',
            photos: 'YES',
            userTracking: 'NO',
            notifications: 'NO',
            faceid: 'NO',
        },
    });
}

/**
 * シミュレーターを終了します
 */
export async function terminateSimulator(): Promise<void> {
    await device.terminateApp();
}

端末操作の共通処理

./e2e/page/_abstract_hardware.ts
./e2e/page/_abstract_hardware.ts
/**
 * 端末の抽象クラス
 */
export abstract class AbstractHardware {
    /**
     * キーボードの完了ボタン
     * @private
     */
    private readonly doneKeyLocator: Detox.NativeElement = element(by.text(/^完了$/));
    /**
     * スクロールビュー
     * (※ 本指定では、モバイルアプリのScrollViewのaccessibilityIdentifierに「scrollView」を事前に割り振る必要がある)
     * @private
     */
    private readonly scrollView: Detox.NativeMatcher = by.id('scrollView');

    /**
     * キーボードの「完了」が表示されていた際にタップする
     * @protected
     */
    protected async tapOnDoneKeyIfExists(): Promise<void> {
        try {
            await waitFor(this.doneKeyLocator).toBeVisible().withTimeout(2 * 1000);
            await this.doneKeyLocator.tap();
        } catch (error) {
            return;
        }
    }

    /**
     * 要素までスクロールする
     * @param args - スクロールに必要な要素
     * @protected
     */
    protected async scrollToElement(args: scrollArg): Promise<void> {
        await waitFor(args.element).toBeVisible()
            .whileElement(args.scrollView === undefined ? this.scrollView : args.scrollView)
            .scroll(args.scrollVolume ?? 1000, args.scrollDirection ?? 'down', NaN, args.scrollStartPositionY ?? NaN);
    }

    /**
     * デバイスのスクリーンの座標から要素の位置を特定し、タップ操作を行います
     * (※ SwiftUIなど、Detoxで要素の座標が正確に特定できない場合に、直接端末の座標をしてタップ操作を行うために使用します)
     * @param {Object} args - element: 画面の要素、index: (要素が配列の場合)タップしたい要素のindex、coordinate: タップしたい座標
     * @returns {Detox.IosElementAttributes} タップ対象の要素
     * @protected
     */
    protected async tapUsingDevice(args: {
        element: Detox.IndexableNativeElement,
        index?: number,
        coordinate?: Detox.Point2D
    }): Promise<Detox.IosElementAttributes> {
        let attr: Detox.IosElementAttributes;
        let frame: Detox.IosElementAttributeFrame;
        let point2d: Detox.Point2D;
        const attributes: Detox.IosElementAttributes | Detox.AndroidElementAttributes
            | { elements: Detox.IosElementAttributes[] } | { elements: Detox.AndroidElementAttributes[] }
            = await args.element.getAttributes();
        if (this.isDetoxIosElementAttributes(attributes)) {
            attr = attributes;
            frame = attributes.frame;
        } else if (this.isDetoxIosElementAttributesArray(attributes)) {
            if (args.index === undefined) {
                throw new Error('index is undefined');
            }
            attr = attributes.elements[args.index];
            frame = attr.frame;
        } else {
            throw new Error('element is invalid type');
        }
        if (args.coordinate === undefined) {
            point2d = {x: Math.floor(frame.x + frame.width / 2), y: Math.floor(frame.y + frame.height / 2)};
        } else {
            point2d = args.coordinate;
        }
        await device.tap(point2d);
        return attr;
    }

    /**
     * iOSElementAttributesかどうかを確認します
     * @param value - 型チェック対象の要素
     */
    private isDetoxIosElementAttributes(value: unknown): value is Detox.IosElementAttributes {
        if (typeof value !== "object" || value === null) {
            return false;
        }
        const {hittable} = value as Record<keyof Detox.IosElementAttributes, unknown>;
        return typeof hittable === 'boolean';
    }

    /**
     * { elements: Detox.IosElementAttributes[] }かどうかを確認します
     * @param value - 型チェック対象の要素
     */
    private isDetoxIosElementAttributesArray(value: unknown): value is { elements: Detox.IosElementAttributes[] } {
        if (typeof value !== "object" || value === null) {
            return false;
        }
        const {elements} = value as Record<keyof { elements: Detox.IosElementAttributes[] }, unknown>;
        return Array.isArray(elements);
    }
}

/**
 * スクロール引数
 */
export type scrollArg = {
    /**
     * 表示させたい要素
     */
    element: Detox.NativeElement;
    /**
     * スクロールビュー
     */
    scrollView?: Detox.NativeMatcher;
    /**
     * スクロール量
     */
    scrollVolume?: number;
    /**
     * スクロール時の画面上の相対的なタップ位置
     */
    scrollStartPositionY?: number;
    /**
     * スクロール方向
     */
    scrollDirection?: Detox.Direction;
}

画面操作の共通処理

./e2e/page/_abstract_common_page.ts
./e2e/page/_abstract_common_page.ts
import {expect} from 'detox';
import {AbstractHardware} from "./_abstract_hardware";

/**
 * 画面共通の抽象クラス
 */
export abstract class AbstractCommonPage extends AbstractHardware {
    /**
     * karteのWebView
     * @private
     */
    private readonly karteWebView: Detox.WebViewElement = web(by.type('KarteInAppMessaging.IAMWebView'));
    /**
     * karteの閉じるアイコン
     * @private
     */
    private readonly closeKarteMatcher: Detox.WebMatcher = by.web.cssSelector('[class*="karte-close"]');

    protected constructor() {
        super();
    }

    /**
     * 要素が表示されるまで待機する
     * @param element - 表示確認対象の要素
     * @param maxWaitTime - 最大待機時間
     */
    protected async waitForElementWhetherToExist(element: Detox.NativeElement | Detox.WebElement, maxWaitTime: number = 5000): Promise<boolean> {
        const startTime: number = Date.now();
        while (Date.now() - startTime < maxWaitTime) {
            try {
                await expect(element).toExist();
                return true;
            } catch (error) {
                if (Date.now() - startTime >= maxWaitTime) {
                    break;
                }
                await new Promise(resolve => setTimeout(resolve, 100));
            }
        }
        return false;
    };

    /**
     * karteのダイアログが表示された場合に閉じる
     * @protected
     */
    protected async closeKarteIfExists(): Promise<void> {
        const karteElement: Detox.WebElement = this.karteWebView.element(this.closeKarteMatcher);
        const existsKarte: boolean = await this.waitForElementWhetherToExist(karteElement);
        if (existsKarte) {
            await karteElement.tap();
            await expect(karteElement).not.toExist();
        }
    }
}

テストケース作成のtips

Matcherの活用

Detoxでは、モバイルアプリの要素に対して、一意となるIDを割り振らずに要素の一意指定ができます。 モバイルアプリのUI階層が、以下の場合に、特定のラベル「AAA」に対する割合を取得するMatcherを考えてみます。

<UIView: 0x3e0034390; >\
| <UIStackView: 0x3e00341e0; >\
|    | <hogehogeView: 0x3aff0aaf0; >\
|    |    | <UIView: 0x1512b7e70; >\
|    |    |    | <UIStackView: 0x1512603d0; >\
|    |    |    |    | <UILabel: 0x1512600b0; text = \\\"8.5%\\\"; ax.label = \\\"8.5%\\\"; >\
|    |    |    | <UILabel: 0x1512fafe0; text = \\\"BBB\\\"; ax.label = \\\"BBB\\\"; >\
|    | <hogehogeView: 0x393a74cf0; >\
|    |    | <UIView: 0x3aff0a3d0; >\
|    |    |    | <UIStackView: 0x3aff0a560; >\
|    |    |    |    | <UILabel: 0x3aff0a0b0; text = \\\"25.4%\\\"; ax.label = \\\"25.4%\\\"; >\
|    |    |    | <UILabel: 0x3aff09750; text = \\\"AAA\\\"; ax.label = \\\"AAA\\\"; >\

上記の実例では、以下の方法で、特定のラベル「AAA」に対する「割合」の要素を取得できます。

element(by.type('UILabel').and(by.text(/^[0-9]{1,2}\.[0-9]%$/))
    .withAncestor(by.type('hogehogeView').withDescendant(by.label('AAA'))));

Detoxでは、特定の要素の型や文言に対する指定だけでなく、親子の要素を参照した要素指定ができます。
そのためDetoxのテストケースは、様々な要素指定の方法を活用することで、モバイルアプリのコードに手を加えることなくテストケースを作成できます。

Assertionの拡張

Detoxのテストランナーは、標準でjestを利用しているため、Detoxに用意されていないアサーションをjestを用いて実現できます。

import {expect as jestExpect} from '@jest/globals'; // jestのバージョンが29.5以上の場合

async function assertTrue(): Promise<void> {
    const text: string = (await element(by.type('UILabel')).getAttributes() as Detox.IosElementAttributes).text!;
    // Detoxのアサーションで用意されている`toHaveText`は、正規表現での検証ができないため、jestのアサーションで要素の文言を検証する
    jestExpect(text).toBe(new RegExp(`[0-9]{1,2}\.[0-9]%`));
}

DetoxによるE2Eテスト実行例

テスト実行のコマンド

Detox標準のテスト実行は、特定のテストケースを指定した実行ができないため、jestを直接使用することで一部のテストケースに絞ったテスト実行ができます。

DETOX_CONFIGURATION=ios.sim.debug node_modules/.bin/jest --config e2e/jest.config.js --testNamePattern='<テスト名>'

テスト実行結果例

モバイルアプリのテスト実行例を、以下に示します。

WebViewを含む画面での操作実例

ウォークスルー
(※ 3倍速で再生)

UIKitとSwiftUIを含む画面での操作実例

口座開設申込
(※ 3倍速で再生)

おわりに

Detox導入の結果

モバイルアプリのテストツールとしてDetoxの導入は、商用サービスでは作成できなかった業務のテストシナリオを実現でき、テストカバレッジが大きく向上しました。
またDetoxでのテスト実行は、モバイルアプリの状態を監視しながらテストが実行されるため、モバイルアプリの挙動が不安定となってもテストの待機が正しく行われ、テスト実行は安定しflakyさが削減されました。

目指すべき未来

ウェルスナビのQAチームは、Webサービスとモバイルアプリにおいて、品質を保証できる仕組みや体制を作るため、改善を続けていきます。
本記事の取り組みは、iOSのネイティブモバイルアプリに対する、安定的な品質保証への土台となります。今後は、Androidのネイティブモバイルアプリに対してE2Eテストツールを導入し、全プロダクトのさらなる品質向上を目指していきます。

著者プロフィール

木下 智弘(きのした ともひろ)

2015年10月にウェルスナビに、エンジニアとして入社。
プライベートでは、Google Cloudを好んで利用しています。
Goのシンプルな考えが好きです。

WealthNavi Engineering Blog

Discussion