テスト

APIシナリオテストとは?runnでログインから後続APIまでYAMLで自動化する手順【v1.11対応】

APIシナリオテストとは?runnでログインから後続APIまでYAMLで自動化する手順【v1.11対応】

APIシナリオテストは、ログインで受け取ったトークンや登録APIが返したIDを次のリクエストへ引き継ぎ、業務の流れ全体が通るかを確かめるテストです。本記事では、YAMLでシナリオを書くOSSツール runn の2026年9月28日公開版(v1.11.1)を、Pythonの標準ライブラリで立てたモックAPIに対して実行し、その出力をもとに書き方・失敗時の読み方・CIへの組み込みを説明します。

まとめ:APIシナリオテストとrunnの要点

  • APIシナリオテストは、複数のAPIを順に呼び、前の応答から取り出した値を次のリクエストに埋め込んで、業務フロー単位で検証するテストです。1エンドポイントずつのテストでは見えない、認可の抜けや非同期処理の待ち合わせの不具合を拾えます。
  • runn はGo製・MITライセンスのCLIです。1つのYAML(runbook)にHTTP・gRPC・DB・ブラウザ操作・SSH・コマンド実行の手順を並べられ、単一バイナリなのでCIに入れやすいのが特徴です。2025年11月8日にv1.0.0、2026年9月28日にv1.11.1が出ています。
  • 書くときは、ログイン部分を別のrunbookに切り出して include で呼び、非同期処理は loop.until で待ちます。同じステップに完了状態を要求する test: を併記すると、未完了の応答を受けた反復で失敗します。
  • HTTPランナーに openapi3 でOpenAPI定義を設定すると、そのランナーを使うステップのリクエストとレスポンスが定義に照合されます。ステータスコードだけを見るテストでは見逃す、項目の欠落を検出できます。
  • CIでは失敗時の終了コード1でジョブを落とします。JUnit XMLの出力機能は無く、--format が受け付けるのは json と none だけです。

以下、定義と他のテストとの違いから順に、実行結果を示しながら説明します。

APIシナリオテストの定義と単体APIテスト・E2Eテストとの違い

単体のAPIテストは、1つのエンドポイントにリクエストを送り、ステータスと本文を確かめます。シナリオテストは「ログイン→トークン取得→リソース作成→処理完了の確認」のように複数のAPIを順番に呼び、前のレスポンスの値を次のリクエストに埋め込みます。検証の単位が、エンドポイントではなく業務フローになります。

シナリオテストで確かめたい不具合には、次のようなものがあります。認可の不備のように対象APIを個別に検証して見つけられるものもありますが、シナリオにすると業務の順序どおりに再現できます。

  • トークンの権限範囲の誤り(ユーザーAのトークンでユーザーBのリソースが取れてしまう)
  • 作成直後のIDで参照すると、非同期処理が終わっておらず404や未完了の状態が返る
  • 前のステップで文字列だったIDが、別のAPIでは数値で返り、後続の呼び出しが失敗する

curlを順に叩いて目視で確かめる方法でも再現はできます。ただ、トークンやIDを人が手でコピーする方法でも回帰テストはできますが、リリースのたびに繰り返すと作業負担と転記ミスが増えます。

テストの種類 検証の単位 主な道具 確かめること
単体のAPIテスト 1エンドポイント Jest、Postman 入力値ごとの応答
APIシナリオテスト 複数APIの連続呼び出し runn、scenarigo、Newman 認証や状態の受け渡し
契約テスト 提供側と利用側の取り決め Pact 破壊的変更の検知
画面経由のE2Eテスト 画面操作を含む全体 Playwright、Cypress 利用者の操作経路

契約テストは「APIの形が取り決めどおりか」を見るもので、業務フローの正しさは見ません。考え方と実装はPactによる消費者駆動契約の実装で、画面を含めた検証の設計はE2Eテストのベストプラクティスで扱っています。APIシナリオテストは、画面を介さずにAPI層の業務フローを検証します。実システムの処理を端から端まで通す構成なら、E2Eテストにも該当します。

runnの概要と2026年10月時点の版

runn は k1LoW 氏が2022年3月にGitHub(k1LoW/runn)で公開したOSSで、名前は「Run N」に由来します。READMEが挙げる用途は、シナリオベースのテスト、Go言語のテストヘルパー、ワークフローの自動化の3つです。シナリオを書いたYAMLを runbook と呼び、runn run で実行します。日本語の解説書として、作者による「runn cookbook」と、katzumi氏による「runn Tutorial」がZennで公開されています。

ランナーの種類と既定で使えない機能

