記事

POC AppのE2Eテスト|Local Snapshot API Mock Serverで既存APIを効率検証

現存アプリとAPI構造のE2Eテスト課題を解決。Local Snapshot API Mock Serverを活用し、テスト環境構築と動作検証を迅速化、品質向上を実現します。

POC AppのE2Eテスト|Local Snapshot API Mock Serverで既存APIを効率検証

本記事は AI による翻訳をもとに作成されています。表現が不自然な箇所がありましたら、ぜひコメントでお知らせください。

記事一覧


[POC] アプリのエンドツーエンドテスト ローカルスナップショットAPIモックサーバー

既存のAppおよび既存のAPIアーキテクチャに対するE2Eテスト実現の可能性検証

Photo by [freestocks](https://unsplash.com/@freestocks?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by freestocks

はじめに

長年オンラインで稼働しているプロジェクトとして、安定性を継続的に向上させることは非常に難しい課題です。

ユニットテスト

Appは開発言語がSwift/Kotlinの静的+コンパイル+強型付け、またはObjective-CからSwiftへの動的から静的への移行で、開発時にテスト可能性を考慮してインターフェースの依存を切り離していないため、後からUnit Testingを追加するのはほぼ不可能です。しかしリファクタリングの過程で不安定要素も生まれ、鶏が先か卵が先かの問題に陥ります。

UIテスト

UIのインタラクションやボタンのテストは、新規開発または既存画面のデータ依存を少し切り離すだけで実現できます。

スナップショットテスト

調整前後のUI表示内容やスタイルが一致しているかを検証します。UIテストと同様に、新規開発や既存画面でデータ依存を少し分離すれば実現可能です。

Storyboard/XIBからCode LayoutやUIViewへの変換、Objective-CからSwiftへの移行に非常に便利です。直接 pointfreeco / swift-snapshot-testing を導入して素早く実装できます。

後からUIテストやスナップショットテストを追加することは可能ですが、カバーできるテスト範囲は非常に限られています。多くのエラーはUIのスタイルではなく、プロセスやロジックの問題であり、ユーザーの操作中断を引き起こします。特に決済プロセスで発生すると、収益に関わるため問題の重大度が非常に高くなります。

エンドツーエンドテスト

前述の通り、現行プロジェクトで単体テストを簡単に追加できず、単体テストをまとめて統合テストを行うこともできないため、ロジックやフローの保護には、外部からのエンドツーエンド(E2E)ブラックボックステストしか残っていません。ユーザー視点で操作フローを確認し、重要なプロセス(登録/決済など)が正常に動作するかをチェックします。

重要な機能のリファクタリングに対しても、リファクタリング前のフローテストを先に作成し、リファクタリング後に再検証して、機能が期待通りに動作することを確認できます。

リファクタリングと同時にUnit TestingとIntegration Testingを追加して安定性を向上させ、鶏が先か卵が先かの問題を解消します。

QAチーム

End-to-End Testing の最も直接的で手っ取り早い方法は、QAチームにテストプランに従って手動テストを行ってもらい、その後継続的に改善や自動化を導入することです。コストを計算すると、最低でも2名のエンジニアと1名のリーダーが半年から1年かけて成果を出す必要があります。

時間とコストを評価すると、現状でできることや将来QAチームのために準備できることはありますか?QAチームができた際には、すぐに最適化や自動化、さらにはAIの導入に進めるようにしたいです。

自動化

現段階では自動化End-to-Endテストの導入を目標とし、CI/CDの工程で自動チェックを行います。テスト内容は完璧である必要はなく、重大なプロセスの問題を防げれば十分価値があります。後から徐々にテストプランを改善し、カバー範囲を拡充していきます。

End-to-End Testing —技術的な課題

UI 操作の問題

Appの仕組みは、別のテスト用Appを使ってテスト対象のAppを操作し、View Hierarchyから対象のオブジェクトを探す形に近いです。また、テスト中にテスト対象のAppのログや出力を取得することはできません。なぜなら、本質的に異なる2つのAppだからです。

iOSでは、効率と正確性を高めるためにViewのAccessibility Identifierを充実させる必要があり、またAlert(例:プッシュ通知の許可要求)にも対応する必要があります。

Androidでは以前の実装でComposeとFragmentを混用するとターゲットオブジェクトが見つからない問題がありましたが、Teammateによると、新しいバージョンのComposeではすでに解決されています。

以上の従来のよくある問題に加え、さらに大きな問題は二つのプラットフォームの統合が難しいこと(1つのテストで両方のプラットフォームを実行すること)です。現在、私たちは新しいテストツール mobile-dev-inc / maestro を試しています:

YAMLでテストプランを書き、両プラットフォームでテストを実行できます。詳細な使い方や使用感については、別のチームメイトの記事共有をお待ちください。cc’ed Alejandra Ts. 😝。

API データの問題

AppのE2Eテストにおける最大の変数はAPIデータです。確実なデータを提供できないと、テストの不安定さが増し、誤検知が発生し、最終的にはテストプランへの信頼が失われてしまいます。

例えば、チェックアウトのフローをテストする場合、商品が販売停止や消失する可能性があり、これらの状態変化がアプリで制御できない場合、上記のような状況が発生しやすくなります。

データの問題を解決する方法はいくつかあります。クリーンなステージングやテスト環境を構築する方法や、Open APIを基にした自動生成モックAPIサーバーを作る方法です。しかし、これらはすべてバックエンドやAPIの外部要因に依存します。さらに、バックエンドAPIもアプリと同様に長年オンラインで稼働しているプロジェクトであり、一部の仕様はまだリファクタリングやマイグレーション中のため、現時点ではモックサーバーを用意できません。

以上の要因に基づき、ここで立ち止まっても問題は変わらず、鶏が先か卵が先かの問題も突破できません。結局は「思い切って先に変更し、問題が起きたら対処する」しかありません。

スナップショットAPIローカルモックサーバー

「考えがぶれなければ、方法は困難よりも必ず多い」

UIのSnapshotで画像をキャプチャしてReplayで検証テストができるなら、APIでも同様にできるのではないでしょうか?
APIのRequestとResponseを保存して、後でReplayして検証テストを行うことは可能でしょうか?

本記事のポイント:Snapshot API Local Mock Serverの構築によるAPIリクエストの記録とレスポンスの再生、APIデータへの依存からの切り離し。

本文はPOCの概念検証のみを行っており、まだ高いカバレッジのエンドツーエンドテストを完全に実装しているわけではありません。したがって、本手法は参考例としてご利用ください。現状の環境で新たな気づきとなれば幸いです

スナップショットAPIローカルモックサーバー

コアコンセプト — APIデータの録画と再生

[Record] — End-to-Endテストのテストケース開発完了後、録画パラメータをオンにしてテストを一度実行すると、すべてのAPIリクエスト&レスポンスが各テストケースのディレクトリに保存されます。

[Replay] — テストケース実行時に、リクエストに応じてテストケースディレクトリから対応する録画済みのレスポンスデータを取得し、テストフローを完了します。

図示

購入フローのテストを行うとします。ユーザーがアプリを開き、ホーム画面で商品カードをタップして商品詳細ページに入り、下部の購入ボタンを押し、ログイン画面が表示されてログインを完了し、購入を完了すると、購入成功の通知が表示されます:

UIテストでボタンのクリックや入力フィールドへの入力などを制御する方法は、本記事の主な研究対象ではありません。既存のテストフレームワークを直接参照してご利用ください。

レギュラープロキシまたはリバースプロキシ

Record & Replay APIを実現するには、AppとAPIの間にProxyを挟んで中間者攻撃を行う必要があります。詳細は私の以前の記事「APPはHTTPS通信を使っているのに、データが盗まれた。」をご参照ください。

簡単に言うと、AppとAPIの間に代理の伝達者が一人増えたようなもので、まるでメモを渡すように、双方のリクエストとレスポンスはすべて彼を経由します。彼はメモの内容を開いて確認できるし、メモの内容を偽造して双方に渡すこともできますが、双方はあなたが介入していることに気づきません。

正向プロキシ Regular Proxy:

正のプロキシは、クライアントがプロキシサーバーにリクエストを送信し、プロキシサーバーがそのリクエストをターゲットサーバーに転送し、ターゲットサーバーの応答をクライアントに返す仕組みです。正のプロキシモードでは、プロキシサーバーがクライアントを代表してリクエストを発行します。クライアントはプロキシサーバーのアドレスとポート番号を明示的に指定し、リクエストをプロキシサーバーに送信する必要があります。

リバースプロキシ Reverse Proxy:

リバースプロキシはフォワードプロキシとは逆で、ターゲットサーバーとクライアントの間に位置します。クライアントはリバースプロキシサーバーにリクエストを送り、リバースプロキシサーバーは一定のルールに従ってリクエストをバックエンドのターゲットサーバーに転送し、ターゲットサーバーのレスポンスをクライアントに返します。クライアントから見ると、ターゲットサーバーはリバースプロキシサーバーのように見え、クライアントはターゲットサーバーの実際のアドレスを知る必要がありません。

私たちのニーズに対しては、フォワードでもリバースでも目的を達成できます。唯一考慮すべき点はプロキシの設定方法です:

正方向プロキシは、パソコンやスマホ、エミュレーターのネットワーク設定でプロキシを設定する必要があります:

  • Androidはエミュレーター内で個別にプロキシを直接設定できます

  • iOS Simulatorはパソコンと同じネットワーク環境のため、個別にProxyを設定できません。パソコンの設定を変更してProxyを適用する必要があり、パソコンのすべての通信がこのProxyを経由します。また、同時にProxymanやCharlesなど他のネットワークツールを起動すると、これらのソフトがProxy設定を強制的に変更する可能性があり、動作しなくなることがあります。

リバースプロキシは、Codebase内のAPIホストを変更し、代理するすべてのAPIドメインを宣言する必要があります:

  • Codebase内のAPIホストをテスト時にプロキシサーバーのIPに置き換える

  • リバースプロキシを有効にする際に、どのドメインをプロキシにかけるか宣言する必要があります。

  • 宣言されたドメインのみがプロキシを通り、宣言されていないものは直接接続されます。

iOSアプリに合わせて、以下はiOSとリバースプロキシを使ったPOCの例です。Androidでも同様に使用可能です。

iOSアプリに現在End-to-Endテストが実行中であることを認識させる方法

Appに現在End-to-Endテストが実行中であることを認識させ、App内でAPIホストの置換ロジックを追加する必要があります:

1
2
3
4
// UIテストターゲット:
let app = XCUIApplication()
app.launchArguments = ["duringE2ETesting"]
app.launch()

私たちはネットワーク層で判定と差し替えを行います。

これはやむを得ない調整ですが、できるだけテストのためにAppのコードを変更しないようにしてください。

MITMProxyを使ったリバースプロキシサーバーの実装

Swiftで独自にSwiftサーバーを開発して実現することも可能ですが、本記事はPOCのため、直接MITMProxyツールを使用しています。

[2023–09–04 更新] Mitmproxy-rodo はオープンソース化されました

以下の実装内容はすでに mitmproxy-rodo プロジェクトでオープンソース化されています。ぜひ直接参照してご利用ください。

一部の構成と本記事の内容が調整され、オープンソース化時に後続の調整が行われました:

  • 保存ディレクトリの構造を host / requestPath / method / hash に変更する

  • Header情報の保存を修正し、純粋なJSON文字列ではなくバイトデータにする

  • 一部の誤りを修正する

  • Set-Cookie の有効期限自動延長機能を追加する

⚠️ 以下のスクリプトはデモ参考用のみであり、今後のスクリプト調整はオープンソースプロジェクトで管理されます。

⚠️ 以下のスクリプトはデモ参考用のみであり、今後のスクリプト調整はオープンソースプロジェクトで管理されます。

⚠️ 以下のスクリプトはデモ参考用のみであり、今後のスクリプト調整はオープンソースプロジェクトで管理されます。

⚠️ 以下のスクリプトはあくまでデモ用の参考です。今後のスクリプト調整はオープンソースプロジェクトで管理します。

⚠️ 以下のスクリプトはデモ参考用のみであり、今後のスクリプト調整はオープンソースプロジェクトで管理されます。

MITMProxy

MITMProxy 官網 に従ってインストールを完了してください:

1
brew install mitmproxy

MITMProxy の詳細な使い方は、以前の記事「APPはHTTPSで通信しているが、それでもデータが盗まれた。」を参照してください。

  • mitmproxy はインタラクティブなコマンドラインインターフェースを提供します。

  • mitmweb はブラウザベースのグラフィカルユーザーインターフェースを提供します。

  • mitmdump は非対話型の端末出力を提供します。

Record & Replay の実現

MITMProxyのリバースプロキシは、リクエストの記録(Recordまたはdump)やリクエストの再生(Mapping Request Replay)機能をネイティブに持っていないため、この機能を実現するために自作のスクリプトが必要です。

mock.py :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
"""
例:
    録画: mitmdump -m reverse:https://yourapihost.com -s mock.py --set record=true --set dumper_folder=loginFlow --set config_file=config.json
    再生: mitmdump -m reverse:https://yourapihost.com -s mock.py --set dumper_folder=loginFlow --set config_file=config.json
"""

import re
import logging
import mimetypes
import os
import json
import hashlib

from pathlib import Path
from mitmproxy import ctx
from mitmproxy import http

class MockServerHandler:

    def load(self, loader):
        self.readHistory = {}
        self.configuration = {}

        loader.add_option(
            name="dumper_folder",
            typespec=str,
            default="dump",
            help="レスポンスダンプのディレクトリ。テストケース名ごとに作成可能",
        )

        loader.add_option(
            name="network_restricted",
            typespec=bool,
            default=True,
            help="ローカルにマッピングデータがない場合... trueなら404を返し、falseなら実際のリクエストを送信してデータを取得します。",
        )

        loader.add_option(
            name="record",
            typespec=bool,
            default=False,
            help="trueに設定するとリクエストのレスポンスを録画します",
        )

        loader.add_option(
            name="config_file",
            typespec=str,
            default="",
            help="設定ファイルのパス。サンプルファイルは下記参照",
        )
    
    def configure(self, updated):
        self.loadConfig()

    def loadConfig(self):
        configFile = Path(ctx.options.config_file)
        if ctx.options.config_file == "" or not configFile.exists():
            return

        self.configuration = json.loads(open(configFile, "r").read())

    def hash(self, request):
        query = request.query
        requestPath = "-".join(request.path_components)

        ignoredQueryParameterByPaths = self.configuration.get("ignored", {}).get("paths", {}).get(request.host, {}).get(requestPath, {}).get(request.method, {}).get("queryParamters", [])
        ignoredQueryParameterGlobal = self.configuration.get("ignored", {}).get("global", {}).get("queryParamters", [])

        filteredQuery = []
        if query:
            filteredQuery = [(key, value) for key, value in query.items() if key not in ignoredQueryParameterByPaths + ignoredQueryParameterGlobal]
        
        formData = []
        if request.get_content() != None and request.get_content() != b'':
            formData = json.loads(request.get_content())
        
        # または formData = request.urlencoded_form
        # または formData = request.multipart_form
        # API設計による

        ignoredFormDataParametersByPaths = self.configuration.get("ignored", {}).get("paths", {}).get(request.host, {}).get(requestPath, {}).get(request.method, {}).get("formDataParameters", [])
        ignoredFormDataParametersGlobal = self.configuration.get("ignored", {}).get("global", {}).get("formDataParameters", [])

        filteredFormData = []
        if formData:
            filteredFormData = [(key, value) for key, value in formData.items() if key not in ignoredFormDataParametersByPaths + ignoredFormDataParametersGlobal]
        
        # 辞書をJSON文字列にシリアライズ
        hashData = {"query":sorted(filteredQuery), "form": sorted(filteredFormData)}
        json_str = json.dumps(hashData, sort_keys=True)

        # SHA-256ハッシュ関数を適用
        hash_object = hashlib.sha256(json_str.encode())
        hash_string = hash_object.hexdigest()
        
        return hash_string

    def readFromFile(self, request):
        host = request.host
        method = request.method
        hash = self.hash(request)
        requestPath = "-".join(request.path_components)

        folder = Path(ctx.options.dumper_folder) / host / method / requestPath / hash

        if not folder.exists():
            return None

        content_type = request.headers.get("content-type", "").split(";")[0]
        ext = mimetypes.guess_extension(content_type) or ".json"


        count = self.readHistory.get(host, {}).get(method, {}).get(requestPath, {}) or 0

        filepath = folder / f"Content-{str(count)}{ext}"

        while not filepath.exists() and count > 0:
            count = count - 1
            filepath = folder / f"Content-{str(count)}{ext}"

        if self.readHistory.get(host) is None:
            self.readHistory[host] = {}
        if self.readHistory.get(host).get(method) is None:
            self.readHistory[host][method] = {}
        if self.readHistory.get(host).get(method).get(requestPath) is None:
            self.readHistory[host][method][requestPath] = {}

        if filepath.exists():
            headerFilePath = folder / f"Header-{str(count)}.json"
            if not headerFilePath.exists():
                headerFilePath = None
            
            count += 1
            self.readHistory[host][method][requestPath] = count

            return {"content": filepath, "header": headerFilePath}
        else:
            return None


    def saveToFile(self, request, response):
        host = request.host
        method = request.method
        hash = self.hash(request)
        requestPath = "-".join(request.path_components)

        iterable = self.configuration.get("ignored", {}).get("paths", {}).get(request.host, {}).get(requestPath, {}).get(request.method, {}).get("iterable", False)
        
        folder = Path(ctx.options.dumper_folder) / host / method / requestPath / hash

        # ディレクトリがなければ作成
        if not folder.exists():
            os.makedirs(folder)

        content_type = response.headers.get("content-type", "").split(";")[0]
        ext = mimetypes.guess_extension(content_type) or ".json"

        repeatNumber = 0
        filepath = folder / f"Content-{str(repeatNumber)}{ext}"
        while filepath.exists() and iterable == False:
            repeatNumber += 1
            filepath = folder / f"Content-{str(repeatNumber)}{ext}"
        
        # ファイルに書き込み
        with open(filepath, "wb") as f:
            f.write(response.content or b'')
            
        
        headerFilepath = folder / f"Header-{str(repeatNumber)}.json"
        with open(headerFilepath, "wb") as f:
            responseDict = dict(response.headers.items())
            responseDict['_status_code'] = response.status_code
            f.write(json.dumps(responseDict).encode('utf-8'))

        return {"content": filepath, "header": headerFilepath}

    def request(self, flow):
        if ctx.options.record != True:
            host = flow.request.host
            path = flow.request.path

            result = self.readFromFile(flow.request)
            if result is not None:
                content = b''
                headers = {}
                statusCode = 200

                if result.get('content') is not None:
                    content = open(result['content'], "r").read()

                if result.get('header') is not None:
                    headers = json.loads(open(result['header'], "r").read())
                    statusCode = headers['_status_code']
                    del headers['_status_code']

                
                headers['_responseFromMitmproxy'] = '1'
                flow.response = http.Response.make(statusCode, content, headers)
                logging.info("ローカルからレスポンスを返しました: "+str(result['content']))
                return

            if ctx.options.network_restricted == True:
                flow.response = http.Response.make(404, b'', {'_responseFromMitmproxy': '1'})
        
    def response(self, flow):
        if ctx.options.record == True and flow.response.headers.get('_responseFromMitmproxy') != '1':
            result = self.saveToFile(flow.request, flow.response)
            logging.info("レスポンスをローカルに保存しました: "+str(result['content']))

addons = [MockServerHandler()]

公式ドキュメントは公式ドキュメントを参照し、必要に応じてスクリプト内容を調整してください。

このスクリプトの設計ロジックは以下の通りです:

  • ファイルパスのロジック: dumper_folder(別名テストケース名) / リバースのAPIホスト / HTTPメソッド / パスを-で結合(例:app/launch -> app-launch) / ハッシュ(クエリとPOST内容を取得) /

  • ファイルのロジック:レスポンスの内容:Content-0.xxxContent-1.xxx(同じリクエストを2回目に送信)…以下同様;レスポンスのヘッダー情報:Header-0.jsonContent-xと同じロジック)

  • 保存時はパスとファイルの論理に従って順番に保存されます;Replay時も同様に順番に取り出されます

  • 回数が一致しない場合、例えばReplay時に同じパスが3回呼ばれたが、Recordで保存されたデータが2回目までしかない場合は、2回目、つまり最後の結果を返し続けます。

  • recordTrue の場合、ターゲットサーバーにリクエストを送りレスポンスを取得し、上記のロジックに従って保存します。False の場合はローカルからデータを読み込むだけです(Replayモードと同じ)。

  • network_restrictedFalse の場合、ローカルにマッピングデータがなければ直接 404 を返します;True の場合はターゲットサーバーにリクエストを送ってデータを取得します。

  • _responseFromMitmproxy はレスポンスメソッドに対して現在のレスポンスがローカルから来ていることを通知し、無視してよいことを示します。_status_code は Header.json フィールドを利用して HTTP レスポンスのステータスコードを保存します。

