🧪

APIシナリオテストの新ツールrunn

2022/09/20に公開1

runn is 何?

3行まとめ
https://twitter.com/katzchum/status/1561490586858770432

今回はAPIシナリオテストツールのrunnをプロジェクトに導入し、一部機能のコントリビュートしながら3ヶ月間触ってみておすすめだと感じたことを記事にまとめたいと思います。

runnとの出会い

4ヶ月ほど前にスキーマ駆動開発を行っているプロジェクトでいい感じのAPIのテストをしようと色々 調査 をしていました。
その当時はOpenAPIでスキーマ定義してswagger-uiからポチポチ手動テストをしていましたが、APIの数も増えるし同じAPIでもパターンが結構あり、流石に手動でのテストでは限界があるなーと考えていました。
パラメータ数も多いのでControllerテストで書くにしてもコード量が多く、レビューが辛いと感じていました。
APIをE2Eでテストしたい、最終的にはCIで自動テストまでもっていきたいと思いました。

しかし、いざ調べてみると案外ニーズにマッチするツールが見当たりませんでした。いくつか調べた中では

があり良さそうに思いましたが、微妙にマッチしませんでした。
API単体でのテストは上記以外も数多く存在しましたが、APIをチェーンして呼び出すシナリオテストをするには難しそうと判断しました。

APIをチェーンするというのは例えば

  1. 登録API(コードが発行される)
  2. 更新API(1.で発行されたコードを指定して更新)

という一連の流れがあるケースです。Chaining Requests と呼ぶみたいです [1]
PostmanではGUIでパラメータ埋め込みをしてAPIを繋げていけそうな雰囲気がありましたが

  • GUIで出力したファイル(Collection)が大きすぎてレビューに向かない
    GUIでパラメータを細かく設定していくのもかなり時間がかかり辛かったです。
  • シナリオをflowsで定義してみましたが、flowsをexportできずNewmanでCI実行できなさそうでした

他のツールもレスポンスをステートとして引き継いでAPIを呼び出すということが、スクリプトを書かないといけなさそうな雰囲気があり直感的に使えなさそうでした。[2]

そうこうしている間にふと、以下の記事が目に止まりました。

https://tech.pepabo.com/2022/06/07/scenario-testing-in-go/

ひと目見てコレだ!という感じでした。
こちらの記事と REAMDE を読めば大凡の使い勝手の理解が出来る可読性の高さを感じました。

APIチェーンは簡単

シナリオはRunbookというyaml形式で記載します。
Runbook自体の記述方法は、上記の紹介記事やREADMEをさらっと目を通して貰えれば雰囲気は掴めると思います。
レスポンスを次のステップで参照できるので、サックとチェーンさせることが出来ます。

steps:
  createUser:
    desc: Create User APIのテスト(IDが発行される)
    req:
      /user:
        post:
          body:
            application/json:
              username: "alice"
              email: "alice@example.com"
    test: steps.createUser.res.status == 200
  updateUser:
    desc: Updated User APIのテスト(aliceをbobに変更する)
    req:
      /user/{{ steps.createUser.res.body.id }}:
        put:
          body:
            application/json:
              username: "bob"
    test: steps.updateUser.res.status == 200

{{ }} で囲まれた steps.createUser.res.body.id の部分が Create User したレスポンスデータに含まれる id を展開する式です。  
実行したステップ名(上記例だと createUserupdateUser ) を指定して

  • ステップ名.res.status
    レスポンスのステータスコード
  • ステップ名.res.headers['ヘッダー名'][0]
    レスポンスヘッダ
  • ステップ名.res.body
    レスポンスのボディ

が参照できます。
例えば以下のようなjsonレスポンスだった場合

{
  "data": {
    "username": "alice",
    "email": "alice@example.com"
  }
}

username を参照するには {{ ステップ名.res.body.data.username }} で展開ができます。
期待値の検証も test 構文に同様に書くことができます。

    test: current.res.status == 200 && current.res.data.email == "alice@example.com"

ステップ名が current になっていますが、runnerの実行ステップと同じステップを参照できる予約語になっています。
test構文の場合は {{ }} で式を囲まなく直接記載します。

シナリオを再利用して書ける

varsとinclude構文を組み合わせてシナリオを再利用させることができます。
前述例のAPIチェーンを再利用させて書くとこんな感じになります。

  • createUser.yml
    vars:
      username: "alice"
      email: "alice@example.com"
    steps:
      createUser:
        desc: Create User APIのテスト(IDが発行される)
        req:
          /user:
            post:
              body:
                application/json:
                  username: "{{ vars.username }}"
                  email: "{{ vars.email }}"
        test: steps.createUser.res.status == 200  
    
  • updateUser.yml
    vars:
      username: "bob"
      email: "bob@example.com"
    steps:
      createUser:
        desc: Create User APIのテスト(include実行)
        include:
          path: createUser.yml
          vars:
            username: "{{ vars.username }}"
            email: "{{ vars.email }}"
          skipTest: trrue
      updateUser:
        desc: Updated User APIのテスト
        req:
          /user/{{ steps.createUser.res.body.id }}:
            put:
              body:
                application/json:
                  username: "sam"
        test: steps.updateUser.res.status == 200  
    