runbookの runners: に接続先を書くと、スキームでランナーの種類が決まります。コマンド実行とAIエージェント連携は、--scopes で明示的に許可しないと動きません。

ランナー runners: の書き方 既定で使えるか
HTTP https:// または http:// 使える
gRPC grpc:// 使える
DB mysql://、postgres:// など 使える
ブラウザ操作(CDP) chrome://new 使える
SSH ssh:// 使える
コマンド実行(Exec) 定義不要(exec:) run:exec が必要
AIエージェント(実験的) agent: claude など run:agent が必要

スコープを付けずに exec: を含むrunbookを実行すると、「'run:exec' scope is required」というエラーでそのステップが失敗しました。作業ディレクトリより上のファイル(../openapi.yaml など)を読む場合も、read:parent が必要です。CIで他人が書いたrunbookを実行する場面を想定した安全側の既定値なので、許可は必要な実行にだけ付けます。

v1.0.0以降の主な変更

旧来の解説記事の多くはv0系の時期に書かれています。1.0以降で挙動に関わる主な変更を、公式リリースノートに基づいて整理します。

版 公開日 変更点
v1.0.0 2025-11-08 1.0系へ移行(機能追加なし)
v1.8.0 2026-04-07 Agent Runnerを追加
v1.10.0 2026-09-01 --debug-on-failure を追加
v1.11.0 2026-09-18 環境変数を実行開始時に固定(破壊的変更)
v1.11.1 2026-09-28 依存更新・CREDITS生成CIの変更

v1.11.0では、変数 env が各runbookの実行開始時点のスナップショットになり、実行中に環境変数を変えても反映されなくなりました。include先のrunbookも呼び出し元と同じスナップショットを共有します。あわせてGoパッケージから runn.LoadEnvFile が削除されたので、Goのテストヘルパーとして使っていてこの関数を呼んでいるコードは、v1.11.0へ上げるとビルドが通りません。

runnのインストールと最初のrunbookの作り方

CLIのインストール方法

配布は単一バイナリで、Homebrew、go install、Dockerイメージ、deb・rpm・apkパッケージ、aquaが用意されています。

# Homebrew(macOS・Linux)
$ brew install k1LoW/tap/runn

# Go 環境があれば
$ go install github.com/k1LoW/runn/cmd/[email protected]

