Hello Tech

AutoReserve 等を開発する株式会社ハローのテックブログです。スタートアップの最前線から本質的な価値を届けるための技術を紹介します。

React NativeとExpoを活用したネイティブビルド不要のE2Eテストの導入

はじめに

はじめまして、株式会社ハローで業務委託として開発をしている@0906kokiです。 今回の記事では、React Nativeで開発されているAutoReserve for Restaurantsで、Expoを最大限に活かしたE2Eテストの導入実装について書きたいと思います。

背景

飲食店向けにセルフオーダーや予約台帳の機能を提供するAutoReserve for Restaurantsは、React Nativeで開発されております。 今回、AutoReserve for RestaurantsにE2Eを導入した目的に関しては、以下のような点が挙げられます。

  • 手動テストの場合、テストをスキップ or 見逃していたケースがあったので、毎回網羅的にテストできるようにし、QAの質を上げたい
  • 頻繁に本番デプロイできるようにする
  • コミットごとにテストできるようにすることで、QAを待たず事前にデグレを検知できる
  • (今後) ライブラリのアップデートなど、全てのテストが通ったら自動でマージできるようにしていきたい
    • 例えば、react-navigationやreact-native-reanimatedなどの全体に影響あるライブラリのパッチバージョンアップなど

これまでも、AutoReserveなどweb側のプロジェクトにはcypressによるテストが導入されていましたが、ネイティブアプリ側のE2Eテストは導入できていませんでした。

理由として一番大きかったのは、

  • ウェブ側のテストと比べ、アプリ側のテストはネイティブビルドのみで数十分以上かかるため、E2Eテストを回すのに時間がかかり、開発のスピード感が損なわれる

という点です。前提の部分に詳しく記載しましたが、今回、ExpoのOTAリリースの仕組みを取り入れることで、ビルドバイナリを再利用することで上記課題を解決し、ウェブと同じようなスピード感でPRのコミットごとにE2Eテストを走らせることができるようになっています。

前提

今回のE2E実装について詳細をお伝えする前に、AutoReserve for RestaurantsのReact Nativeにおける技術的な前提条件について書きたいと思います。

冒頭に AutoReserve for Restaurantsでは React Nativeを使っていると紹介しましたが、React Nativeの中でも、ExpoのManaged Workflowで開発しており、EAS Buildの仕組みを使ってネイティブビルド、ストアリリース等を行っています。 Expoのワークフロー上で開発しているため、ネイティブモジュールに変更が加わらないJavaScript部分の修正に関しては、OTA (Over The Air Update)で、ストアリリースを通さずにリリースを行っており、逆にネイティブに変更が加わり、ストアリリースが必要な際には、EAS Build を使ってネイティブビルドを走らせてから各ストアへのリリースを行います。また、開発時においてもPR単位でExpoのリリースチャンネルを生成してOTAを走らせるため、PRへのpushごとでのJavaScript部分の動作確認やテストが可能となっております。

いずれの場合も、GitHub Actionsでワークフローは自動化されており、Expoを利用する上でのメリットを最大限に享受している形となっています。

実装内容

これらの技術的な前提を置いた上で、E2E を導入した際の実装内容について書きたいと思います。

今回、E2E のライブラリとして、Detox を使用しました。Detoxを採用した理由としては、非同期処理を内部で監視してflakiness(テストの不安定さ)を回避し、実行の安定性を担保してくれる点や React Native用 E2Eツールとして開発されている点などが挙げられます。 前者に関して補足すると、Detoxには同期の仕組みがあり、アプリのネットワークリクエストやアニメーション、タイマーを監視し、自動で完了を待機してくれるため、テストコード側でsleepなどを入れる必要がなく、高速かつ安定的にテストを走らせることができるそうです。

E2E を CI で実行する上で考えたいこと

CI上でE2Eを実行し、事前にデグレを検出したいので、以下のような条件であることが望ましいです。

  • E2E のセットアップ・実行にかかる時間が短いこと
  • 最新の変更に対する E2E テストであること

E2Eの実行速度が速いこと

CIでは基本的に実行時間に対する課金となるため、E2E自体の実行時間は速ければ速いほどハッピーです(イテレーションを速く回すという意味でも)。

