インテグレーションテストに `query:` と `body:` キーワード引数を追加し、GET+JSON問題を修正
インテグレーションテストの process メソッドに query: と body: キーワード引数が追加され、リクエストパラメータの送信先を明示的に制御できるようになりました。従来の params: + as: :json + GETの組み合わせで発生していたAPIオンリーアプリの障害が根本的に解消されます。
背景
params: キーワード引数は、GETリクエストに as: :json を組み合わせた場合に曖昧な挙動を示していました。GETリクエストでパラメータをJSONエンコードして送信するには、本来はクエリ文字列に含めるのが適切ですが、リクエストボディにJSONを乗せる実装との兼ね合いで、従来コードはGETを POST に変換してから X-Http-Method-Override: GET ヘッダを付与するという回避策を採っていました。
このハックは #57131 で報告されたように、Rack::MethodOverride ミドルウェアを除外するAPIオンリーアプリでは機能しません。Rack::MethodOverride がなければ X-Http-Method-Override ヘッダは無視され、ルーターにはPOSTリクエストとして到達します。GET /items というルートに対して [POST] "/items": No route matches というルーティングエラーが発生し、テストが意図せず失敗していました。
この問題の本質は、params: の意味論があいまいだったことにあります。テストヘルパーが「HTTPメソッドに応じてパラメータの送信先を推測する」設計になっていたため、JSONエンコードとGETメソッドが衝突した際に予期しない副作用が生じていました。
技術的な変更
process メソッドのシグネチャに query: と body: の2つのキーワード引数が追加され、GETをPOSTに変換していたコードが削除されました。
変更前:
def process(method, path, params: nil, headers: nil, env: nil, xhr: false, as: nil)
request_encoder = RequestEncoder.encoder(as)
headers ||= {}
if method == :get && as == :json && params
headers["X-Http-Method-Override"] = "GET"
method = :post
end
# ...
end
変更後:
def process(method, path, params: nil, headers: nil, env: nil, xhr: false, as: nil, query: nil, body: nil)
request_encoder = RequestEncoder.encoder(as)
headers ||= {}
if query
query_string = query.is_a?(String) ? query : Rack::Utils.build_nested_query(query)
path = path.include?("?") ? "#{path}&#{query_string}" : "#{path}?#{query_string}"
end
# ...
body_params = body || (method != :get ? params : nil)
query_params = body ? nil : (method == :get ? params : nil)
end
各キーワード引数の役割は以下の通りです:
-
query:: HTTPメソッドに関わらず常にURLクエリ文字列に付加する。Hashまたはエンコード済みStringを受け付ける -
body:: HTTPメソッドに関わらず常にリクエストボディにas:でエンコードして送信する -
params:: 従来通りの挙動を維持。GETはクエリ文字列、それ以外はボディ
query: が指定された場合は Rack::Utils.build_nested_query でクエリ文字列に変換し、既存のクエリ文字列があれば & で連結、なければ ? で始まる文字列としてパスに付加します。また body: が明示された場合、params: は query_params の計算から除外されるため、body: と params: の意図しない二重送信を防ぎます。
テストでも変更の効果が確認できます。従来は POST メソッドと X-Http-Method-Override: GET ヘッダを期待していたアサーションが、GET メソッドかつ X-Http-Method-Override ヘッダなし(assert_nil)に書き換えられています。
# 変更前
assert_equal "POST", request.method
assert_equal "GET", request.headers["X-Http-Method-Override"]
# 変更後
assert_equal "GET", request.method
assert_nil request.headers["X-Http-Method-Override"]
設計判断
明示的な制御手段を追加し、暗黙的な変換を廃止するという方針が採られました。
従来の X-Http-Method-Override ハックは、「テストヘルパーがHTTPメソッドを変換する」という副作用をテストコードから隠蔽していました。テストを書くエンジニアからは、GETを意図したコードが実際にはPOSTとしてルーターに到達していることが見えず、APIオンリーアプリとの相性問題が顕在化するまで問題に気付きにくい設計でした。query: と body: という明示的なキーワード引数を導入することで、「何を何処に送るか」がコードを読むだけで判断できます。
params: の後方互換性は完全に維持されており、既存のテストコードを変更する必要はありません。新しい query: と body: はオプトイン形式のため、段階的な移行が可能です。PRのベンチマークによると、GETに as: :json を組み合わせた場合は従来比約6.6%のスループット向上も確認されています。これはPOST変換を省いたことによる経路の短縮が寄与しています。
まとめ
本PRは、テストヘルパーが持っていた「暗黙のHTTPメソッド変換」という設計上の負債を query: / body: の導入によって解消しました。Rack::MethodOverride の有無に依存しない実装になったことで、APIオンリーアプリを含むあらゆる構成でインテグレーションテストが正しく動作するようになります。