👟

runnでAPIテストをしてみた

に公開

こんにちは!アルダグラムでエンジニアをしている秋田です。

KANNAでは、契約していただいた会社様が自社リソースとシームレスに連携できるよう OpenAPI を公開し、業務に合わせた柔軟な拡張を可能にしています。

今回は、その OpenAPI のシナリオテストツールとして runn を使ってみたので、その内容を書きたいと思います。

runn とは
API のテストシナリオを YAML で記述し、その手順どおりにリクエストを送りながら検証できるテストツールです。OpenAPI と連携した自動チェックにも対応しています。
https://github.com/k1LoW/runn

なお、 OpenAPI のリポジトリには元々Jestで書かれたテストも存在しています。
しかし、今回はライブラリアップデート作業の都合上、様々なローカルやテスト環境を含む様々なターゲットに対してテスト実行できたほうが便利だったので、使用感の確認も含めて新規にテストを書きました。

今回 runn を採用したのは、複数の環境(ローカル/テスト/ステージング環境など)に対して、同じシナリオで API テストを一貫して実行したかったためです。
既存の Jest によるテストは実行できる環境が限られており、ライブラリアップデート時のデグレチェックとして本番に近い環境までテストを展開できる仕組みが必要でした。
また、 YAML でシナリオを記述できるため テスト内容の見通しがよく、環境差分を吸収しやすい点も採用理由のひとつです。

使ってみる

runn ではシナリオ(runbook)をYAMLファイルで記載します。
色々なことができますが、大体以下のような感じです。

desc: シナリオテスト
runners:
  req:
    endpoint: http://localhost:3000
steps:
- desc: ステップ1
  req:
    /api/profile:
      get:
        headers:
          Accept: application/json
        body: null
  test: |
    current.res.status == 200

desc で説明、 runners で実行先などの設定、 steps で各実行手順を定義し、1つのステップの中にリクエスト内容と検証条件があります。

テスト実行は以下のようなコマンドになります。

runn run runbook.yml

また、実際のリクエストおよびレスポンスからrunbook作成も可能です。

runn new -- curl -X 'GET' \                                      
  'https://localhost/api/profile' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d ''

全く同じリクエストとレスポンスにするという場合以外には手作業での修正が多少必要ですが、最初のrunbookを作る場合などには便利です。

公式チュートリアルが日本語で詳しく書かれているのも嬉しいですね。

https://zenn.dev/katzumi/books/runn-tutorial

得た知見など

さて、それでは実際に使ってみて悩んだポイントや工夫したポイントを挙げていきます。

Expr に慣れる

レスポンスの取り出しやtestでの検証によく使うんですが、runnでは expr-lang/expr を式の評価エンジンとして組み込んでいます。
https://expr-lang.org/docs/language-definition

  test: |
    current.res.status == 200
    && len(current.res.body.images) > 0
    && len(filter(current.res.body.images, .uuid == imageUuid)) == 1

こんなやつ。

current.res はこのステップのレスポンスなので、 current.res.status がHTTPステータスコードなのは分かると思います。
レスポンスボディがJSONなので、 len(current.res.body.images)images 配列の長さというのも想像できそうです。
ただその後は filter 関数があり、ここはExprのドキュメントを見ることになります。
すでに知っている書き方の部分は問題ないですが、「このレスポンスのこの部分だけをこういう条件で検証するには……?」となったとき、書き慣れていないと苦労すると思います。

OAuth がつらい

OpenAPI は OAuth によって認可を行っていますが、これをすべてrunnのrunbookで完結させるのは難しかったです。
runnには CDP(Chrome DevTools Protocol) が組み込まれており、ヘッドレスブラウザ操作によるログインと同意確認も可能ではありました。
が、以下の理由により、実装が複雑になりすぎるため今回は採用を見送りました。

  • runnはdockerで動かしていたため、ローカル向けだとホスト名(localhost)に差異が出る
    • dockerコンテナ内から見た localhost はホストマシンを指さないため
    • dockerやめればいける
  • テスト環境はさらにもう一つ認証が必要

