麻雀集計スマホアプリの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先へ流れる。offlineはflow.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は動いている。今回の確認ではnormal、offline、500の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、レスポンス破損なども同じ仕組みで追加できそう。ただし、まずは実際にアプリで必要な異常ケースだけ追加する。