config_file.json 設定ファイルのロジック設計は以下の通りです:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
  "ignored": {
    "paths": {
      "yourapihost.com": {
        "add-to-cart": {
          "POST": {
            "queryParamters": [
              "created_timestamp"
            ],
            "formDataParameters": []
          }
        },
        "api-status-checker": {
          "GET": {
            "iterable": true
          }
        }
      }
    },
    "global": {
      "queryParamters": [
        "timestamp"
      ],
      "formDataParameters": []
    }
  }
}

queryParamters formDataParameters

一部のAPIパラメータは呼び出しごとに変わる可能性があります。例えば、あるエンドポイントは時間パラメータを含むことがあります。この場合、サーバーの設計により、Hash(Query Parameter & Body Content) の値がReplayリクエスト時に異なり、ローカルレスポンスにマッピングできなくなります。そこで、この状況に対応するために config.json を追加しました。Endpointパスごとやグローバルに、特定のパラメータをハッシュ計算から除外する設定ができ、同じマッピング結果を得られます。

iterable

一部のポーリングチェックAPIは定期的に繰り返し呼び出されるため、サーバーの設計上、多くの Content-x.xxxHeader-x.json ファイルが生成されます。しかし、気にしない場合は True に設定すると、レスポンスは常に Content-0.xxxHeader-0.json の最初のファイルに上書き保存されます。