# Docker(カレントディレクトリを /books にマウント)
$ docker container run -it --rm -v $PWD:/books ghcr.io/k1low/runn:latest list /books/*.yml

$ runn --version
runn version 1.11.1

curlコマンドからrunbookを生成するrunn new

手元で叩いているcurlコマンドを runn new の後ろに渡すと、同じリクエストを送るrunbookが生成されます。--and-run を付けると、生成と同時に実行して応答から test: まで書き込みます。grpcurlのコマンドやWebサーバーのアクセスログも入力にできます。

$ runn new --and-run --desc 'login via curl' --out gen.yml -- \
    curl -X POST http://127.0.0.1:18080/login \
    -H 'Content-Type: application/json' \
    -d '{"email":"[email protected]","password":"passw0rd"}'

生成された gen.yml は次のとおりです。

desc: login via curl
runners:
  req: http://127.0.0.1:18080
steps:
- req:
    /login:
      post:
        body:
          application/json:
            email: [email protected]
            password: passw0rd
  test: |
    current.res.status == 200
    && current.res.headers["Content-Length"][0] == "20"
    && current.res.headers["Content-Type"][0] == "application/json"
    && "Date" in current.res.headers
    && current.res.headers["Server"][0] == "BaseHTTP/0.6 Python/3.9.6"
    && compare(current.res.body, {"token":"t-alice"})

生成直後の test: は、Server や Content-Length のヘッダーまで完全一致で固定しています。そのままコミットすると、サーバーのミドルウェアを更新しただけでテストが落ちます。コマンドに書いたパスワードもrunbookに平文で残ります。生成物は下書きと割り切り、test: はステータスと compare(current.res.body, ...) 程度に絞り、秘密情報は次の章のように環境変数へ移してから使います。

ログインから後続APIまでつなぐrunbookの書き方

ここからは、ログイン→アイテム登録→処理完了の待機、という3段のシナリオを例にします。検証用にモックAPI(127.0.0.1:18080)を立て、runn v1.11.1(macOS amd64版)で実行しました。モックAPIの仕様は、POST /login がメールアドレスとパスワードを照合してトークンを返し、POST /items が状態 pending のアイテムを作り、GET /items/{id} が3回目の参照から done を返す、というものです。API_BASE を自分のAPIのURLに変えれば、同じ構成のrunbookを流用できます。

ログイン用runbookの分離と認証情報の渡し方

ログインはほぼすべてのシナリオの前提になるので、1ファイルにまとめておきます。

# books/login.yml
desc: ログインしてトークンを取得する
runners:
  req: ${API_BASE}
vars:
  email: [email protected]
  password: ${API_PASSWORD}
secrets:
  - vars.password
steps:
  login:
    req:
      /login:
        post:
          body:
            application/json:
              email: "{{ vars.email }}"
              password: "{{ vars.password }}"
    test: current.res.status == 200

${API_PASSWORD} の形で書くと、実行時の環境変数が展開されます。secrets: に並べた値は出力でマスクされ、--debug 付きで実行しても送信ボディは "password":"*****" と表示されました。steps: を配列でなくキー付きのマップで書くと、後から steps.login のように名前で参照できます。

includeによるトークンの取得と後続APIへの受け渡し

本体のシナリオでは、先頭のステップで login.yml を include します。include先で記録された値は入れ子になるため、トークンは steps.auth.steps.login.res.body.token で参照します。

# books/item_flow.yml
desc: アイテムを登録して処理完了まで待つ
runners:
  req: ${API_BASE}
steps:
  auth:
    include: login.yml
  create:
    req:
      /items:
        post:
          headers:
            Authorization: "Bearer {{ steps.auth.steps.login.res.body.token }}"
          body:
            application/json:
              name: report-2026-10
    test: |
      current.res.status == 201
      && current.res.body.status == "pending"
  wait_done:
    req:
      /items/{{ steps.create.res.body.id }}:
        get:
          headers:
            Authorization: "Bearer {{ steps.auth.steps.login.res.body.token }}"
          body: null
    loop:
      count: 5
      until: current.res.status == 200 && current.res.body.status == "done"
      minInterval: 0.2
      maxInterval: 1
$ export API_BASE=http://127.0.0.1:18080 API_PASSWORD=passw0rd
$ runn run books/item_flow.yml
.

1 scenario, 0 skipped, 0 failures

{{ steps.create.res.body.id }} のように、前のステップの応答をパスやヘッダーの中に埋め込めるのが、シナリオテストの中核です。test: の条件式は組み込みの式言語 expr-lang/expr で評価され、&& で複数条件をつなげます。

loop.untilによる非同期処理の完了待ち

wait_done ステップは、until の条件が真になるまで最大5回GETを繰り返します。--debug で確かめると、1回目と2回目は pending、3回目で done が返り、そこでループを抜けて成功しました。count を使い切っても条件を満たさなければ、そのステップは失敗扱いになります。

ここでやりがちなのが、同じステップに test: current.res.body.status == "done" を書き足すことです。実際に書き足すと、runnは警告を出し、1回目の反復で失敗しました。

⚠ Warning: Using both 'loop.until' and 'test' in the same step can lead to unexpected behavior.
  The 'test' condition runs on each loop iteration, which may cause early failure.
  Consider using only 'loop.until' for retry logic.
F

1) books/until_pitfall.yml 11f0a4e87c3a57c6664a23d68cadbb95101cf78c
  Failure/Error: loop failed on "until と test を同じステップに書いた例".steps.wait_done.loop[0]: ...

test: は反復ごとに評価されるため、待ち合わせの途中の応答で落ちます。待つステップには until だけを書き、完了後の中身の検証は次のステップに分けます。

失敗したシナリオの読み方とデバッグオプション

パスワードを誤った値にして同じシナリオを実行すると、次の出力になりました。終了コードは1です。

$ API_PASSWORD=wrong runn run books/item_flow.yml
F

1) books/item_flow.yml 798499a48e751eda988fad33a37fc0f30f6ca8ff
   └── books/login.yml
  Failure/Error: test failed on "ログインしてトークンを取得する".steps.login: condition is not true

  Condition:
    current.res.status == 200
    │
    ├── current.res.status => 401
    └── 200

  Failure step (books/login.yml):
  10   login:
  11     req:
  ...
  18     test: current.res.status == 200

1 scenario, 0 skipped, 1 failure

どのrunbookのどのステップで落ちたかが、include元からのツリーで示されます。Condition の下には、条件式の各項が実際に何に評価されたか(ここでは401)が並ぶので、期待値との差がすぐ分かります。

リクエストとレスポンスの中身まで見たいときは、オプションを使い分けます。

  • --debug-on-failure:失敗したステップだけ、送ったリクエストと受け取ったレスポンスを表示します(v1.10.0で追加、環境変数 RUNN_DEBUG_ON_FAILURE でも指定可)。CIのログが全ステップの通信で埋まらないので、CIではこちらを常時付けておくのが実用的です。
  • --debug:成功したステップも含め、全ステップの通信を表示します。手元でシナリオを書いている最中に使います。
  • --format json:結果をJSONで出力します。ステップごとの成否とinclude先の結果が入れ子で入るので、集計や通知に回せます。

なお、runn run 'books/*.yml' のようにディレクトリごと指定すると、include用の login.yml も単独のシナリオとして実行され、件数が二重に数えられます。部品のrunbookを単独で走らせたくない場合は --skip-included を付けます。

OpenAPI定義によるレスポンスのスキーマ違反検出

ステータスコードと一部の値だけを test: で見ていると、レスポンスから項目が消えたことにはクライアント側の不具合で初めて気づきます。runnはHTTPランナーにOpenAPI v3の定義ファイルを渡せ、そのランナーを使うステップのリクエストとレスポンスを定義と照合します。定義の書き方はOpenAPIの仕様書の書き方と3.0・3.1・3.2の差分を参照してください。

# books/login_oas.yml
desc: OpenAPI定義でレスポンスを検証する
runners:
  req:
    endpoint: ${API_BASE}
    openapi3: openapi.yaml
vars:
  password: ${API_PASSWORD}
secrets:
  - vars.password
steps:
  login:
    req:
      /login:
        post:
          body:
            application/json:
              email: [email protected]
              password: "{{ vars.password }}"
    test: current.res.status == 200

試しに、定義の200応答で token と expires_in を必須にし、モックAPIは token だけを返すようにして実行すると、test: ではステータスしか見ていないにもかかわらず、次のエラーで失敗しました。

Failure/Error: http request failed on "OpenAPI定義でレスポンスを検証する".steps.login:
openapi3 validation error: Error: 200 response body for '/login' failed to validate schema,
... Validation Errors: [Reason: missing property 'expires_in'], Line: 21, Column: 17

当初 openapi3: ../openapi.yaml と親ディレクトリの定義を参照したところ、検証より前に「'read:parent' scope is required」で止まりました。定義をrunbookと同じ階層以下に置くか、--scopes read:parent を付けます。

runn coverage を使うと、定義にあるオペレーションのうち、runbookに呼び出しが記述されている割合を出せます。通信して成功した割合ではなく、APIを止めた状態で実行しても値は変わりませんでした。3つのオペレーションを定義してログインだけを記述した状態では、33.3%です。

$ runn coverage books/login_oas.yml
 Spec          Coverage
------------------------
 Total            33.3%
   demo:1.0.0     33.3%

判断としては、OpenAPI定義がすでにあるプロジェクトなら、個別のアサーションを書き足すより先に openapi3 を設定するべきです。数行の追加で、設定したHTTPランナーを使うステップの応答がOpenAPI定義に記載した形で検査され、test: には業務上の条件(状態が done になったか、件数が正しいか)だけを書けば済みます。

GitHub Actionsへのrunnの組み込み

READMEのインストール手順にGitHub Actionは載っていないので、リリースページのdebパッケージを入れて実行します。テスト対象のAPIはDocker Composeで起動します。APIサービスに healthcheck を定義しておき、--wait で正常判定を待ってからシナリオを流します。healthcheckが無いサービスでは、コンテナが実行中になるまでしか待ちません。

name: api-scenario
on:
  pull_request:

jobs:
  scenario:
    runs-on: ubuntu-24.04
    env:
      RUNN_VERSION: 1.11.1
    steps:
      - uses: actions/checkout@v7
      - name: Start API
        run: docker compose up -d --wait
      - name: Install runn
        run: |
          curl -fsSL -o runn.deb \
            https://github.com/k1LoW/runn/releases/download/v${RUNN_VERSION}/runn_${RUNN_VERSION}-1_amd64.deb
          sudo dpkg -i runn.deb
      - name: Run scenarios
        env:
          API_BASE: http://127.0.0.1:8080
          API_PASSWORD: ${{ secrets.API_PASSWORD }}
        run: runn run --skip-included --debug-on-failure 'books/**/*.yml'

