Maestroとmitmproxyを連携して通信異常系E2Eテストを自動化したメモ

Programming

麻雀集計スマホアプリのE2Eテストで、通信エラー時の表示やリトライ処理をMaestroから自動確認できるようにしたので、構成を忘れないようにまとめておく。

最初はMaestroのsetAirplaneModeで通信断を作れないか試したが、USBデバッグ+adb reverseを使っている開発環境では期待どおりにAPI通信を止められなかった。そこで、アプリとFlask APIの間にmitmproxyを置き、Maestroからmitmproxyの動作モードを切り替える形にした。

今回作りたかったもの

テスト中に次の3状態を切り替えられるようにする。

  • normal:通常どおりAPIへ転送
  • offline:通信そのものを失敗させる
  • 500:APIへ転送せずHTTP 500を返す
Android実機
   ↓ localhost:6080
adb reverse tcp:6080 tcp:6080
   ↓
mitmproxy :6080
   ↓ reverse proxy
Flask API :5000

Maestro runScript
   ↓
mitmproxy control API :9099
   ├─ GET  /mode
   ├─ POST /mode/normal
   ├─ POST /mode/offline
   └─ POST /mode/500

ポイントは、アプリが使うAPI入口の6080番と、Maestroがモード変更に使う9099番を分けたこと。offlineにしても制御APIまで止まらないので、Maestro側からnormalへ戻せる。

mitmproxy addonで通信モードを切り替える

mitmproxyのaddonに、HTTPフローを書き換える処理と簡単な制御APIを持たせる。配置場所はプロジェクトに合わせればよいが、例えばmitmproxy/network_mode.pyのように置く。

import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Lock, Thread

from mitmproxy import http

VALID_MODES = {"normal", "offline", "500"}

class ModeState:
    def __init__(self) -> None:
        self._mode = "normal"
        self._lock = Lock()

    def get(self) -> str:
        with self._lock:
            return self._mode

    def set(self, mode: str) -> None:
        if mode not in VALID_MODES:
            raise ValueError(f"invalid mode: {mode}")
        with self._lock:
            self._mode = mode

state = ModeState()

class ControlHandler(BaseHTTPRequestHandler):
    def _send_json(self, status: int, payload: dict) -> None:
        body = json.dumps(payload).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self) -> None:
        if self.path == "/mode":
            self._send_json(200, {"mode": state.get()})
            return
        self._send_json(404, {"error": "not found"})

    def do_POST(self) -> None:
        prefix = "/mode/"
        if not self.path.startswith(prefix):
            self._send_json(404, {"error": "not found"})
            return
        mode = self.path[len(prefix):].strip("/")
        if mode not in VALID_MODES:
            self._send_json(400, {"error": "invalid mode"})
            return
        state.set(mode)
        self._send_json(200, {"mode": mode})

    def log_message(self, format: str, *args) -> None:
        return

class NetworkModeAddon:
    def __init__(self) -> None:
        self.server = None
        self.thread = None

    def running(self) -> None:
        self.server = ThreadingHTTPServer(("0.0.0.0", 9099), ControlHandler)
        self.thread = Thread(target=self.server.serve_forever, daemon=True)
        self.thread.start()

    def done(self) -> None:
        if self.server is not None:
            self.server.shutdown()
            self.server.server_close()

    def request(self, flow: http.HTTPFlow) -> None:
        mode = state.get()
        if mode == "offline":
            flow.kill()
            return
        if mode == "500":
            flow.response = http.Response.make(
                500,
                b'{"message":"forced by mitmproxy"}',
                {"Content-Type": "application/json"},
            )

addons = [NetworkModeAddon()]

normalでは何もしないので、そのままreverse proxy先へ流れる。offlineflow.kill()で転送を止め、500はmitmproxy自身がレスポンスを生成する。

Docker Composeの設定

Flask APIはDockerネットワーク内の5000番で待ち受け、ホスト側のAPI入口はmitmproxyの6080番に統一した。mitmwebのGUIは8090番、モード制御APIは9099番。

