React NativeでIntegrated Component Test

# Integrated Component Testとは

外部サービス(Web API等)とIntegrationされたUI Componentに着目し、テストすることです。
(どこかに定義があるわけではなく、僕が勝手に呼んでいる名前です。)

[React Native公式でTestに関する説明](https://reactnative.dev/docs/testing-overview)があります。その中ではEnd-to-End Testsに分類されると思いますが、
ユーザシナリオに基づいてテストはしません。IntegrateされたComponentに着目し、テストを分割、実行していきます。


# なぜ、Integrated Component Testしたいか

理由は以下の通りです。

## 1. デグレチェック/リファクタリングするためのテストがほしい

React NativeでAndroidアプリを開発しています。
そこそこ機能が増えてきて、
コミットする度にデグレしてないか気になるし、
自信をもってどんどんリファクタリングしていきたい。
テストがほしいと思いました。

## 2. 少ない努力で効果が得られるシンプルなテストにしたい

React Nativeから外部サービスと連携するロジックを切り離し、Unitテストを書く方法もあります。
むしろ、そうするべきです。

でも今のチームは開発メンバーも多くないですし、
とりあえず作る、リファクタリングするの順で進めたくなることが多いです。

テストはvalueとeffortを天秤にかけろとよく言われます。
そう、僕らは少ないeffortである程度のvalueを得たいのです。

じゃあどうするか、ある程度大きな塊Componentでテストするアプローチをとることを決めました。


## 3. mockする手間をはぶきたい

僕らのチームはフロントエンド/バックエンド両方を開発しています。
アプリからコールするWeb APIも自分たちで開発したものです。

つまり、両方のコードが手元にある。
ローカルでWeb APIサーバを起動させて、連携させることだってできます。
そんな状況だから、mockするのが手間に感じます。
mockを書くのはやめました。


# やってみよう

ここからは実際にIntegrated Component Testを実行する方法を書きます。
テストにはe2eテストフレームワークappiumを利用します。
コードは[こちら](https://github.com/hirokoji/blog/tree/main/react_native/IntegrationTestSample)にあります。

## 1. 環境構築


### React Nativeプロジェクト作成
```sh
mkdir IntegrationTestSample
cd IntegrationTestSample
npx react-native init IntegrationTestSampleApp --template react-native-template-typescript
cd IntegrationTestSample
npm install
npm run android
```

### REST API 構築
[JSON Server](https://github.com/typicode/json-server)を利用してRestful Web Serverを起動する。

```sh
vim server_db.json
```
ファイルに以下の内容をコピペする

```json
{
  "posts": [
    { "id": 1, "title": "json-server", "author": "typicode" }
  ],
  "comments": [
    { "id": 1, "body": "some comment", "postId": 1 }
  ],
  "profile": { "name": "typicode" }
}
```
以下のコマンドでサーバを起動する。privateIPにはご自身のPCにアサインされているIPをいれてください。
--hostを指定する理由は[AxiosはAndroid Emulatorでlocalhostにアクセスできない](https://github.com/axios/axios/issues/973)ためです。

```sh
yourPrivateIP=xxx.xxx.xxx.xxx
npx json-server --watch server_db.json --host ${yourPrivateIP}
```
以下の通り、コマンドを実行しレスポンスが返ってくれば、正常に動作しています。

```sh
curl http://${yourPrivateIP}:3000/posts
  {
    "id": 1,
    "title": "json-server",
    "author": "typicode"
  }
]%
```

## 2. RESTと連携するアプリを準備する

```sh
npm install axios 
vim App.tsx
```

App.tsxを開き、以下のの通りコピペする。
privateIPには先程と同じものを設定します。

```typescript
import React, {useEffect, useState} from 'react';
import {
  SafeAreaView,
  StyleSheet,
  View,
  Text,
  StatusBar,
  TouchableOpacity,
  Linking,
} from 'react-native';

const App = () => {
  const [bookCard, setBookCard] = useState<BookCard>({
    id: 0,
    title: '',
    author: '',
  });

  useEffect(() => {
    // 起動時にREST Serverに格納されたデータをカードに反映する
    const updateCard = async (): Promise<void> => {
      const targetBook = await getDataFromServer();
      if (targetBook) {
        setBookCard({
          id: targetBook.id,
          title: targetBook.title,
          author: targetBook.author,
        });
      }
    };
    updateCard();
  }, []);

  const styles = StyleSheet.create({
    cardPostion: {
      alignItems: 'center',
      justifyContent: 'center',
      height: '100%',
    },
  });

  return (
    <View accessibilityLabel="App">
      <StatusBar barStyle="dark-content" />
      <SafeAreaView>
        <View style={styles.cardPostion}>
          <Card
            id={bookCard.id}
            title={bookCard.title}
            author={bookCard.author}
          />
        </View>
      </SafeAreaView>
    </View>
  );
};

type BookCard = {
  id: number;
  title: string;
  author: string;
};

const Card: React.FC<BookCard> = ({title, author}) => {
  const styles = StyleSheet.create({
    container: {
      width: '80%',
      height: '10%',
      borderWidth: 1,
      borderRadius: 5,
      justifyContent: 'center',
      alignItems: 'center',
    },
    titleText: {
      fontSize: 20,
    },
    authorText: {
      fontSize: 15,
    },
  });
  return (
    <TouchableOpacity style={styles.container} accessibilityLabel="card">
      <Text style={styles.titleText} accessibilityLabel="card_title">
        {title}
      </Text>
      <Text style={styles.authorText} accessibilityLabel="card_author">
        by {author}
      </Text>
    </TouchableOpacity>
  );
};

/** ここからはREST Clientロジック **/

import axios from 'axios';
const privateIP = 'xxx.xxx.xxx.xxx';  // ***** ここにprivate ipを設定してください ***** //
const request = axios.create({
  baseURL: `http://${privateIP}:3000`,
  responseType: 'json',
});
// REST Serverからデータを取得する関数
const getDataFromServer = async (): Promise<BookCard | undefined> => {
  const res = await request.get('/posts');
  if (res.data) {
    return res.data[0] as BookCard;
  }
  return undefined;
};

export default App;
```

Androidエミュレータを起動し、

```sh
npm run android
```

以下のように表示されれば、正しく動作しています。

<img width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/510218/83d1cc27-6176-29c4-94f7-ad94f7f77ed6.png"/>

## 3. テストの準備と実行

## appiumのセットアップ

[appium](https://github.com/appium/appium)と[wd](https://github.com/admc/wd#readme)をインストールします。

```sh
npm install -D appium wd
mkdir integratedComponentTest
vim integratedComponentTest/config.js
```

config.jsを作成します。以下の通り記述してください。

```javascript
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);
```

### テストコードを書く
```sh
vim integratedComponentTest/sample.test.ts
```

```typescript
import {config, driver} from './config';

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

describe('Integrated Component Test', () => {
  describe('Card Component', () => {
    beforeAll(async () => {
      // Given: card component is available
      expect(await driver.hasElementByAccessibilityId('card')).toBe(true);
    });

    test('title should be json_server ', async () => {
      // Then
      expect(await driver.elementByAccessibilityId('card_title').text()).toBe(
        'json-server',
      );
    });

    test('author should be typicode', async () => {
      // Then
      expect(await driver.elementByAccessibilityId('card_author').text()).toBe(
        'by typicode',
      );
    });
  });
});
```

### テスト実行

事前にappiumを起動しておきます。

```
npx appium
```

以下の通り、テストを実行します。

```
➜ npm test sample.test.ts

> integration-sample-app@0.0.1 test
> jest --verbose "sample.test.ts"

 PASS  integratedComponentTest/sample.test.ts (14.084s)
  Integrated Component Test
    Card Component
      ✓ title should be json_server  (110ms)
      ✓ author should be typicode (646ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        14.124s
Ran all test suites matching /sample.test.ts/i.
```

# まとめ

Integrated Component Testする方法を書きました。
技術スタックとしては一般的なe2e testと同じですが、思想は違います。
IntegrateされたComponentに着目し、コンポーネント毎にテストを実行しています。

正直、e2e testフレームワークを利用することは理想ではありません。
mockせずに[React Native Testing Library](https://callstack.github.io/react-native-testing-library/)等を使ってintegration testテストかけたら最高ですが、
私が試した限りではそれはできないようなのです。
エミュレータを起動せずにテストする方法を知っている方がいらっしゃいましたらお教えいただけますと幸いです。

今回は簡単なサンプルであり、実際のアプリに組み込むには物足りない内容だったかもしれません。
次回、ディープリンク等も利用したテスト方法について書きたいと思います。