includeを使用してCreate Userのテストが再利用されるようになりました。
varsはシナリオ内で利用できる変数を定義できます。
include時にvarsを上書きできるようになっているので、必要なパラメータをvarsにしておくと再利用性が高くなります。
また skipTesttrue にしておくと、includeしたいシナリオのtest構文がスキップされるので軽微ですが実行時間の短縮になります。

注意点としてシナリオをDRYに書きすぎると、どこまでも数珠つなぎになってしまいます。
メンテナンス性や読みやすさを顧慮して適度に共通化する感じが良いです。

データ駆動テストができる

上記のincludeとvarsを組み合わせしたテストを発展させてデータ駆動テストっぽくシナリオを書いてみます。
varsの設定でjson読み込みをサポートさせました。

  • user.json
    {
      "username": "alice",
      "email": "alice@example.com"
    }
    
  • createUser1.yml
    vars:
      createUserRequest: "json://path/to/user.json"
    steps:
      createUser:
        desc: Create User APIのテスト(request bodyをjsonファイルで指定)
        req:
          /user:
            post:
              body:
                application/json: "{{ vars.createUserRequest }}"
        test: steps.createUser.res.status == 200  
    

リクエストbodyの内容をシナリオファイルから追い出すことができました。
APIが複雑でパラメータが多い場合等、jsonデータをそのままリクエストで使えるようになるので大変便利です。
レスポンスの期待値もjsonデータにしておくのもありです。

もう少し発展させて、いくつかのデータパターンを繰り返しテストさせたくなります。
@k1low さんと協議して runnに元々あったretry機能をloop機能として リファクタリング&機能拡張してもらい 以下のようにすることができました。

  • createUserBase.yml
    vars:
      createUserRequest: "json://path/to/user_request.json"
      createUserResponse: "json://path/to/user_response.json"
    steps:
      createUser:
        desc: Create User APIのテスト(リクエストとレスポンスの期待値をjsonデータで受け取る)
        req:
          /user:
            post:
              body:
                application/json: "{{ vars.createUserBody }}"
        test: |
          steps.createUser.res.status == 200
          && compare(steps.createUser.res.body, vars.createUserResponse)
    
  • createUserPattern.yml
    vars:
      requestJson:
         - "json://path/to/case1_request.json"
         - "json://path/to/case2_request.json"
         - "json://path/to/case3_request.json"
      responseJson:
         - "json://path/to/case1_response.json"
         - "json://path/to/case2_response.json"
         - "json://path/to/case3_response.json"
    steps:
      createUser:
        desc: Create User APIのテスト(jsonファイル数分繰り返しテストする)
        loop:
          count: len(vars.requestJson)
        include:
          path: createUserBase.yml
          vars:
            createUserRequest: "json://{{ vars.requestJson[i] }}"
            createUserResponse: "json://{{ vars.responseJson[i] }}"
    

createUserBase.ymlではレスポンスの期待値のチェック様に compare という組み込み関数を用意しました。
もしレスポンス内で毎回値が変わるフィールドがあれば、第3引数以降にフィールド名を指定して除外できるようになっています。

createUserPattern.ymlからcreateUserBase.ymlへリクエストと期待値のjsonをloop構文を使って渡しています。
ループカウンターは i の部分になります。

jsonファイル以外にも直接DBからデータを参照して活用できる

他のAPIツールにない強力な機能です。
前述のloop構文と組み合わせればかなり大量にアクセスができるかと思いますw

runners:
  db: my:dbuser:${DB_PASS}@hostname:3306/dbname
steps:
  find_user:
    db:
      query: SELECT * FROM users WHERE email like '%@exmple.com'
  user_info:
    loop:
      count: len(steps.find_user.rows)
    req:
      /users/{{ steps.find_user.rows[i].id }}:
        get:
          body: null

リクエストの結果がレスポンスから窺い知れないケースでも、DBへクエリ発行して期待値として検証することができるのも地味ですが役に立ちます

シナリオをシンプルにしデータは柔軟にする

データ駆動テストの例でもありますが、シナリオ自体はごくシンプルにしてデータに柔軟性をもたせることが重要かと考えています。
試験的な機能ではあるのですが、Go Templateの機能を利用してデータを生成できる仕組みも組み込んでいます。

やり方は先程のvars構文でjsonを読み込む場合と同じで拡張子だけを .json.template にするだけです。
includeのvarsの上書き時に利用すると、前ステップの結果等を踏まえたデータを生成することができます。

  • updateUserPattern.yml

      updatedUser:
        desc: Updated User APIのテスト(テンプレートで期待値を生成)
        include:
          path: updateUser.yml
          vars:
            updatedUserRequest: "json://path/to/request.json"
            updatedUserResponse: "json://path/to/response.json.template"
    
  • response.json.template

    {
      "id": {{.steps.createUser.res.body.id}},
      "username": "alice",
      .. snip ..
    }
    

