MaestroではじめるmenuユーザーアプリのE2Eテスト

menu事業部 フロントエンドエンジニアの松﨑です。

フロントエンドチーム内で私は「品質管理大臣」という役職(品質管理向上委員会)を任されており、サービスの安定的運用を目指して動いているので、今回は「menuのユーザーアプリにE2EテストツールのMaestroを導入してみた✨」という話をしようと思います。

導入の背景

ユーザーアプリでは単体テストの実装は既に結構書けていましたが、アプリ全体を通したシナリオテストが不足しており、QAチームが毎リリースごとに検証してくれるとはいえ「開発チームでもテストを実装して最低限の品質は担保したいよね」という課題がチームとしてありました。

当初はJest + Testing Libraryを使うインテグレーションの実装を検討しましたが、テスト時に描画しているコンポーネントやそれに伴うライブラリをモックする辛さから開発者の負担が大きいと感じていました。そこでテストへの心理的ハードルを下げるツールはないかを探した結果、Maestroを導入してみることになりました。

Maestroとは?

What is Maestro? | Maestro by mobile.dev

ビルドしたアプリをシミュレーターもしくはエミュレーター上で動かしてテストを実施するE2Eテストフレームワークです。開発しているmenuのユーザーアプリはReact Native製なのでローカル環境だとExpoを使って動いていますが、ローカルのExpoアプリでも工夫すればMaestroで動かすことが可能です。

Maestroの利点を挙げると以下の通りです。

  • yamlベースでCommandを使って、テストしたいユーザーの挙動を直感的に表現できる
  • Maestro Studioが提供されているので、テスト対象の要素の取得方法がすぐわかる
  • iOS, Android, React Native, Flutterすべてサポートされている
  • recordを使えばテスト実行の様子をスクリーンレコードしてくれる

他のテストツールに比べても開発側でE2Eテストをしやすいようにテストしたユーザーの挙動に重点を置いてくれています。手元のシミュレーターもしくはエミュレーター上でアプリを自動で動かしてテストを実施してくれるので、開発者がわかりやすくテストの作成や修正を行うことができます。

mswでAPIコールをモックしてMaestroを使う

Maestro導入にあたって悩んだのがMaestro自体がモックする機能を提供していないという点です。

私は今までNext.jsで構築したWebアプリのE2EテストにCypressを使った経験があり、Cypressだとcy.interceptのようなAPIコールをモックする関数が用意されています。

APIコールをモックしないと開発サーバー側にリクエストを投げてしまうため、バックエンド側でデータ変更が起きてテストの冪等性が失われてしまいます。

開発元のmobile.devのブログの記事を読んでいるとWireMockを使った方法が紹介されていますが、チームがWireMockにあまり馴染みがないというのとテストシナリオによっては「叩いたAPIエンドポイントの挙動を変えたい」などがありそうだなと思いフロント開発ではお馴染みのライブラリmswを使う選択をしました。

mswは対象のAPIリクエストをインターセプトする機能を持ち、フロントエンド側のテストにとって都合の良いレスポンスを返すことができます。

mswはReact Nativeにも対応しているので、開発コマンドのyarn startとは別にyarn start-testingというテスト開発用コマンドを用意して実行時にはmswが起動するようにしました。

加えて各テストケースごとにモックするエンドポイントの対象を変更するという仕組みを用意しました。これについてこの記事で後述します。

Maestroのテストの設計と書き方

導入したMaestroでテストを設計して書くための手順を紹介します。

ざっと全体像は以下の通りになります。

1. テストフローを考える

Maestroではテストシナリオのことを「Flow」という呼び方をします。FlowはMaestroで用意されているCommandで構築されます。

テストフローを考えるにあたっては実行中にどのような操作をしてどのような期待値でアサーションするかを考えます。これらのテストフローは開発チーム以外のQA側にも連携できるようにスプレッドシートで管理するようにしています。

2. テストフローで必要なAPIモックを洗い出す

テストフローの中で、画面アクセス時や操作処理時においてどのようなAPIが呼ばれているかを確認します。テスト開発中のAPIコールはProxymanを使ってリクエスト/レスポンスをプロキシすることを推奨しています。

一応モックする対象の指針としては、

  • データ変更系APIコールは、なるべくモックする
  • データ取得系APIコールは、操作に影響するものだけをモックする
  • 次の操作のAPIコールに影響する場合もあるので、リクエストボディを見てレスポンス内容の整合性を考慮する

を掲げています。

3. mswでAPIモックを定義する