リバースプロキシ録画モードの有効化:

1
mitmdump -m reverse:https://yourapihost.com -s mock.py --set record=true --set dumper_folder=loginFlow --set config_file=config.json

リバースプロキシリプレイモードの有効化:

1
mitmdump -m reverse:https://yourapihost.com -s mock.py --set dumper_folder=loginFlow --set config_file=config.json

組み立て & Proof Of Concept

0. Codebase内のHostの差し替え完了

そして、テスト実行時にAPIが http://127.0.0.1:8080 に変更されていることを確認してください。

1. Snapshot API ローカルモックサーバー(別名リバースプロキシサーバー)録画モードの起動

1
mitmdump -m reverse:https://yourapihost.com -s mock.py --set record=true --set dumper_folder=addCart --set config_file=config.json

2. E2EテストのUI操作の実行

Pinkoi iOS App を例に、以下のフローをテストします:

アプリ起動 -> ホーム -> スクロールダウン -> ウィッシュリストに似たアイテムセクション -> 最初の製品 -> 最初の製品をクリック -> 製品ページに入る -> カートに追加をクリック -> UIでカートに追加の反応 -> テスト成功 ✅

UIの自動操作方法は前述しましたが、ここではまず手動で同じプロセスをテストして結果を検証します。

3. Record結果の取得