現時点ではトークン発行までを実行するシナリオとそれ以降のシナリオに分かれており、トークン発行についてはシェルスクリプトで順次実行する形にしました。
途中で手作業でのログインと認可コードの入力を強制しています。

#!/bin/bash

ENV_FILE="${1:-.env}"

# login
docker compose run -it --rm runn run /books/auth/login.yml --env-file="/books/${ENV_FILE}" --verbose
if [ $? -ne 0 ]; then
  exit 1
fi
# login.yml内で色々ダンプ、ブラウザで認可コードを取得

read -p "Enter authorization code: " AUTHORIZATION_CODE
if [ -z "$AUTHORIZATION_CODE" ]; then
  exit 1
fi

# get token
docker compose run -it --rm -e AUTHORIZATION_CODE="$AUTHORIZATION_CODE" runn run /books/auth/get-token.yml --env-file="/books/${ENV_FILE}" --verbose

CDPで認可コードを取得してみた場合の参考パターンは以下。

runners:
  cc: chrome://new   # Chrome runner を利用してブラウザ操作を自動化する
# 中略
- cc:
    actions:
      # ログイン画面に合わせてセレクタ調整
      - navigate: "{{ auth_url }}"
      - waitVisible:
          sel: 'input[name="email"]'
      - sendKeys:
          sel: 'input[name="email"]'
          value: "${TEST_EMAIL}"
      - sendKeys:
          sel: 'input[name="password"]'
          value: "${TEST_PASSWORD}"
      - click: 'button[type="submit"]'
      - waitVisible:
          sel: 'input[name="searchWord"]'
      - location
  test: |
    current.url matches '.*code=.*'
  bind:
    redirect_url: current.url