Proxymanで傍受したレスポンスのJSONをコピーしてテストに都合のいいレスポンスになるように編集してからmswのレスポンスで返すようにします。

handler.ts

import publishSms from "./response/publishSms.json";

export const testXXHandlers = [
  http.post(`${EnvUtil.getApiBasePath()}/publishSms`, ({ request }) => {
    Logger.info(request);
    return HttpResponse.json(publishSms); // Proxymanで取得したレスポンスJSONを参照している
  }),
  http.post(`${EnvUtil.getApiBasePath()}/authSms`, ({ request }) => {...}),
  ...
];

工夫すれば同じエンドポイントでも1回目と2回目で異なるレスポンスを返す処理も書くことができます。

次にこれらの処理を各テストごとに適用する方法を模索しました。

menuのユーザーアプリでは元々開発アプリのみ表示されるデバッグページがあるので、テストアプリもそれにあやかりテストフローごとにモックするエンドポイントの対象を変更するための「MaestroAPIモック切替」というページを用意しました。

中身はシンプルでmswで提供されているresetHandlersuseを呼び出すようにしています。

export const setHandler = (scenario: TestScenarioType) => {
  mockedServer.resetHandlers();
  mockedServer.use(...handlerList[scenario]);
};

そして、Maestro側では各テストごとにモック切替の振る舞いができるように以下のようにモジュールとして共通化して書来ました。

テストフロー開始後すぐにこのモック切替をrunFlowで実行するようにしています。

appId: ${APP_ID}
name: SetApiMock
---
# マイページに遷移
- runFlow:
    file: ./go_mypage.yaml

# マイページにある開発者専用のデバッグページに遷移
- scrollUntilVisible:
    element:
      text: "デバッグ機能"
- tapOn: "デバッグ機能"

# テストシナリオにもとづいたAPIモックを選択
- scrollUntilVisible:
    element:
      text: "MaestroAPIモック切替"
- tapOn: "MaestroAPIモック切替"
- scrollUntilVisible:
    element:
      text: ${TEST_SCENARIO}
- tapOn: ${TEST_SCENARIO}

- tapOn: "OK"

4. Maestroのテストを実装

テストフォルダの構成はおおまかなアプリ機能ごとのカテゴリにフォルダを分けてその中にbehaviorフォルダを置いています。実際に実行されるのはtest_というプリフィックスがついているyamlファイルです。

フォルダ構成

.maestro/android
├── common                 
│   ├── behavior
│   │   ├── login.yaml
│   │   ...
│   ├── setup.yaml
│   └── teardown.yaml
├── auth
│   ├── behavior
│   ├── test_auth_account.yaml
│   ...
├── mypage
│   ├── behavior
│   ├── test_coupon.yaml
│   ...
...

テストフローは基本的にbehaviorフォルダにあるモジュール化されたユーザー操作をrunFlowで実行するようにします。

runFlow | Maestro by mobile.dev

クーポン取得の正常系テストを実装したtest_coupon.yamlの中身を例に紹介すると以下のようなイメージです。

appId: ${APP_ID}
name: TestCoupon
---
# アプリを起動
- runFlow: 
    file: ../common/behavior/launch_app.yaml

# ログイン
- runFlow: 
    file: ../auth/behavior/login.yaml
    env:
      EMAIL_ADDRESS: "XXXXXX@XXX.jp"
      AUTH_CODE: "XXXXX"

# モック切替
- runFlow:
    file: ../common/behavior/set_api_mock.yaml
    env:
      TEST_CASE: "TestCoupon"

# マイページに遷移
- runFlow:
    file: ../common/behavior/go_mypage.yaml

# クーポンを取得
- runFlow:
    file: ../mypage/behavior/get_coupon.yaml

- assertVisible: "クーポンを取得しました!"

こうすることでテストの可読性が上がり、他のフローでよく使われるユーザー操作の再利用性が上がります。

またこれらのテストを書く上で取得する要素はMaestro Studioが本当に便利です。Maestro Studioはモバイルアプリを起動した状態でmaestro studioというコマンドを打つことで表示できるWebツールで、モバイルアプリ上のタップしたい要素をどのようにMaestroでどう書くかを取得してくれます。

まとめ

さいごにtest_coupon.yamlをMaestroで実行して手元のAndroidエミュレーター上で動くところを載せておきます。

このテストは適当なクーポンコードを入力してもクーポン取得時のリクエストを成功で返すようにmswでモックして実施しています。

モバイルアプリでE2Eを実現するのに他にも有用なツールはたくさんありますが、Maestroで実現したいという方の参考になれば嬉しいです☺️