操作完了ら ^ + C で Snapshot API Mock Server を終了し、ファイルディレクトリで録画結果を確認できます:

4. Replayで同じプロセスを検証、サーバー起動とReplayモードの使用

1
mitmdump -m reverse:https://yourapihost.com -s mock.py --set dumper_folder=addCart --set config_file=config.json

5. 先ほどのUI操作を再度実行して結果を検証する

  • 左:テスト成功 ✅

  • 右:録画していない商品のクリックをテストするとエラーになります(ローカルにデータがなく、network_restricted のデフォルトが False のため、ローカルにデータがない場合は直接404を返し、ネットワークからデータを取得しません)。

6. Proof Of Concept ✅

概念実証に成功しました。リバースプロキシサーバーを実装し、APIリクエストとレスポンスを自分で保存して、テスト時にモックAPIサーバーとしてAppにデータを返すことが確かに可能です 🎉🎉🎉。

[2023–09–04] mitmproxy-rodo がオープンソース化されました

今後の予定と雑記

本文は概念実証のみを扱っており、今後補完すべき点や実装可能な機能がまだ多くあります。

  1. maestro UIテストツールとの統合

  2. CI/CDパイプライン統合設計(リバースプロキシを自動起動する方法と設置場所)

  3. MITMProxyを開発ツールに組み込む方法は?

  4. より複雑なテストシナリオの検証

  5. 送信されたTracking Requestの検証には、Request Bodyを保存し、どのTracking Event Dataが含まれているか、またプロセスに沿った適切なイベントが送信されているかを確認する必要があります

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#...
    def response(self, flow):
        setCookies = flow.response.headers.get_all("set-cookie")
        # setCookies = ['ad=0; Domain=.xxx.com; expires=Wed, 23 Aug 2023 04:59:07 GMT; Max-Age=1800; Path=/', 'sessionid=xxxx; Secure; HttpOnly; Domain=.xxx.com; expires=Wed, 23 Aug 2023 04:59:07 GMT; Max-Age=1800; Path=/']
        
        # または Cookie のドメインを .xxx.com から 127.0.0.1 に置換
        setCookies = [re.sub(r"\s*\.xxx\.com\s*", "127.0.0.1", s) for s in setCookies]

        # そして セキュリティ関連の制限を削除
        setCookies = [re.sub(r";\s*Secure\s*", "", s) for s in setCookies]
        setCookies = [re.sub(r";\s*HttpOnly;\s*", "", s) for s in setCookies]

        flow.response.headers.set_all("Set-Cookie", setCookies)

        #...