varsと変数展開と似ていて紛らわしいのですが、これはGo Templateの記法となります。
テンプレートとして機能し、idの値を埋め込む等できます。
またテンプレートの構文としてifやrangeが使えるので単純な値の埋め込みだけでなく、よりダイナミックにデータを生成することができます。

色々な環境でサクッと動かすことができる

goのコマンドとして実装されているので、CIやそれ以外の環境でも簡単に動かすことができます。
シナリオ内で環境変数の参照が可能で、環境毎のエンドポイント等の違いを切り替えることができます。

runners:
  req:
    endpoint: ${END_POINT:-https://httpbin.org/}
  db: my:dbuser:${DB_PASS}@${DB_HOST:-localhost}:3306/dbname

runners構文内の設定は上記の様に環境変数を埋め込んでおくと良いです。
docker-composeのように環境変数の参照時にdefault指定ができます。
普段はローカル用にして、それ以外の環境の場合は環境変数で上書きする感じで使えます。

環境変数を埋め込むのにひと手間かかりますが、デプロイ後の動作検証がrunnのコマンド一発で済むようになったりと活用範囲が広がっておすすめです。

GitHub Actionsで簡単に動かせるようにカスタムアクションも用意したので良かったら是非!

https://github.com/k2tzumi/runn-action

デバック実行とOpenAPIV3対応が親切

シナリオ作成時にはデバック実行させて行うのがベストです。
デバック実行はrunn実行時のオプションで指定させるのが良いです。

$ runn run path/to/scenario.yml --debug

リクエスト及びレスポンスの内容も全て表示されます。
test構文でのチェックで字句解析されて結果が以下の様に出力されるので大変わかりやすいです。

Run 'req' on 'testing include'.steps[0]
-----START HTTP REQUEST-----
POST /post?count=0 HTTP/1.1
Host: httpbin.org
Content-Type: application/json
X-Test: default

{"bar":1,"foo":"test"}
-----END HTTP REQUEST-----
-----START HTTP RESPONSE-----
HTTP/2.0 200 OK
Content-Length: 523
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: *
Content-Type: application/json
Date: Mon, 19 Sep 2022 09:28:25 GMT
Server: gunicorn/19.9.0

{
  "args": {
    "count": "0"
  }, 
  "data": "{\"bar\":1,\"foo\":\"test\"}", 
  "files": {}, 
  "form": {}, 
  "headers": {
    "Accept-Encoding": "gzip", 
    "Content-Length": "22", 
    "Content-Type": "application/json", 
    "Host": "httpbin.org", 
    "User-Agent": "Go-http-client/2.0", 
    "X-Amzn-Trace-Id": "Root=1-63283639-124641461cb7175a1280e0ad", 
    "X-Test": "default"
  }, 
  "json": {
    "bar": 1, 
    "foo": "test"
  }, 
  "origin": "127.0.0.1", 
  "url": "https://httpbin.org/post?count=0"
}

-----END HTTP RESPONSE-----
Run 'test' on 'testing include'.steps[0]
-----START TEST CONDITION-----
current.res.status == 200
&& current.res.body.json == vars.jsonRequestBody

├── current.res.status => 200
├── 200 => 200
├── current.res.body.json => {"bar":1,"foo":"test"}
└── vars.jsonRequestBody => {"bar":1,"foo":"test"}
-----END TEST CONDITION-----
testing include ... ok

1 scenario, 0 skipped, 0 failures

またOpenAPIのv3にも対応していてSpecを以下のように指定しておくと幸せになれます。

runners:
  req:
    endpoint: https://api.github.com
    openapi3: path/to/openapi.yaml

OpenAPIの仕様書通りのリクエストとレスポンスになってない場合はエラーにしてくれます。
エラー内容も仕様書の定義を表示してくれるので、シナリオの記述間違いに気付くことができます。
シナリオ自体の品質を高めるのと一緒にテスト対象のAPIの品質も同時にチェックできて大変良いです。
実際にrunnに導入していくつかAPIの不具合を検知することが出来ました。

今後の発展にも期待!

今進行中なものとしてGolden Test用にCapture機能が実装され始めたり、組込み関数が実装しやすく拡張ポイントが提供されていたりもするので今後も期待大です。

最後に

素晴らしいツールを世に送り出してくれた @k1low さんに感謝です。
記事を見かけて感じた、可読性の高さと学習の容易性は思惑以上でした。既にプロジェクト内には浸透済みとなり、なくてはならないツールになりました。
この記事を見て興味を持たれた方は是非試してみて頂きたいと思います。

また私個人(Contributor)としては、ふわっとした提案でも丁寧にディスカッションをして頂き大変ありがたかったです。
背景や考え方についての学びも多く、何よりも発展的に機能が充実していく様を間近で見られるのが楽しいです。
今後も微力ながら貢献できていけたらと思います。

脚注
  1. 参考: https://learning.postman.com/labs/postman-flows/getting-started/chaining-requests-with-data/ ↩︎

  2. ツールがカバーする範囲が広く、自分の調べ方が良くなかったかもですが。。 ↩︎

Discussion