通常Detoxは最初にdetox buildという、シミュレータでテストを実行できるようにするためのネイティブビルドを行い、そこで生成したビルドファイルを元にテストを実行します。

このdetox buildは、普通に行うと約10分 ~20分ほど実行に時間がかかり、それをCIで毎回コミットごとに行うことは避けたいことであります。 なので、今回は事前にシミュレータ用のEAS Buildを実行してビルドファイルを生成しておき、 テスト時はそのビルドファイルをExpo上からフェッチしてシミュレータ用のバイナリから最新のJavaScriptのbundleを読み込み、E2Eを実行することとしました。

EAS Buildした最新のビルド結果のリストは、以下のコマンドで取得できます。

$ eas build:list --status finished --platform ios --distribution simulator --non-interactive --json

※ 吐き出される JSON

[
  {
    "id": "...",
    "status": "FINISHED",
    "platform": "IOS",
    "artifacts": {
      "buildUrl": "ビルドファイルのURL",
      "xcodeBuildLogsUrl": "ログURL"
    },
    "initiatingActor": {
      "id": "...",
      "displayName": "..."
    },
    "project": {
      // ...
    }
    // ...
  }
]

CI上でこのコマンドを実行し、上記のJSONから配列の先頭にある artifacts.buildUrl から最新のシミュレータ用ビルドファイルを取得できます。 そのため、 artifacts.buildUrl をフェッチし、プロジェクト内に配置することで、CIで毎回 detox build を行わず、最新のビルド結果フェッチするだけで E2Eを実行できるようになります。

具体的に、以下が取得するスクリプトとなります。

const axios = require("axios");
const path = require("path");
const { existsSync, writeFileSync } = require("fs");
const { execSync } = require("child_process");

const OUTPUT_TAR_PATH = path.resolve(__dirname, "Sample.tar.gz");
const OUTPUT_SIMULATOR_BUILD_BINARY_PATH = path.resolve(__dirname, ".");

// 最新のシミュレータ用ビルドファイルを取得する
const getLatestSimulatorBuildBinary = async (platform) => {
  if (existsSync(`${OUTPUT_SIMULATOR_BUILD_BINARY_PATH}/Sample.app`)) {
    return;
  }

  try {
    // 最新のシミュレータ用ビルドの結果リストを取得する
    const buildJson = execSync(
      `eas build:list --status finished --platform ${platform} --distribution simulator --non-interactive --json`
    );
    // 最新のシミュレータ用ビルドファイルのURLを取得
    const buildUrl = JSON.parse(buildJson.toString())[0]?.artifacts?.buildUrl;
    const response = await fetchBinary(buildUrl);
    writeFileSync(OUTPUT_TAR_PATH, response.data);

    if (!buildUrl) {
      throw new Error(
        "simulator buildしたバイナリが存在しません。simulator buildを実行して、再度試してください。"
      );
    }

    // Sample.appファイルに展開する
    execSync(
      `tar -xf ${OUTPUT_TAR_PATH} -C ${OUTPUT_SIMULATOR_BUILD_BINARY_PATH} && rm -rf ${OUTPUT_TAR_PATH}`
    );

    return console.log("success: バイナリの取得が完了しました。");
  } catch (e) {
    throw new Error(`error log: ${e}`);
  }
};

const fetchBinary = async (url) => {
  return axios.get(url, {
    responseType: "arraybuffer",
    headers: { Accept: "application/x-tar" },
  });
};

// 一旦iOSのみ
(async () => await getLatestSimulatorBuildBinary("ios"))();

先程書いた通り、このシミュレータ用ビルドファイルは 事前にEAS Buildを行ってExpo上から取得できるようにする必要があるため、beta版のネイティブビルドをCIから作成するタイミングでシミュレータ用ビルドも同時に行っています。 これは、beta版をビルドするタイミングは基本的にJavaScriptのbundle以外のネイティブ部分に変更が入るタイミングで、それに合わせてシミュレータビルドも最新にする必要があるためです。

上記のように、detox build を行わずに、最新のシミュレータビルドしたバイナリを取得する方針にしたことで、約10~20分かかっていた時間が、1分未満(フェッチする時間)に短縮することができました。

最新の変更に対する E2E テストであること

