emahiro/b.log

日々の勉強の記録とか育児の記録とか。

Firebase の API をエミュレートしてローカルでテストしやすくする

Overview

※ Firebase Go SDK のお話です。

Firebase を始め、GCP の公式の Go のライブラリは interceptor 等を公式で提供していないことが多く、local でテストをするときに GCP のライブラリそのものをモックするのが一般的なテスト方法かと思いますが、そもそもモックを差し替えたりと行ったことはテスト時の記述量が多くなってしまうので自分はあまり好みません。
Go にはせっかく httptest というテストサーバーを立ち上げるテストライブラリが公式から提供されているので極力こちらを使って実際のサーバーの振る舞いをエミュレートしてテストを書くことが多いです。

今回は同じアプローチで Firease のテストを書いてみます。

テストサーバーの作り方

Firebase Go SDKFIREBASE_AUTH_EMULATOR_HOST という環境変数が実行時にセットされていると自動的に emulator を利用して、実際 Firebase で行われている検証をスキップできるので、この環境変数をセットした httptest サーバーに http のリクエストをルーティングして payload の検証のみを行うだけで Firebase の通信をエミュレートできます。
※ payload の検証は Firebase の Call する API ごとに異なるので Emulate したい API ごとに httptest サーバー内の振る舞いは変更する必要があります。

実際のコードは この辺 です。

https://github.com/firebase/firebase-admin-go/blob/26dec0b7589ef7641eefd6681981024079b8524c/auth/token_verifier.go#L172-L175:embed:lang=go:h150

この isEmulator は Firebase クライアントを初期化するときに差し込んでいます。

https://github.com/firebase/firebase-admin-go/blob/d515faf47673ae79005d4b0abceca74716a5ac92/auth/auth.go#L69-L80:embed:lang=go:h300

これを使って httptest サーバーを立てるコードは以下です。

func UseFakeFirebase(t *testing.T) {
    t.Helper()
    ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
        //....
    }))
    t.Cleanup(ts.Close)
    t.Setenv("FIREBASE_AUTH_EMULATOR_HOST", strings.TrimPrefix(ts.URL, "http://"))
    t.Setenv("GOOGLE_CLOUD_PROJECT", "test")
}

利用するときは UseFakeFirebase(t) でテストサーバーが立ち上げます。
環境変数を実行時にセットしてるのでテスト内における Firebase のリクエストは自動的に httptest で立ち上げたサーバーに routing されます。

今回は Firebase.VerifyIDTokenAPI を Emulate するサーバーを実装してみました。
LLM に聞いて必要な API (Firebase Go SDK 内で実際に叩いている Firebase の API) について調べさせたところ accounts:lookup という path を call してる事がわかったので、以下のようになります。

   ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if strings.Contains(r.URL.Path, "accounts:lookup") {
            response := map[string]any{
                "kind": "identitytoolkit#GetAccountInfoResponse",
                "users": []map[string]any{
                    {
                        "localId":       "test-user",
                        "email":         "test@example.com",
                        "emailVerified": true,
                        "displayName":   "Test User",
                        "disabled":      false,
                    },
                },
            }

            w.Header().Set("Content-Type", "application/json")
            _ = json.NewEncoder(w).Encode(response)
            return
        }

        w.WriteHeader(http.StatusOK)
    }))

また検証するには Firebase 用のダミーの IdToken (JWT形式) を作ってこれを渡して疑似検証プロセス(payload の中に必須では行っていて欲しい field 群のみ Validation 処理が入っている) を通す必要があるのでそのダミー Token も作成します。

func genIdToken() string {
    now := time.Now().Unix()

    // JWT Header
    header := map[string]any{
        "alg": "none",
        "typ": "JWT",
    }

    // JWT Payload with Firebase-specific claims
    payload := map[string]any{
        "iss":            "https://securetoken.google.com/test",
        "aud":            "test",
        "sub":            "test-sub",
        "user_id":        "test-user_id",
        "iat":            now,
        "exp":            now + 3600,
        "auth_time":      now,
        "email":          "hoge@example.com",
        "email_verified": true,
        "firebase": map[string]any{
            "identities": map[string]any{
                "email": []string{"hoge@example.com"},
            },
            "sign_in_provider": "custom",
        },
    }

    headerStr := mustEncode(header)
    payloadStr := mustEncode(payload)

    return headerStr + "." + payloadStr + "."
}

func mustEncode(v any) string {
    b, _ := json.Marshal(v)
    return base64.RawURLEncoding.EncodeToString(b)
}

これ実際に利用するときはこんな感じでテストサーバー起動後、トークンを作成して VerifyToken を Call する、という流れで VeridyToken API の処理がエミュレートされます。

testhelper.UseFakeFirebase(t)
// Firebase の初期化処理 (略)

testToken := genIdToken()
if err := localVerifyIdToken(ctx, testToken); err != nil {...}

ここまで書きましたが、どんな API Call がされていて dummy 処理としてはどういう物が必要なのか?ということは実は LLM に聞くと大体回答してくれます。
ちょっと前までは実際に API の仕様調べてたんですけど、こういうテストのための事前準備と行った実装は LLM でだいぶ進めやすくなったなと思います。