こんにちは。2023年のQA Advent Calendarを見てfreeeのQAに興味を持ち、転職。そして本年のAdvent Calendarの内の1日を担当することになりました。決済プロダクトのQAエンジニアのsunnyです。
チームではアジャイルQAのスタイルを採用しており、要求・要件定義からユーザーストーリーを作成し、開発者とやりとりしながら仕様の理解を深めつつ、テスト分析・設計を行っています。 本記事はバックエンドQAで実践してきた、runnというツールを用いた変遷と、その先の展望についてのお話です。
この記事はfreee QA Advent Calendar 2024 - Adventarの15日目です。
runnとは
runnはRunbookと呼ばれる、yamlで記述したシナリオテストの実行・検証を行うことができるOSSのツールです。
runn利用の変遷
実務で活用するとしたらどのような形になるのか?ということで、ここではその変遷をフェーズ単位で記します。
フェーズその1 基本的なAPIテストの実装
まず初めに、リリース済みのAPIの動作検証から肩慣らしです。便宜上、ユーザ一覧取得用のAPIがあるとした上での記述になります。
check_api_call_part_one.yml
desc: ユーザー取得のテスト vars: # 変数を設定 user_email: ${USER_EMAIL} password: ${PASSWORD} user_id: ${USER_ID} runners: req: # リクエスト先のエンドポイントを設定。stepsで使用する endpoint: ${ENDPOINT} steps: get_header_param: desc: ヘッダーに用いる値取得 include: # 共通ステップをinclude path: path/to/get_header_param.yml vars: user_email: '{{ vars.user_email }}' password: '{{ vars.password }}' bind: # includeで取得した値をbindで設定 session: current.session check_created_user: desc: ユーザー取得 req: /path/to/user?id={{ vars.user_id }}: # リソースのパスを指定 & クエリパラメータに変数の値を利用 get: headers: # get_header_paramステップで取得した値を使用 Cookie: '{{ session }}' test: | # レスポンスのテスト current.res.status == 200 dump: # レスポンスをファイルに出力 expr: current.res out: user_result.json
上記Runbookを掻い摘むと、以下のようになります。
steps.get_header_param
vars:
で設定したuser_email
とpassword
を用い、セッション情報を取得bind:
セクションでsessionという名前に値をセット(束縛)
steps.check_created_user
- 指定したユーザーを取得
test:
で、レスポンスのステータスが200であることを検証dump:
では、expr:
で指定したAPIリクエストのレスポンス内容をout:
で指定したファイルに出力
フェーズその1の感想
基本的なrunnによるAPIテストを試してみました。 変数や、stepsの途中でbindした値を最終的にリクエストパラメータなどに利用することができる、というのは便利で応用ができそうです。 たとえば実行時点の日*1や、未来・過去日をリクエストパラメータに用いたい...!ということも、以下のように対応することができます
steps: bind_current_date: bind: today: 'now().Format("2006-01-02")' tomorrow: '(now() + duration("24h")).Format("2006-01-02")' yesterday: '(now() - duration("24h")).Format("2006-01-02")'
フェーズその2 共通テストパターンの抽出
フェーズ1でAPIをコールする、結果を検証する、というのは確認できました。ここからは実務で用いているデシジョンテーブルを実際にrunnのシナリオに落とし込むとどうなるか?を検証しました。
今までのAPI動作確認において、共通で切り出せそうな確認項目をrunnで対応します。今回はリクエストヘッダーの特定パラメータの有無による振る舞いを見ています。 safe系メソッドを対象としたヘッダーパラメータの共通で使える部分を以下のように書き出します。
header_safe.yml
vars: # ヘッダーのパラメータを設定 request_path: # リソースのパス method: # safe系メソッド session: param_a: ${PARAM_A} runners: req: endpoint: ${ENDPOINT} steps: header_safe: desc: ヘッダーのパラメータが正常に設定されている場合 req: '{{ vars.request_path }}': '{{ vars.method }}': headers: Cookie: '{{ vars.session }}' X-Param-A: '{{ vars.param_a }}' test: | current.res.status == 200 cookie_empty: desc: Cookieが空の場合 req: '{{ vars.request_path }}': '{{ vars.method }}': headers: Cookie: "" X-Param-A: '{{ vars.param_a }}' test: | current.res.status == 400 random_cookie: desc: Cookieがランダムな文字列の場合 req: '{{ vars.request_path }}': '{{ vars.method }}': headers: Cookie: "random" X-Param-A: '{{ vars.param_a }}' test: | current.res.status == 400 param_a_empty: desc: X-Param-Aが空の場合 req: '{{ vars.request_path }}': '{{ vars.method }}': headers: Cookie: '{{ vars.session }}' X-Param-A: "" test: | current.res.status == 400 random_param_a: desc: X-Param-Aがランダムな文字列の場合 req: '{{ vars.request_path }}': '{{ vars.method }}': headers: Cookie: '{{ vars.session }}' X-Param-A: "random" test: | current.res.status == 400
各ステップでリクエスト先のリソースパスとメソッドを動的に受け取れるようにし、網羅的に確認できるようになりました。 上記Runbookを以下のように呼び出します。
check_api_call_part_two.yml
steps: get_header_param: desc: ヘッダーに用いる値取得 include: path: path/to/get_header_param.yml vars: user_email: '{{ vars.user_email }}' password: '{{ vars.password }}' bind: session: current.session param_a: current.param_a check_header_param: desc: header確認のテスト include: path: path/to/header_safe.yml vars: request_path: /path/to/team?id={{ vars.team_id }} method: get session: '{{ session }}' param_a: '{{ param_a }}'
steps.get_header_param
セクションでは、フェーズ1と同じくセッション情報とAPIリクエストに必要なパラメータを取得します。steps.check_header_param
セクションで、上述したheader_safe.yml
をincludeし、各パラメータの組み合わせを検証します。
フェーズその2の感想
共通部分を外部に切り出し、動的に値を反映することができました。関数のように共通処理を切り分けて管理できそうです。 今回のように外部に切り出したことにより、新規実装のAPIをrunnで検証する際、header_safe.ymlを使い回すことがき、一定の品質を保つことが期待できます。 とくにヘッダーには認証情報に関する値を扱うことがあるため、意図しない脆弱性を早期に発見し、事前に防ぐ機会が得られそうです。
フェーズその3 データ駆動テスト化
各APIで共通で使える部分とは別に対象のAPI特有の振る舞いを確認したい時、リクエストごとにstepを作成して検証するのは記述量と時間と共に大きくなります。Table Driven Testのように検証したい値の組み合わせをリスト化して管理することはできないかと思い、試してみました。
値の組み合わせをList化する
steps内に、パラメータのリストをbindするセクションを設けます。varsセクションでも似たようなことができますが、runn組み込みのexpr langの恩恵を受けられないため、steps内でパラメータリストを宣言します。リスト内の要素は以下の通りです。
- title: テスト項目名
- id: テスト識別子
- param: 検証したいパラメータの組み合わせをMap形式で記述
- want: 期待するレスポンスステータス
これらをbind専用のセクションで宣言し、後ろに続くステップでリストをループしてAPIコール & 検証をします。
check_api_call_part_three.yml
steps: bind_query_params: bind: query_params: - title: '"自チームのid"' id: 1 param: team_id: '{{ vars.team_id }}' rows: 20 want: "200" - title: '"他チームのid"' id: 2 param: team_id: '{{ vars.another_team_id }}' rows: 20 want: "404" - title: '"randomなチームのid"' id: 3 param: team_id: "12345678987654321" rows: 20 want: "404" - title: '"有効なteam_idで表示件数0"' id: 4 param: team_id: '{{ vars.team_id }}' rows: 0 want: "200" check_team_member: desc: チームメンバー取得 loop: count: len(query_params) interval: 1.5 # 流量制限を加味し、1.5秒間隔でリクエストを送信 req: /path/to/team?id={{ query_params[i].param.team_id }}&rows={{ query_params[i].param.rows }}: get: headers: Cookie: '{{ session }}' X-Param-A: '{{ param_a }}' test: | current.res.status == int(query_params[i].want) dump: expr: '{"title": query_params[i].title, "req": query_params[i].param, "res": current.res}' out: out/check_team_case_{{ query_params[i].id }}.json
ステップごとにやっていることは
bind_query_params:
で動作検証したいパラメータの組み合わせと期待値のリストをquery_params
として宣言します。
check_team_member:
では、query_params
の要素数をloop:
に渡すことにより、1 step内でquery_paramsの各要素を呼び出しますintervalを設定してリクエスト数が過度にならないよう調整します。
リクエスト呼び出し後、dump:
ではタイトル、リクエスト、レスポンスの組み合わせをjson化し、ファイルに出力しています。query_paramsの各要素の検証結果をjsonファイルに出力されることになり、関係者への結果共有も容易になります。
代替手段としてリクエスト内容をjsonファイルで外部に配置し、それを一覧で読み込んで利用する方法があります。こちらについてはrunnのcontributerであるkatzumiさんの以下の記事を参照してみてください。
フェーズその3の感想
1APIに対し、共通のヘッダーパラメータの確認と、必須・オプショナルパラメータの組み合わせの動作確認ができるようになりました。 デシジョンテーブルで確認したいパラメータの組み合わせをrunn上で記述・実行できることにより、リグレッションテストが容易に行えるようになります。 この方式を他のAPIに対して同じように適用し、共通利用パターンを特定して外部Runbookで適用できるようにしたい...!
フェーズその4 リクエストパラメータの渡し方をもっと動的に...
リクエストボディ・クエリパラメータともに膨大なAPIを検証したい場合、フェーズ3のようなデータ駆動の方法だと、指定するパラメータ数も増えて管理が大変になります。少ない手数で思い描くリクエスト内容をどのように構築するか...?というのがここでのテーマになります。
今回、リクエスト対象のAPI毎にmodelとなるRunbookを用意し、リクエストパラメータを動的に作成・実行できるかどうかを検証してみました。 やりたいことを図にすると以下のようになります。
登場人物は以下になります。
- Table Driven Testのようにデータ一覧を配置するRunbook
- build_params_create_team.yml
- 検証したいAPIに対応するリクエストボディ生成用のRunbook
- create_team_model.yml
- 出力されたリクエスト内容を実行するRunbook
- do_request.yml
APIの対象として、以下の2つを対象として考えます。(リクエストの型はイメージを掴むためのものになります)
チームを作成するAPI (POST)
interface CreateTeamRequest { name: string; type: "public" | "private"; member_max: number; member_min: number; description: string }
まず、チーム作成のリクエストボディを作成するmodelとなるRunbookを用意します。ここでは以下の要件を満たします
- デフォルトのパラメータを持ち、外部からの値を受け付けてマージすることができる
- 必要に応じてパラメータを除外
- 指定のキーを除外する
create_team_model.yml
desc: team作成 vars: default_param: name: "A Team" type: "public" member_max: 100 member_min: 3 description: "This is a team." param: {} use_default: true ignore_keys: [] steps: check_user_default: desc: "デフォルト値利用の確認" bind: base_param: 'vars.use_default ? vars.default_param : vars.param' merge_param: desc: "パラメータをマージ" bind: merged_param: 'vars.param != null ? merge(vars.default_param, vars.param) : base_param' omit_param: desc: "ignore_keysで指定されたキーを削除" bind: result_param: 'len(vars.ignore_keys) > 0 ? reduce(vars.ignore_keys, {omit(#acc, #)}, merged_param) : merged_param'
expr langによるデータ加工が柔軟に対応できるため、最終的に実現したいリクエスト内容を構築することができます。 リクエストパラメータを一部省略する方法の別案として、nullの値は除外対象にする、というケースがある場合は以下に差し替えます。
steps: filter_valid_param_from_map: bind: valid_param: 'reduce( filter( keys(vars.map_param), {vars.map_param[#] == null} ), omit(#acc, #), vars.map_param )'
呼び出し側は以下になります。
build_params_create_team.yml
steps: bind_create_team_params: bind: team_params: - params: name: '"B_Team"' type: '"private"' member_max: 50 member_min: 5 description: '"This is a B team."' want: "201" - params: name: '"C_Team"' type: '"public"' member_max: 100 member_min: 3 description: '"This is a C team."' want: "201" - params: name: '"D_Team"' ignore_keys: ['"type"', '"description"'] want: "400" create_team_request_body: desc: チーム作成 loop: count: len(team_params) include: path: path/to/create_team_model.yml vars: params: '{{ team_params[i].params ?? {} }}' ignore_keys: '{{ team_params[i].ignore_keys ?? [] }}' dump: expr: '{"name": team_params[i].params.name, "req": current.result_param, "want": team_params[i].want}' out: out/create_team_{{ team_params[i].params.name }}.json
これを実行すると、team_params
の各要素に対応した、以下の内容のようなjsonファイルが出力されます。
例: create_team-B_Team.json
{ "name": "B Team", "req": { "description": "This is a B team.", "member_max": 50, "member_min": 5, "name": "B Team", "type": "private" }, "want": 201 }
最後に、出力されたjsonファイルの一覧を元にAPIコールするRunbookを用意します。
do_create_team.yml
desc: チーム作成APIを実行 vars: data: json://out/*.json session: runners: req: endpoint: ${ENDPOINT} steps: call_api: loop: count: len(vars.data) interval: 1.5 req: /path/to/team: post: headers: Cookie: '{{ vars.session }}' body: vars.data[i].req test: | current.res.status == vars.data[i].want dump: expr: 'merge(vars.data[i], {"res": {"status": current.res.status, "body": current.res.body}})' out: out/create_team_{{ vars.data[i].name }}.json
build_params_create_team.ymlで作成したjsonファイル一覧をvars:
セクションで変数で指定します。これをsteps.call_api
でloop対象に渡し、jsonファイル内のreq要素をリクエストボディに指定します。このようにして、jsonファイル分のリクエストを1.5秒間隔で実施します。
最後のdump:
セクションで既存のjsonファイルにレスポンス結果を上書きするようexpr:
で指定し、最終的にリクエストとレスポンス内容が出力されるようになります。
また、GETのAPIにたいしても動的に対応する場合、以下のようにリクエスト前段でMap形式をクエリパラメータに変換する処理を挟むことで実現できます。
steps: map_to_query_param: bind: query_param: 'join(reduce(keys(vars.map_param), { let k = #; let cv = vars.map_param[k]; type(cv) == "array" ? concat(#acc, map(cv, {string(k + "[]=" + #)})) : concat(#acc, [string(k + "=" + string(cv))]) }, []), "&")' do_request: req: /path/to/team?{{ query_param }}: get: ...
フェーズその4の感想
リクエストパラメータを動的に、キーの欠如による動作確認も含めたリクエストパラメーターの生成方法について検討しました。これにより更に複雑なパラメータの組み合わせを生成できるようになり、探索的な動作確認を少ない手数で実施できる下地ができました。
しかし、Runbookが複数になってくると複雑度が増し、メンテナンスコストが上がるという難点があります。 またexpr langによるデータ加工について、できることは多いのですが、実装者によっては秘伝のタレ化しやすい罠が潜んでいます。いい具合にexpr langの複雑な処理を外部化し、容易に利用できるような構成が必要になりそうと感じました。
runn利用のこれから
ここまで、runnでどのようなAPIテストの検証が可能かについて試行錯誤してきた変遷をお送りしました。ymlの定義だけで出来ることが多く、痒いところに手が届く素晴らしいツールであると感じました。複雑になりすぎない程よい使用感でQAチーム全体に共有できるよう、trial & errorを繰り返し、洗練させていきたいと考えています。
お付き合いいただき、ありがとうございました。次回は支出管理QAのkana-sanが産休について記事を書いてくれます!
それでは、よい品質を〜