freeeの開発情報ポータルサイト

runnを用いたバックエンドテストの試行錯誤の変遷とこれから

こんにちは。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のツールです。

github.com

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_emailpasswordを用い、セッション情報を取得
    • 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さんの以下の記事を参照してみてください。

zenn.dev

フェーズその3の感想

1APIに対し、共通のヘッダーパラメータの確認と、必須・オプショナルパラメータの組み合わせの動作確認ができるようになりました。 デシジョンテーブルで確認したいパラメータの組み合わせをrunn上で記述・実行できることにより、リグレッションテストが容易に行えるようになります。 この方式を他のAPIに対して同じように適用し、共通利用パターンを特定して外部Runbookで適用できるようにしたい...!

フェーズその4 リクエストパラメータの渡し方をもっと動的に...

リクエストボディ・クエリパラメータともに膨大なAPIを検証したい場合、フェーズ3のようなデータ駆動の方法だと、指定するパラメータ数も増えて管理が大変になります。少ない手数で思い描くリクエスト内容をどのように構築するか...?というのがここでのテーマになります。

今回、リクエスト対象のAPI毎にmodelとなるRunbookを用意し、リクエストパラメータを動的に作成・実行できるかどうかを検証してみました。 やりたいことを図にすると以下のようになります。

フェーズ4のrunnを用いたデータ作成及び実行のシーケンス図

登場人物は以下になります。

  • 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が産休について記事を書いてくれます!

それでは、よい品質を〜

*1:expr langのdateの扱いについてはこちらを参照