ZhgChg.Li

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

Photo by freestocks

前書き

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

ユニットテスト

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

UIテスト

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

SnapShot テスト

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

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

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

エンドツーエンドテスト

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

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

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

QAチーム

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

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

自動化

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

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データです。確実なデータを保証できないと、テストの不安定さが増し、誤検知が発生し、最終的にTest Planへの信頼が失われてしまいます。

例えば、チェックアウトのテストで、商品が販売停止や消失する可能性があり、これらの状態変化がアプリ側で制御できない場合、上述の問題が発生する可能性があります。

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

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

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

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

私たちは別の考え方をしてみましょう。もしUIをSnapshotで画像として保存し、Replayで検証テストができるなら、APIも同じことができるでしょうか?APIのRequestとResponseを保存して、後でReplayして検証テストを行うことは可能でしょうか?

本記事の要点を紹介します:「Snapshot API Local Mock Server」を構築し、APIリクエストを記録(Record)してレスポンスを再生(Replay)することで、APIデータへの依存を切り離します。

本文はPOCの概念検証のみであり、まだ高いカバレッジのEnd To End Testingを完全に実装していません。そのため、本手法は参考としてご利用ください。現状の環境で新たな気づきとなれば幸いです

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

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

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

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

示意図

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

UI Testingでボタンのクリックや入力欄への入力などの操作方法は、本記事の主要な研究対象ではありません。既存のテストフレームワークをそのまま利用してください。

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

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

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

正向プロキシ Regular Proxy:

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

リバースプロキシ Reverse Proxy:

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

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

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

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

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

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

  • Codebase 内の API ホストはテスト時に Proxy サーバーの IP に置き換える必要があります。

  • Reverse Proxyを有効にする際に、どのドメインにProxyを適用するか宣言する必要があります。

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

iOSアプリに対応しており、以下はiOSとリバースプロキシを用いたPOCの例です。Androidでも同様に利用可能です。

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

我們需要讓 App 知道現在正在跑 End-to-End Testing 才能在 App 程式裡加上 API Host 替換邏輯:

アプリが現在End-to-Endテストを実行していることを認識できるようにし、アプリ内でAPIホストの置き換えロジックを追加する必要があります:

// UI Testing ターゲット:
let app = XCUIApplication()
app.launchArguments = ["duringE2ETesting"]
app.launch()
// アプリターゲット:
var api: String = {
  if (CommandLine.arguments.contains("duringE2ETesting")) {
    // UIテスト中の場合
    return "http://127.0.0.1:8080" // ローカルモックリバースプロキシサーバーのアドレス
  } else {
    return "https://api.zhgchg.li" // 実際のリモートAPIサーバー
  }
}
//...

私たちは Network 層で判定と差し替えを行います。

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

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

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

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

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

一部の構成と本記事の内容が調整され、オープンソース化の際にさらに修正されました:

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

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

  • 一部の誤りを修正しました

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

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

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

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

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

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

[MITMProxy

MITMProxy 官網](https://mitmproxy.org){:target=”_blank”} に従ってインストールを完了してください:

brew install mitmproxy

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

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

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

  • mitmdump はインタラクティブでないターミナル出力を提供します。

Record & Replay の実装

MITMProxyのリバースプロキシは、ネイティブでリクエストの記録(またはダンプ)やリクエストのリプレイマッピング機能を持っていないため、この機能を実装するために自作のスクリプトが必要です。

mock.py :

"""
例:
    録画: 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(別名テストケース名) / Reverseの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 設定ファイルのロジック設計は以下の通りです:

{
  "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 Request時に異なり、Local Responseにマッピングできなくなります。そこで、この状況を処理するために config.json を用意し、Endpoint Pathごとまたはグローバルに特定のパラメータをHash計算から除外する設定を行うことで、同じマッピング結果を得られるようにしています。

iterable

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

Reverse ProxyのRecordモードを有効にする:

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

Reverse Proxy Replay モードの有効化:

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

mock.py の内容例

from mitmproxy import http

def request(flow: http.HTTPFlow) -> None:
    # リクエストをログに記録
    print(f"Request URL: {flow.request.url}")

def response(flow: http.HTTPFlow) -> None:
    # レスポンスをログに記録
    print(f"Response status code: {flow.response.status_code}")

組み立て & プルーフ・オブ・コンセプト

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

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

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

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モード使用

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

「mitmdump」コマンドの使用例です。

-m reverseオプションでリバースプロキシを指定し、

-sオプションでスクリプトを指定しています。

–setオプションでフォルダ名や設定ファイルを指定しています。

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

  • 左:テスト成功 ✅

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

6. Proof Of Concept ✅

概念検証に成功し、Reverse Proxy Serverを実装してAPIリクエストとレスポンスを自分で保存し、テスト時にMock API ServerとしてAppにデータを返すことができることを確認しました🎉🎉🎉。

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

今後の予定と雑記

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

  1. maestro UIテスティングツールとの連携

  2. CI/CDフロー統合設計(リバースプロキシをどのように自動起動するか?どこで起動するか?)

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

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

  5. 送信されたTracking Requestの検証には、Request Bodyを保存し、どのTracking Event Dataが含まれているか、またプロセスに沿った適切なイベントが送信されているかを確認する必要があります
#...
    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 によって変換されました。

GitHub で編集
この記事を改善
本記事は Medium で初公開
オリジナルを読む
この記事をシェア
リンクをコピー · SNS でシェア
ZhgChgLi
著者

ZhgChgLi

An iOS, web, and automation developer from Taiwan 🇹🇼 who also loves sharing, traveling, and writing.

コメント