Cookieに関する問題が発生した場合、例えばAPIがCookieを返しているのにAppが受け取れていない場合は、上記の調整を参考にしてください。

Pinkoiの最後の記事で

Pinkoiでの900日以上の時間の中で、私のキャリアやiOS / App開発、プロセスに関する多くの想像を実現しました。すべてのチームメンバーに感謝します。共にパンデミックを乗り越え、困難を経験しました。別れの勇気は、夢を追いかけて入社したあの時の勇気と同じです。

新しい人生の挑戦(エンジニアリングに限らず)を求めて航海中です。iOS、エンジニアリングマネジメント、新規プロダクトなど適した機会があればご連絡ください。 🙏🙏🙏

ご質問やご意見がございましたら、こちらからご連絡ください

Post は Medium から ZMediumToMarkdown によって変換されました。


🍺 Buy me a beer on PayPal

👉👉👉 Follow Me On Medium! (1,053+ Followers) 👈👈👈

本記事は Medium にて初公開されました(こちらからオリジナル版を確認)。ZMediumToMarkdown による自動変換・同期技術を使用しています。

Improve this page on Github.

本記事は著者により CC BY 4.0 に基づき公開されています。

© ZhgChgLi. All rights reserved.
閲覧数: 802,415+, 最終更新日時: 2026-01-15 11:14:58 +08:00

本サイトは Chirpy テーマを使用し、Jekyll 上で構築されています。
Medium の記事は ZMediumToMarkdown により変換されています。