CIを実行するたびにE2Eを実行して事前のデグレを検出したいので、当然ではありますが、最新の変更コミットが含まれたものに対して E2Eを実行させる必要があります。 これを実現する方法として、最新のOTAされた JavaScriptの bundleを取得し、その bundleを元にテストを実行する方法です。

前提での部分でも紹介した通り、AutoReserve for RestaurantsではPRごとに 動作確認ができるようにするため、PR単位でリリースチャンネルを作成してOTAを行っております。Expoでは、リリースチャンネルにOTAされたJavaScriptのbundleUrlを取得することができるため、まずは、そのbundleを取得するために、以下のURLからリリースチャネルごとのマニフェストJSONを取得します。

※ URL

https://exp.host/@sample-org/sample/index.exp?release-channel=${対象PRのリリースチャネル}&runtimeVersion=${アプリのバージョン}

※ 返されるJSON

{
  "ios": {
    "jsEngine": "jsc"
    // ...
  },
  "bundleUrl": "OTAされたJavaScriptのbundleファイルのUrl"
}

Manifestにある bundleUrl はOTAされたJavaScriptのbundleファイルのURLであるので、このURLからbundleを取得します。

フェッチからアプリ起動までの流れとしては、以下のような形となります。

  1. bundleUrlをフェッチする
  2. 1でフェッチした bundleUrlを、development buildのdeepLink URLであるexp+app-slug://expo-development-client/?url=のurlに指定する
  3. 上記deepLinkをアプリ起動後に開くURLとして指定する

スクリプトで見た方が分かりやすいと思うので、テストのbeforeAll時 に実行しているlaunchAndOpenApp関数を下記に記載します。

// リリースチャンネルごとの最新のManifestUrl
// RELEASE_CHANNELはGitHub Actions等で環境変数として取得できるようにする
const LATEST_PUBLISHED_MANIFEST_URL = `https://exp.host/sample/sample/index.exp?release-channel=${process.env.RELEASE_CHANNEL}&runtimeVersion=${appConfig.version}`;

const developmentBundleUrl = (platform: PlatformType): string =>
  `http://localhost:8081/index.bundle?platform=${platform}&dev=true&minify=false&disableOnboarding=1`;

const fetchLatestPublishedBundleUrl = async (): Promise<string> => {
  try {
    const response = await axios.get(LATEST_PUBLISHED_MANIFEST_URL);
    const data = response.data;
    return data.bundleUrl;
  } catch (e) {
    throw new Error(e);
  }
};

// 対象のbundleUrl
const getTargetUrl = async (platform: PlatformType): Promise<string> => {
  if (!!process.env.RELEASE_CHANNEL) {
    return fetchLatestPublishedBundleUrl();
  } else {
    // 開発環境の場合は、ローカルのbundleを参照する
    return developmentBundleUrl(platform);
  }
};

const getDeepLinkUrl = (url) =>
  `sample://expo-development-client/?url=${encodeURIComponent(url)}`;

// 指定したdeepLink先を開く
export const openDeepLinkUrl = async () => {
  // 一旦iOSのみ
  const url = await getTargetUrl("ios");
  await device.openURL({
    url: getDeepLinkUrl(url),
  });
};

// アプリを起動する
export const launchAndOpenApp = async () => {
  await device.launchApp({
    delete: true,
    newInstance: true,
    permissions: {
      notifications: "YES",
    },
  });
  await openDeepLinkUrl();
};

アプリ起動後にCI上であればaxiosを使って最新のOTAされたbundleUrlを取得、開発環境であれば、ローカルのbundleUrlを取得します。 取得できたら、deepLinkのqueryStringにそのURLを指定し、device.openURLでdeepLinkを開きます。

このように、アプリ起動時に最新のbundleを取得してdeepLinkを開く関数を用意することで、常に最新の変更に対してのE2E実行が可能となります。

まとめ

Expoを最大限に活用して、React NativeアプリにE2Eを導入する方法を書きました。まだ導入したばかりであるので、テストケースの作成やログイン周りの制御など探りながら整備していく必要があるものの、E2Eのデメリットである実行時間やアプリならではの最新の変更に対するテスト実行の難しさなどは解決することができました。

ハローでは一緒に働く仲間を募集しています。少しでも気になる方は気軽にお問い合わせください!