Detox の導入 〜 最初のテストまで

React, JavaScript

Detox の備忘録。

Detox とは

wix が開発している、React Native 上で動く E2E ライブラリ。
グレーボックステスト(実装を把握しつつ行われるブラックボックステスト)をコンセプトとしている。
wix/Detox: Gray box end-to-end testing and automation framework for mobile apps

よく比較対象に挙げられるのが Appium で、そちらは Selenium のモバイルアプリ版。
Detox は WebDriver ではなく、EarlGrey と Espresso で動かせるように実装されている。

この手の E2E は端末操作の自動化みたいなモノになるけど、アプリ内の通信などが生じるのでどうしても冪等でなく、"Flakiness" なテストになりがちなのが問題だった。
アプリの挙動もちゃんと見ながら E2E をやろうず、というのが Detox。

導入

基本的には getting-started の手順通りに進めて、detox build を実行して、ビルドが通れば成功。
導入は楽なものの、各所に罠がある。
特に「10 分程度で終わる」とあるが、ビルドに 30 分以上かかる。
他にも見落としがちな罠を書き残しておく。

共通

Detox は jest だけでなく jest-circus を environment に使っている。
jest-circus はカスタム testEnvironment にイベントを bind できるものだそうな。
jest/packages/jest-circus at master · facebook/jest

jest-circus も入れておく。

shell

npm install -D jest-circus

また、TypeScript に対応させるため、ts-jest と @types/detox を入れて…

shell

npm install -D ts-jest @types/detox

"e2e/config.json" をいじる。

e2e/config.json

{
  "testEnvironment": "./environment",
  "testRunner": "jest-circus/runner",
  "testTimeout": 120000,
  "testRegex": "\\.e2e\\.[j|t]s$",
  "preset": "ts-jest",
  "reporters": ["detox/runners/jest/streamlineReporter"],
  "verbose": true
}

iOS

下記を読んで applesimutils を入れる。
Detox/Introduction.IosDevEnv.md at master · wix/Detox

あとは下記の設定を進める。
Detox/Introduction.Ios.md at master · wix/Detox

罠は ".detoxrc.json" の設定。
サンプルコードは下記のようになっている。

.detoxrc.json

{
  "configurations": {
    "ios.sim.debug": {
      "binaryPath": "ios/build/Build/Products/Debug-iphonesimulator/example.app",
      "build": "xcodebuild -project ios/example.xcodeproj -scheme example -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build",
      "type": "ios.simulator",
      "device": {
        "type": "iPhone 11 Pro"
      }
    }
  }
}

これの example をアプリ名に置き換えるだけ、と思いきや、サンプルコードの下にさりげなく書いてあった。

For React Native 0.60 or above, or any other iOS apps in a workspace (eg: CocoaPods) use -workspace ios/example.xcworkspace instead of -project.

サンプルコードは xcodeproj 指定になっているので、RN 0.60 以上だと "modulemap not found" エラーでビルドに失敗する。ドキュメントは最後まで読みましょう。
下記が workspace 指定のもの。

.detoxrc.json

{
  "configurations": {
    "ios.sim.debug": {
      "binaryPath": "ios/build/Build/Products/Debug-iphonesimulator/example.app",
      "build": "xcodebuild -workspace ios/example.xcworkspace -scheme example -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build",
      "type": "ios.simulator",
      "device": {
        "type": "iPhone 11 Pro"
      }
    }
  },
  "test-runner": "jest"
}

Android

iOS に比べて導入手順が煩雑。エミュレーターが動くところまでやった。
https://github.com/wix/Detox/blob/master/docs/Introduction.AndroidDevEnv.md

その後の設定手順も長い。手順通りにやれば問題なかった。
https://github.com/wix/Detox/blob/master/docs/Introduction.Android.md

罠は "DetoxTest.java" の保存場所だった。
場所が "android/app/src/androidTest/java/com/example/" なので "android/app/src/" に "androidTest" フォルダを作る必要あり。
"androidTest" はハードウェアやエミュレーターで実行されるテスト(インストゥルメント化テスト)の置き場所らしい。
アプリをテストする  |  Android デベロッパー  |  Android Developers

間違えて "android/app/src/main" 配下に入れると com.wix.detox.Detox などが呼び出せないのでビルドに失敗する。(それはそう)

テストを書く

素振りとして Firebase Authentication を使った、メルアド & パスワードでログインする時のテストを書いた。

あらかじめ操作対象には testID を設定しておく。

src/components/Signin.tsx

// 省略
<TouchableOpacity
  testID={'signinSubmitButton'}
  onPress={onPress}
>
  <Text style={style.buttonLabel}>ログイン</Text>
</TouchableOpacity>

ID を設定したら、単純なテストを書いてみる。

e2e/firstTest.e2e.ts

import { by, device, element, expect } from 'detox'
import { TEST_ID } from '../src/constants/labels'

describe('ログイン操作', () => {
  beforeEach(async () => {
    await device.reloadReactNative()
  })
  it('何も入力しないで送信ボタンだけ押すと、エラーメッセージが確認できる', async () => {
    await element(by.id('signinSubmitButton')).tap()
    await expect(element(by.id('signinErrorMessage'))).toBeVisible()
  })
})