- desc: Extract authorization code
  bind:
    authorization_code: split(filter(split(split(redirect_url, "?")[1], "&"), {# startsWith "code"})[0], "=")[1]

変数を渡す

各環境ごとに実行できるようにする場合、変数を渡すのは必須ですね。
OAuthでの例の通り、runnでは --env-file で環境変数ファイルを渡すことも、環境変数を直接見ることも可能です。
基本的には環境ごとにenvファイルを用意し、実行時にファイルを指定することで切り替えるのがいいかなと思います。
また、トークンのようにJSONファイルをrunbookから読み込むこともできます。

runners:
  req:
    endpoint: ${OPEN_API_HOST}
vars:
  token_file: "json://../token.json"
steps:
- req:
    /users:
      post:
        headers:
          Accept: application/json
          Authorization: "Bearer {{ vars.token_file.access_token }}"
        body:
          application/json:
            email: ${TEST_MAIL}
            firstName: 太郎
            lastName: 山田

${OPEN_API_HOST}${TEST_MAIL} には環境変数が、 {{ vars.token_file.access_token }} にはJSONファイルから読み込んだアクセストークンが入ります。
JSONファイルの読み込み自体は token_file: "json://../token.json" の部分です。
環境に応じて変化するところを変数にしておけば、様々な環境で同じrunbookの使用が可能です。

ラベルを付ける

runbookにラベルを付けておくと、特定のシナリオのみ実行するようなことが可能です。

desc: シナリオテスト
labels:
  - user
runners:
  req:
    endpoint: http://localhost:3000

user ラベルが付いたrunbookのみ実行する場合は以下のようにできます。

runn run *.yml --label user

これを利用すると、デバッグ時にシナリオを繰り返し実行したい場合などに、パス指定を変更せずに特定の複数シナリオを実行可能です。
今回は全体を実行することのほうが多かったのでそこまで使っていませんが、作業内容によっては役に立ちそうです。

詳細出力

runn run ではオプションとして --verbose--debug が使用できます。
通常の実行では両方なしか --verbose のみ、調査やデバッグの際には --debug をつけておくとやりやすいです。

両方なしの場合

runn run runbook.yml --env-file=".env"
.

1 scenario, 0 skipped, 0 failures

verbose

runn run runbook.yml --env-file=".env" --verbose
=== シナリオテスト (runbook.yml)
    --- ステップ1 (0) ... ok
    --- ステップ2 (1) ... ok
    --- ステップ3 (2) ... ok

1 scenario, 0 skipped, 0 failures

debug(一部マスキング・改変しています)

runn run runbook.yml --env-file=".env" --debug
Run "ステップ1" on "シナリオテスト".steps[0]
-----START HTTP REQUEST-----
GET /profile HTTP/1.1
Host: localhost
Accept: application/json
Authorization: Bearer xxxx

-----END HTTP REQUEST-----
-----START HTTP RESPONSE-----
HTTP/1.1 200 OK
Content-Length: 688
Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept
Access-Control-Allow-Origin: *
Connection: keep-alive
Content-Type: application/json; charset=utf-8
Date: Thu, 11 Dec 2025 xx:xx:xx GMT
Keep-Alive: timeout=5

{"user":{"uuid":"xxxx", ...}}
-----END HTTP RESPONSE-----
Run "bind" on "シナリオテスト".steps[0]
Run "test" on "シナリオテスト".steps[0]

Run "ステップ2" on "シナリオテスト".steps[1]
-----START HTTP REQUEST-----
POST /users HTTP/1.1
Host: localhost
Accept: application/json
Authorization: Bearer xxxx
Content-Type: application/json

{"firstName":"太郎","lastName":"山田", ...}
-----END HTTP REQUEST-----
-----START HTTP RESPONSE-----
HTTP/1.1 201 Created
Content-Length: 713
Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept
Access-Control-Allow-Origin: *
Connection: keep-alive
Content-Type: application/json; charset=utf-8
Date: Thu, 11 Dec 2025 xx:xx:xx GMT
Etag: W/"2c9-mRBJe9cVqedGjem9ac2BfsG9e1M"
Keep-Alive: timeout=5
X-Powered-By: Express

{"user":{"uuid":"xxxx", ...}}
-----END HTTP RESPONSE-----
Run "bind" on "シナリオテスト".steps[1]
Run "test" on "シナリオテスト".steps[1]

(中略)

-----END HTTP RESPONSE-----
Run "test" on "シナリオテスト".steps[2]
.

1 scenario, 0 skipped, 0 failures

OpenAPI仕様3(OAS3)チェック

runnを使ってみて、一番良かったポイントはこれかなと思いました。

runners:
  req:
    endpoint: ${OPEN_API_HOST}
    openapi3: ${OPEN_API_HOST}/openapi.json

runnersに openapi3 としてOpenAPIの定義ファイルを指定できます。
Swaggerを使っているとこれを出力できると思いますが、runnがリクエストやレスポンスを検証し、spec通りかチェックしてくれるようになります。
見落としがちなテストケース漏れや、そもそものspecの不備なども見つけられたりするので、使える環境であれば有用ですね。

得られた効果

runn を導入したことで、ライブラリアップデートにおける API テストの運用は大きく改善しました。
まず、複数の環境に対して同じシナリオをそのまま実行できるようになり、ライブラリアップデート時のデグレチェックが格段に楽になりました。
また、YAML によるシナリオ記述は見通しがよく、変数管理やトークン読み込みを柔軟に行えるため、環境差分を吸収しやすいテスト設計が可能になりました。
さらに、OpenAPI仕様(OAS3)に基づく自動検証によって、リクエストやレスポンスの不整合を早期に発見でき、仕様漏れの防止にもつながっています。

最後に

今回はrunnを使ってみた際の気付いたことなどを書きました。
OpenAPIそのものの実装はNestJSになっているため、TypeScriptがそのまま使用できるJestと比べると、シナリオの記述方法(特にExprの式評価)がわかりにくいというのはあるかもしれません。
しかし、「OpenAPIのシナリオテストを記述する」という点において、機能も揃っており使いやすい印象でした。
Jestでユニットテストし、runnでE2Eテストする、などの使い分けをするのもいいかと思います。

これからAPIのテストを導入する方、APIテストツールを探している方は、一度検討してみてはいかがでしょうか。
特にrunnはGo言語で書かれているので、Go言語のプロダクトとは親和性が高そうです。
とはいえ、独立して実行できるもの(そもそもシングルバイナリ)なので、どの言語のプロダクトでも利用可能だと思います。

もっとアルダグラムエンジニア組織を知りたい人、ぜひ下記の情報をチェックしてみてください!

アルダグラム Tech Blog

Discussion