シナリオが1件でも失敗すると runn run は終了コード1を返すので、ジョブはそのまま失敗します。RUNN_VERSION を固定しているのは、v1.11.0のような破壊的変更がマイナー版で入ることがあるためです。'books/**/*.yml' を引用符で囲むと、シェルではなくrunn側がパターンを展開します。

シナリオが増えて実行時間が延びたら、--shard-n と --shard-index でrunbookを分割し、ジョブのmatrixで並列に回せます。同じrunnプロセス内で並列実行するrunbook同士は、runbook側に concurrency: use-shared-db のようなキーを書いておくと同時には実行されません。matrixの別ジョブ間にはこの排他制御は効かないので、共有DBに触れるrunbookは同じジョブにまとめるか、DBをジョブごとに分けます。DBやRedisをサービスとして立てる構成はGitHub Actionsのサービスコンテナで詳しく扱っています。

Goで書いたAPIなら、runnをテストヘルパーとして go test の中から呼ぶ方法もあります。httptest.Server のURLをランナーに渡すと、同じrunbookをサーバーを別に起動せず実行できます。

Postman・scenarigoとの比較と選び方

YAMLでシナリオを書くGo製ツールとしては、runnのREADMEも代替として挙げている scenarigo があります。GUIで作る代表は Postman で、CIでは Newman がコレクションを実行します。