services:
  api:
    build: ./backend
    container_name: mahjongscore-api
    expose:
      - "5000"
    command: >
      gunicorn -b 0.0.0.0:5000 "app:create_app()"

  mitmproxy:
    image: mitmproxy/mitmproxy:latest
    container_name: mahjongscore-mitmproxy
    ports:
      - "6080:6080"
      - "127.0.0.1:8090:8090"
      - "127.0.0.1:9099:9099"
    volumes:
      - ./mitmproxy:/addons:ro
    command:
      - mitmweb
      - --mode
      - reverse:http://api:5000
      - --listen-host
      - 0.0.0.0
      - --listen-port
      - "6080"
      - --web-host
      - 0.0.0.0
      - --web-port
      - "8090"
      - --set
      - web_password=mahjong
      - -s
      - /addons/network_mode.py
    depends_on:
      - api

APIコンテナをホストへ直接公開せず、通常時も異常系テスト時も必ず6080番を通すのが重要。これでアプリ側のAPI URLをテストごとに変える必要がない。

まずcurlでaddonだけを動作確認する

curl http://127.0.0.1:9099/mode

curl -X POST -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:9099/mode/normal
curl -X POST -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:9099/mode/offline
curl -X POST -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:9099/mode/500

GET /mode{"mode": "normal"}のように返れば制御APIは動いている。今回の確認ではnormaloffline500の3状態とも期待どおりに切り替わった。さらに6080番経由で実際のAPIを呼び、normalなら通常レスポンス、offlineなら通信失敗、500ならHTTP 500になることを確認した。

Maestro用の共通JavaScript

.maestro/scripts/set-network-mode.js:

const mode = typeof MODE === 'string' ? MODE : 'normal';

const controlUrl =
  typeof MITMPROXY_CONTROL_URL === 'string'
    ? MITMPROXY_CONTROL_URL
    : 'http://127.0.0.1:9099';

const allowedModes = ['normal', 'offline', '500'];

if (!allowedModes.includes(mode)) {
  throw new Error(`Unsupported network mode: ${mode}`);
}

const response = http.post(`${controlUrl}/mode/${mode}`, {
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({}),
});

if (!response.ok) {
  throw new Error(`Failed to set network mode: status=${response.status}, body=${response.body}`);
}

const result = json(response.body);

if (result.mode !== mode) {
  throw new Error(`Network mode mismatch: expected=${mode}, actual=${result.mode}`);
}

output.networkMode = result.mode;

ここで一度ハマった点:POSTにはbodyが必要

最初はPOST先だけ指定していたところ、maestro.js.JsEvaluationException: method POST must have a request body.になった。制御API側ではbodyを使っていなくても、MaestroのHTTPクライアントではPOSTにbodyが必要だった。body: JSON.stringify({})を付けて解消した。

Maestroのconfig.yamlとCLI実行ラッパー

.maestro/config.yamlに環境変数をまとめたかったが、単純にenvへ書いただけではCLI実行時にappId: ${APP_ID}undefinedになった。そのため、config.yamlのenvを読み取り、Maestro CLIの-e KEY=VALUEへ変換して実行するラッパーを使う。

env:
  APP_ID: com.anzaihome.mahjongapp.dev
  MITMPROXY_CONTROL_URL: http://127.0.0.1:9099
#!/usr/bin/env bash
set -euo pipefail

CONFIG_FILE=".maestro/config.yaml"
CONTROL_URL="http://127.0.0.1:9099"

if [[ ! -f "$CONFIG_FILE" ]]; then
  echo "Error: $CONFIG_FILE was not found." >&2
  exit 1
fi

if ! command -v maestro >/dev/null 2>&1; then
  echo "Error: maestro command was not found in PATH." >&2
  exit 1
fi