これで detox test を実行すれば、対象の環境でシミュレーターを起動し、自動的にテストを行ってくれる。
ユーザーが何も入力しないでログインボタンを押した時、エラーメッセージが表示されるかの確認。

shell

detox test -c ios.sim.debug

シミュレーターが実行され、Detox によるテストが行われる。

Detox がシミュレーターでログイン画面に入り、必須項目を未入力のまま送信した時のテストを実行する

テストの内容

まず、device.reloadReactNative() をライフサイクルに挿す。
device.reloadReactNative() はシミュレーター上で cmd + d を押すのと同じリロード操作を行う。

e2e/firstTest.e2e.ts

  beforeEach(async () => {
    await device.reloadReactNative()
  })

次に、ログインボタンを element(by.id('signinSubmitButton')) で調べ、tap() でタップ操作をする。
最後に toBeVisible() で、View の 75% 以上が画面上にあるかを判定する。
今回はエラーメッセージを確認した。

e2e/firstTest.e2e.ts

  it('何も入力しないで送信ボタンだけ押すと、エラーメッセージが確認できる', async () => {
    await element(by.id('signinSubmitButton')).tap()
    await expect(element(by.id('signinErrorMessage'))).toBeVisible()
  })

toBeVisible() は opacity やコントラストなどの視覚的な情報は判定していないので、それらのテストは別の方法でやらなければならないけど、「エラーメッセージがはみ出て見えなくなった」のようなデグレは検知できそう。

他のテストを書く

他にもいくつか書いてみる。
最終的にログインする。

e2e/firstTest.e2e.ts

describe('ログイン操作', () => {
  beforeEach(async () => {
    await device.reloadReactNative()
  })
  it('何も入力しないで送信ボタンを押す', async () => {
    await element(by.id('signinSubmitButton')).tap()
    await expect(element(by.id('signinErrorMessage'))).toBeVisible()
  })
  it('パスワードが違う', async () => {
    await element(by.id('signinInputEmail')).typeText(
      'grgrdkrk@blog.net',
    )
    await element(by.id('signinInputPassword')).typeText('korehachigau')
    await element(by.id('signinSubmitButton')).tap()
    await expect(element(by.id('signinErrorMessage'))).toBeVisible()
  })
  it('ログインする', async () => {
    await element(by.id('signinInputEmail')).typeText(
      'grgrdkrk@blog.net',
    )
    await element(by.id('signinInputPassword')).typeText('koredeyoina')
    await element(by.id('signinSubmitButton')).tap()
    await expect(element(by.id('signinErrorMessage'))).not.toBeVisible()
  })
})

再度 detox test を実行。自動的に操作が行われる。
テストも全て通る。

Detox がシミュレーターで、ログイン画面のテストパターンを全て実行している

同期について

ログインの期待値が「エラーメッセージ非表示」では心もとないので、次の画面にある testID があれば OK というテストにする。

Detox は reloadReactNative() などの操作、各種ネットワークの通信状態を見ていて、シミュレーターがアイドル状態になるまで待機してくれる。

ただ、「遷移先の画面が表示される」までの待機処理が怪しかった。
React Navigation でログイン前と後の Stack を変えて、ログイン後の testID を見る、と言うやり方だと、どうも遷移した後の testID が取れたり取れなかったり "Flakiness" なテストになってる。

e2e/firstTest.e2e.ts

describe('ログイン操作', () => {
  beforeEach(async () => {
    await device.reloadReactNative()
  })
  it('ログインする、チュートリアル画面が出る', async () => {
    await element(by.id('signinInputEmail')).typeText(
      'grgrdkrk@blog.net',
    )
    await element(by.id('signinInputPassword')).typeText('koredeyoina')
    await element(by.id('signinSubmitButton')).tap()
    // これが通ったり落ちたりしている。
    await expect(element(by.id('loginDekitazo'))).toBeVisible()
  })
})

何もわからんので waitFor を使い、手動で待機させた。
withTimeout(millis: number) で設定した間、expect が満たされるまでポーリングしてくれる。

e2e/firstTest.e2e.ts

describe('ログイン操作', () => {
  beforeEach(async () => {
    await device.reloadReactNative()
  })
  it('ログインする、チュートリアル画面が出る', async () => {
    await element(by.id('signinInputEmail')).typeText(
      'grgrdkrk@blog.net',
    )
    await element(by.id('signinInputPassword')).typeText('koredeyoina')
    await element(by.id('signinSubmitButton')).tap()
    // 条件を満たすまで待機
    await waitFor(element(by.id('loginDekitazo')))
      .toBeVisible()
      // タイムアウトを指定
      .withTimeout(3000)
  })
})

このように、Detox の同期処理をコントロールしたり、デバッグログを出力できるらしい。
あまり使いたくはないけど、もしもの時に…。
Detox/Troubleshooting.Synchronization.md at master · wix/Detox

終わり

つづきます。