観点 runn scenarigo Postman+Newman
シナリオの書き方 YAML YAML GUIで作りJSONで保存
対象プロトコル HTTP、gRPC、DB、CDP、SSH、コマンド HTTP、gRPC HTTPほか
拡張方法 組み込み関数、Goのテストヘルパー Goプラグイン JavaScriptのスクリプト
CIでの実行 単一バイナリ 単一バイナリ Node.js上のnpmパッケージ
ライセンス MIT Apache-2.0 Newmanは Apache-2.0
最新版(2026-10-01時点) v1.11.1 v0.27.0 Newman 6.2.2

選び方は、シナリオの途中でDBの中身を確かめたいか、誰が保守するかで決まります。API呼び出しの後にテーブルの行を直接確認したい、あるいはテストデータをSQLで投入したいなら、DBランナーを組み込みで持つrunnなら、SQLの実行を同じrunbookに記述できます。HTTPとgRPCで足り、独自の検証ロジックをGoで書きたいならscenarigoも候補です。

runnを選ばないほうがよい場面もはっきりしています。QA担当者がGUIでリクエストを組み立てて保守する体制なら、YAMLの差分レビューは負担になるのでPostmanとNewmanによるAPIテスト自動化のほうが定着します。主目的が負荷試験なら、runnにも runn loadt はありますが、仮想ユーザーのランプアップや分散実行まで考えると負荷テスト専用のk6を使うべきです。

よくある質問

シナリオテストとは何ですか?

利用者の操作や業務の流れに沿って、複数の処理を順番に実行して結果を確かめるテストです。API層では、ログインで得たトークンや登録で得たIDを次のリクエストに引き継ぎながら、フロー全体が通るかを検証します。

runnは無料で使えますか?商用のプロジェクトでも使えますか?

MITライセンスのOSSなので無料で使え、商用プロジェクトでの利用も可能です。配布形態はCLIとGoパッケージで、利用にアカウント登録は要りません。

runnのテスト結果をJUnit形式で出力できますか?

v1.11.1時点ではできません。--format が受け付けるのは json と none で、それ以外を指定すると通常の表示になります。CIのテストレポート画面に出したい場合は、--format json の出力を変換する処理を自前で用意します。

パスワードやAPIキーをrunbookに書かずに渡すには?

vars: に ${API_PASSWORD} の形で書いて環境変数から渡し、その変数名を secrets: に並べます。展開されるのは実行時だけで、ログやデバッグ出力では ***** にマスクされます。GitHub Actionsではリポジトリのシークレットを環境変数に割り当てます。

シナリオテストを書く前に、どのAPIから着手すべきですか?

障害時の影響が大きく、かつ複数APIをまたぐフローから始めます。ログイン、決済や申込のように状態が変わる処理、非同期のジョブ完了待ちが典型です。参照系の単発APIは単体のAPIテストで十分なことが多く、シナリオにすると実行時間だけが延びます。

関連記事

お気に入りに入れた記事の一覧

一創の公式 SNS

X では毎朝の新着記事と IT ニュースの速報を、YouTube・TikTok・Instagram・LinkedIn・Facebook では記事の解説動画を配信しています。

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.11.03 テックブログ ノジマ不正アクセスで約700万件漏えい:対象確認・窓口と乗っ取り注文を止める実装
  2. 2026.11.03 テックブログ クラブツーリズム不正アクセス:約432万件の対象確認と偽メールの見分け方・流出検知の設計
  3. 2026.10.29 テックブログ ソニー生命の情報漏洩38.5万件|ログインできない理由、再開状況と窓口の電話番号
  4. 2026.11.02 テックブログ アララの不正アクセス:メール約7,700万件の持ち出しと契約企業が行うAPIキー再設定・代替送信・連名報告
  5. 2026.11.03 テックブログ Securitize不正アクセス:丸井・PPIHのデジタル社債で漏れた項目と投資家情報の分け方

RELATED POSTS 関連記事

目次