if [[ $# -eq 0 ]]; then
  echo "Usage: $0 <flow-or-directory> [maestro test options...]" >&2
  exit 1
fi

ENV_ARGS=()

while IFS=$'\t' read -r key value; do
  [[ -z "$key" ]] && continue
  value="${value%$'\r'}"

  if [[ ${#value} -ge 2 ]]; then
    if [[ "${value:0:1}" == '"' && "${value: -1}" == '"' ]]; then
      value="${value:1:${#value}-2}"
    elif [[ "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then
      value="${value:1:${#value}-2}"
    fi
  fi

  ENV_ARGS+=("-e" "${key}=${value}")

  if [[ "$key" == "MITMPROXY_CONTROL_URL" ]]; then
    CONTROL_URL="$value"
  fi
done < <(
  awk '
    /^env:[[:space:]]*$/ { in_env = 1; next }
    in_env && /^[^[:space:]#]/ { exit }
    in_env && /^[[:space:]]+[A-Za-z_][A-Za-z0-9_]*:[[:space:]]*/ {
      line = $0
      sub(/^[[:space:]]+/, "", line)
      key = line
      sub(/:.*/, "", key)
      value = line
      sub(/^[^:]+:[[:space:]]*/, "", value)
      print key "\t" value
    }
  ' "$CONFIG_FILE"
)

cleanup() {
  local status=$?
  trap - EXIT
  curl -fsS -X POST -H 'Content-Type: application/json' -d '{}' "${CONTROL_URL}/mode/normal" >/dev/null 2>&1 || true
  exit "$status"
}

trap cleanup EXIT

maestro test --config "$CONFIG_FILE" "${ENV_ARGS[@]}" "$@"

set -euo pipefailの意味

  • -e:途中のコマンドが失敗したら終了
  • -u:未定義変数を参照したらエラー
  • pipefail:パイプ途中の失敗も見逃さない

execを使うとcleanupできない

最初は末尾をexec maestro test ...としていたが、execは現在のshellをMaestroプロセスで置き換える。Maestro終了後にshell側のcleanupを実行したいので、execを外してtrap cleanup EXITを使う形にした。これで途中のassert失敗でも最後にnormalへ戻せる。

フロー末尾のnormalだけでは足りない

- runScript:
    file: ../scripts/set-network-mode.js
    env:
      MODE: '500'

# 異常系テスト

- runScript:
    file: ../scripts/set-network-mode.js
    env:
      MODE: normal

正常終了ならこれでよいが、途中のassertVisibleで失敗すると最後のnormalまで到達しない。そのまま次のテストを始めると500やofflineが残るため、flow内の復帰とshellのEXIT trapを併用する。

共通JavaScriptだけを先にテストする

appId: ${APP_ID}
name: mitmproxy network mode script test
---
- runScript:
    file: ../scripts/set-network-mode.js
    env:
      MODE: '500'

- runScript:
    file: ../scripts/set-network-mode.js
    env:
      MODE: normal
./.maestro/scripts/maestro-test.sh .maestro/p0/network-mode-script-test.yaml

別ターミナルでcurl http://127.0.0.1:9099/modeを確認しておくと切替状態を追いやすい。なおrunScriptの相対パスは、Maestroコマンドを実行したディレクトリではなく、呼び出し元flowファイルからの相対位置として考える。

実際のMaestroテストでの使い方

- runScript:
    file: ../scripts/set-network-mode.js
    env:
      MODE: '500'

- tapOn:
    id: game-update-button

- assertVisible: '対局の更新に失敗しました'

- runScript:
    file: ../scripts/set-network-mode.js
    env:
      MODE: normal

完全な通信失敗を確認したい場合はMODE: offlineに変える。実際のエラー表示文言はアプリ側の実装に合わせる。

今回の構成で整理できたこと

  • MaestroのsetAirplaneModeに依存せず、API通信だけを確実に異常化できる。
  • offlineとHTTP 500を別々にテストできる。
  • アプリ側のAPI URLは常に6080番のままでよい。
  • mitmproxy GUIは8090番、制御APIは9099番に分離。
  • ネットワークモード変更処理を共通JSにし、各flowではMODEだけ指定。
  • config.yamlの環境変数はCLIラッパーで-eへ展開。
  • MaestroのPOSTにはbodyが必要。
  • フロー末尾のnormalだけでは途中失敗に弱いので、shell側でもEXIT trapで復帰。
  • cleanupを使うラッパーではexec maestro testにしない。

Maestro Studioについて

Maestro Studioでも環境変数は設定できるが、今回のように環境変数が増えると1個ずつ入力するのが面倒だった。CLIでは.maestro/config.yamlを1か所の設定元にし、実行ラッパーでまとめて読み込む方が扱いやすかったので、現在は主にCLIを使っている。

今後追加したい異常系

今回はnormal、offline、500まで。addon側のモードを増やせば、遅延、特定APIだけ500、429、503、レスポンス破損なども同じ仕組みで追加できそう。ただし、まずは実際にアプリで必要な異常ケースだけ追加する。

参考

タイトルとURLをコピーしました