[React Native]Appiumを使ったE2Eテストをjestで動かす

- この投稿は[React Native Advent Calendar 2018](https://qiita.com/advent-calendar/2018/react-native)の15日目の記事です。

## 概要

- ReactNativeで作ったアプリのE2EテストをAppiumとjestで試してみました。
- 今回はAndroidで実行する手順を紹介します。
- [8日目の記事](https://qiita.com/gougyan/items/b2e692d01c6bbf6a0a21)で@gougyanさんがdetoxを使ったE2Eテストの記事を書かれていますので合わせてご紹介しておきます。

## Appiumとは

- スマホアプリのE2Eテストライブラリ。
    - http://appium.io/
- ReactNativeに特化している訳ではなく、バンドル後のapkやipaに対してテストをするツールなので一般的なAndroid/iOSアプリでも使われるものです。
- WebでいうSeleniumのスマホアプリ版といえばイメージがつきやすい方も多いのではないでしょうか。

## Appium実行まで

- まずは`react-native init`で雛形を作ってからAppiumを動かすところまで

```bash
react-native init AdventCalendarSample
cd AdventCalendarSample
# appium と web driver をインストール
yarn add -D appium wd
```

- appiumを実行するscriptを追加

```json:package.json
  "scripts": {
    "start": "node node_modules/react-native/local-cli/cli.js start",
    "test": "jest",
    "appium": "appium" // <- この行を追加
  },
```

- App.jsのViewタグにaccessibilityLabelを追加
    - AndroidでAppiumを使う場合はaccessibilityLabelで要素を特定します

```jsx:App.js
export default class App extends Component<Props> {
  render() {
    return (
      // accessibilityLabelを追加
      <View style={styles.container} accessibilityLabel="App">
        <Text style={styles.welcome}>Welcome to React Native!</Text>
        <Text style={styles.instructions}>To get started, edit App.js</Text>
        <Text style={styles.instructions}>{instructions}</Text>
      </View>
    );
  }
}
```

- 続いて設定ファイルとテストを作成します

```js:e2e/config.js
import wd from 'wd';

jasmine.DEFAULT_TIMEOUT_INTERVAL = 60000;

const PORT = 4723;

export const config = {
  platformName: 'Android',
  deviceName: 'Android Emulator',
  app: process.env.APK_PATH || './android/app/build/outputs/apk/debug/app-debug.apk',
  unicodeKeyboard: true,
  resetKeyboard: true,
};

export const driver = wd.promiseChainRemote('localhost', PORT);
```

```js:e2e/sample.test.js
import { config, driver } from './config';

beforeAll(async () => {
  // アプリの起動処理
  await driver.init(config);
  // 起動時間の分スリープを入れる
  await driver.sleep(5000);
});

describe('e2e', () => {
  test('トップページが表示されること', async () => {
    // AccessibilityLabel="App"の要素を取得できるかどうかをテストしている
    expect(await driver.hasElementByAccessibilityId('App')).toBe(true);
  });
});
```

- テストを実行するためにapkファイルを作成しておきます
    - デバイスにインストールまで行うのでシミュレータ起動するか実機をつないだ状態で実行します

```bash
# apkの作成
mkdir -p android/app/src/main/assets
react-native bundle --platform android --dev false --entry-file index.js --bundle-output android/app/src/main/assets/index.android.bundle --assets-dest android/app/src/main/res/
cd android
./gradlew assembleDebug installDebug
cd ..
```

- いよいよテストを実行です

```bash
# appiumサーバの起動
yarn appium

# 別ターミナルを開く

# テストを実行
yarn test
```

- うまくいっていれば以下のようにテストが通るはずです

<img width="365" alt="スクリーンショット 2018-12-14 23.19.18.png" src="https://qiita-image-store.s3.amazonaws.com/0/175213/9090e725-af1f-2164-6d7f-a98c36f74dbc.png">


- テストを実行すると自動でアプリが起動していることも確認できると思います

## Appium実践編

- ここまででAppiumを動作させるところまでできました
- 続いてもう少し実践的な内容を紹介します

### アプリの拡張

- テストをするためにアプリの機能を拡張させます
- react-navigationを追加する

```bash
yarn add react-navigation@2
```

- App.jsを修正する
    - SampleA, SampleB, SampleCの3ページ作成
    - 画面の内容は後掲のgifをご覧ください

```jsx:App.js
import React from 'react';
import { Button, ScrollView, Text, TextInput, View } from 'react-native';
import { StackNavigator } from 'react-navigation';

const SampleA = ({ navigation }) => (
  <ScrollView accessibilityLabel="SampleA">
    <Text style={{ height: 1000 }}>↓スクロールしてください↓</Text>
    <Button
      accessibilityLabel="NextButton"
      title="次のページへ"
      onPress={() => navigation.navigate('SampleB')}
    />
  </ScrollView>
);

SampleA.navigationOptions = { title: 'SampleA' };

class SampleB extends React.Component {
  state = { text: '' };
  render() {
    return (
      <View accessibilityLabel="SampleB" style={{ alignItems: 'center', justifyContent: 'center' }}>
        <Text>名前</Text>
        <TextInput
          accessibilityLabel="TextInput"
          onChangeText={text => this.setState({ text })}
          style={{ backgroundColor: 'white', width: 300 }}
        />
        <Button
          accessibilityLabel="NextButton"
          title="次のページへ"
          onPress={() => this.props.navigation.navigate('SampleC', { text: this.state.text })}
        />
      </View>
    );
  }
}

SampleB.navigationOptions = { title: 'SampleB' };

const SampleC = ({ navigation }) => (
  <View accessibilityLabel="SampleC">
    <Text accessibilityLabel="text">{navigation.state.params.text}</Text>
  </View>
);

SampleC.navigationOptions = { title: 'SampleC' };

const AppNavigator = StackNavigator({
  SampleA: { screen: SampleA },
  SampleB: { screen: SampleB },
  SampleC: { screen: SampleC },
});

const App = () => <AppNavigator />;

export default App;
```

### テストを修正

- テストを修正します

```js:e2e/sample.test.js
import { config, driver } from './config';

describe('e2e', () => {
  beforeAll(async () => {
    // アプリの起動処理
    await driver.init(config);
    // 起動時間の分スリープを入れる
    await driver.sleep(5000);
  });

  test('SampleA', async () => {
    // SampleAが表示されていること
    expect(await driver.hasElementByAccessibilityId('SampleA')).toBe(true);
    // 画面をスクロールする
    await driver.elementByAccessibilityId('SampleA').flick(1, -1000, 100);
    // ボタンをクリックする
    await driver.elementByAccessibilityId('NextButton').click();
  });
  test('SampleB', async () => {
    // SampleBが表示されていること
    expect(await driver.hasElementByAccessibilityId('SampleB')).toBe(true);
    // 文字列を入力する
    await driver.elementByAccessibilityId('TextInput').type('ozaki25');
    // ボタンをクリックする
    await driver.elementByAccessibilityId('NextButton').click();
  });
  test('SampleC', async () => {
    // SampleCが表示されていること
    expect(await driver.hasElementByAccessibilityId('SampleC')).toBe(true);
    //表示内容を確認
    expect(await driver.elementByAccessibilityId('text').text()).toBe('ozaki25');
  });
});
```

- テスト実行

```bash
# apkの作成
react-native bundle --platform android --dev false --entry-file index.js --bundle-output android/app/src/main/assets/index.android.bundle --assets-dest android/app/src/main/res/
cd android
./gradlew assembleDebug installDebug
cd ..
# テスト実行
yarn test
```

![appium.gif](https://qiita-image-store.s3.amazonaws.com/0/175213/14100567-2a23-b3bd-1072-83235abeb0e7.gif)

### テストのポイント

#### 要素の存在確認

- `hasElementByAccessibilityId`を使います
    - 指定した値のaccessibilityLabelを持った要素があるかどうかチェックします

```js
await driver.hasElementByAccessibilityId('SampleA')
```

#### 画面のスクロール

- `flick()`を使うことでスクロールさせることができます
    - 第二引数がY軸のflick量なので、上から下にスクロールしたければマイナスの値を設定する
    - 一番下までスクロールをしたい場合は大きめの値を入れておく方が確実

```js
await driver.elementByAccessibilityId('SampleA').flick(1, -1000, 100);
```

#### クリック

- `click()`を使うことでクリックできる

```js
await driver.elementByAccessibilityId('NextButton').click();
```

#### 文字の入力

- `type('入力内容')`で文字を入力できる

```js
await driver.elementByAccessibilityId('TextInput').type('ozaki25');
```

#### 表示内容の取得

- `text()`を使うことでテキストを取得できる

```js
await driver.elementByAccessibilityId('text').text()
```

#### スリープ

- 通信処理等で時間がかかる場合は`sleep(xxxx)`でスリープさせることができる

```js
await driver.sleep(5000);
```

## はまりどころ

- キーボード周り
    - キーボードの設定をOFFにしておかないとテスト中にキーボードが表示されてしまいうまく動かない
    - 日本語を入力させる場合は日本語のキーボードを有効化しておく必要がある
- 実機だと固まることがある
    - 実機で実行すると時々テストが固まってしまう事象が起きていました(画面に軽く触れると続きが動き出す)
    - シミュレータの方が安定していたので基本はシミュレータで回しています
- 不安定な挙動
    - スクロールさせる際に時々スクロール量が少なくなってしまうことがある
        - スクロール量は多めに設定しておくと安全
    - クリック処理がなぜか実行されない
        - 通信処理が絡んでいたせいか、sleepを挟んで二度クリックしないと実行されない、なんていう不思議な挙動もありました
        - click[実行されない] -> sleep(1) -> click[実行される]