カミナシ エンジニアブログ

株式会社カミナシのエンジニアが色々書くブログです

数を増やしても一貫性を保つ、runn の API テスト構成設計

こんにちは、ソフトウェアエンジニアの渡邉(匠)です。 「カミナシ 設備保全」の開発に携わっています。

以前、「40,000行のAPIテスト作成で学んだClaude Code Skillsの育て方」という記事を書きました。 AI を使って大量の API テストを生成した話です。 今回はその土台になっている、テストそのものの構成を紹介します。

私たちのバックエンドの API テストは、数十のAPIを対象に、200 本を超えるファイル、1,000 件を超えるテストケースまで育ちました。 本記事の主題は、テストフレームワーク runn で書いた runbook の設計、なかでも「何を確かめるか」と「どう呼ぶか」を分ける部品化です。 まず土台となる、runn を go test に埋め込む実行基盤を紹介した上で、本題の runbook 設計に入ります。

runn を go test に組み込む

runn は CLI ツールとしても使えますが、このプロジェクトでは go test のテストコードに埋め込んで実行しています。

runbook を go test に乗せ、名前で絞り込む

runn の T() オプションに *testing.T を渡すと、runbook の実行が go test の仕組みに乗ります。 runn.T(t) を渡すだけで、runbook 一本ずつがテスト名つきで報告され、失敗箇所をテスト名で特定できます。 CI のテストレポートにも IDE のテストランナーにも、Go の他のテストと区別なく載ります。

部分実行の仕組みも私たちで足しています。 runn には実行対象を名前で絞り込む RunMatch というオプションがあるので、環境変数 RUNN_RUN を読んでそこに渡す薄いコードを書いています。

RUNN_RUN=TaskService/GetTask go test ./scenario_test/...

これは、このあと詳しく読むテストを 1 本だけ動かすときのコマンドです。 200 本を超えるファイルを毎回流すと手元では時間がかかるので、いま触っている API のテストだけを回せると開発の回転が速くなります。 CLI で特定の runbook だけ実行するのと同じ体験を、go test 上で再現しています。

テストを動かすための周辺コンテナと設定

テストの起動時に TestMain が DB、外部サービスの HTTP モックのコンテナを並行起動します。

API サーバー自体は、httptest.NewServerでインプロセス起動します。 runn 側の設定は、ほぼ次の runn.Option 一つに集約されています。

opts := []runn.Option{
    runn.T(t),                          // go test に統合(runbook がテスト名つきで報告される)
    runn.Runner("req", testServer.URL), // HTTP runner → インプロセスのAPIサーバー
    runn.Runner("db", dbContainer.DSN), // DB runner → DB コンテナ
    runn.Runner("mock", mockContainer.RegisterEndpoint), // モック登録 runner → Mock コンテナ
    runn.RunConcurrent(true, runtime.GOMAXPROCS(0)),         // runbook を並列実行
    runn.Scopes(runn.AllowReadParent),                       // 親ディレクトリの runbook を include 可能にする
}

// RUNN_RUN が指定されていれば、その名前にマッチする runbook だけを走らせる
if runMatch := os.Getenv("RUNN_RUN"); runMatch != "" {
    opts = append(opts, runn.RunMatch(runMatch))
}

t.Run("RPCテスト", func(t *testing.T) {
        t.Parallel()

        o, err := runn.Load("runbook/rpc/example.*/*.yaml", opts...)
        if err != nil {
                t.Fatal(err)
        }
        if err := o.RunN(t.Context()); err != nil {
                t.Error(err)
        }
})
t.Run("シナリオテスト", func(t *testing.T) {
        t.Parallel()
 
        o, err := runn.Load("runbook/scenario/example.*/*.yaml", opts...)
        if err != nil {
                t.Fatal(err)
        }
        if err := o.RunN(t.Context()); err != nil {
                t.Error(err)
        }
})

runn.Runner で名前付きランナーを3つ登録しています。 req はインプロセスの API サーバーへ、db は DBのコンテナへ、mock はモック登録 API へつながります。 runbook の中で req: db: と書いたときに、これらの宛先が使われます。

「何を確かめるか」と「どう呼ぶか」を分ける

テスト本体は何を確かめるかだけを持ち、どう呼ぶかは別の部品に切り出します。 以下では、まず1ファイルを読んでこの分離が実物でどう見えるかを示し、その上で分け方を支える中核の規約へ進みます。

検証と呼び出しの分離を1ファイルで見る

例として、架空のタスク管理ドメインの example.task.v1.TaskService/GetTask のAPI のテストを読みます。 まず前半、共通の初期化とセットアップの部分から見ていきます。

desc: |
  example.task.v1.TaskService/GetTask のテスト

steps:
  # === [Initialize] 全テスト共通 ===
  initialize_tenant:
    desc: "[Initialize] テナントの作成"
    include:
      path: ../../setup/setup_tenant.yaml
    bind:
      tenantID: current.tenantID
      group1: current.group1
      group2: current.group2
      user1: current.user1
      user2: current.user2
      adminUser: current.adminUser

  # === [Setup] 共通セットアップ ===
  setup_task_1:
    desc: "[Setup] 共通: テスト用タスクをDBに挿入"
    include:
      path: ../../seed/insert_task.yaml
      vars:
        tenantID: "{{ tenantID }}"
        groupID: "{{ group1.group.id }}"
        title: "テストタスク"
    bind:
      testTask: current.task

initialize_tenant はテナント一式を、setup_task_1 は検証対象のタスクを用意します。 どちらも include でほかの runbook を呼び出し、その結果を bind で名前付きの変数に束ねています。 ここで作った tenantIDtestTask を、後続のテストケースが参照します。

後半がテストケースです。 正常系と異常系を同じファイルに並べます。 ここでは代表として、成功する 200 と、入力が不正な 400 の2ケースを見ます。

  # === test_200_1: 正常系(全フィールド検証) ===
  test_200_1:
    desc: "[Test] 正常系: タスクを取得できる(全フィールド検証)"
    include:
      path: ../../service/example.task.v1.TaskService/GetTask.yaml
      vars:
        auth:
          tenantID: "{{ tenantID }}"
          userID: "{{ user1.user.userID }}"
        taskID: "{{ testTask.id }}"
    test: |
      current.res.status == 200 &&
      current.res.body.task.id == testTask.id &&
      current.res.body.task.groupId == group1.group.id &&
      current.res.body.task.title == "テストタスク"

  # === test_400_1: 異常系(IDが空)===
  test_400_1:
    desc: "[Test] 異常系: タスクIDが空文字の場合はバリデーションエラー"
    include:
      path: ../../service/example.task.v1.TaskService/GetTask.yaml
      vars:
        auth:
          tenantID: "{{ tenantID }}"
          userID: "{{ user1.user.userID }}"
        taskID: ""
    test: |
      current.res.status == 400

ケース名は test_{ステータス}_{連番} で揃えてあります。 正常系の test_200_1 はレスポンス本文の各フィールドまで検証し、異常系の test_400_1 はステータスコードだけを確認します。 1ファイルがこのように正常系と異常系をまとめて持ち、その集合が全体のテストケースを構成しています。

役割でディレクトリを分け、直接呼び出しを禁じる

runbook は役割ごとにディレクトリで分かれています。

scenario_test/runbook/
├── rpc/       # API テスト本体。1 ファイルで 1 つの API の正常系と異常系を網羅する
├── scenario/  # 複数 API をまたぐ業務フローの統合テスト本体。正常系のみを通す
├── service/   # 1 つの API を呼ぶ手順を定義した再利用部品
├── seed/      # 1 つのテーブルに 1 レコードを INSERT する再利用部品
├── setup/     # seed と mock を組み合わせ、テナント一式のような複雑なデータ構造をまとめて用意する共通処理
├── mock/      # 外部サービスのモックを登録する再利用部品
└── fixture/   # テストで読み込む Excel/CSV のデータ

このディレクトリ分割には一つの規約が乗っています。 rpc/ と scenario/ の中で req: db: を直接書くことを禁じています。 HTTP を呼ぶなら service/ を、DB に INSERT するなら seed/ を include します。

rpc/, scenario/ ──include──> service/ ──req──>  API
                ──include──> seed/    ──db──>   DB
                ──include──> setup/   ──include──> seed/ + mock/

HTTP を叩く req: は service/ に、DB へ INSERT する db: は seed/ に隔離され、それらを使うテスト本体とは別のファイルに分かれています。 この規約のおかげで、テスト本体は何を確かめるかだけに集中できます。 どのエンドポイントをどんなヘッダーで叩くか、どのテーブルにどのカラムを入れるかは、すべて部品側の関心事になります。

この分離を、推奨にとどめず禁止という強制にしているのには理由があります。 大量のシナリオテストに、一箇所でも直接呼び出しが混じると「テスト本体は検証だけを持つ」という前提が崩れ、変更時のメンテナンスコストが高くなってしまいます。

テスト本体が集中する「何を確かめるか」が指す範囲も絞り込んでいます。 確かめるのは、認証や権限、ステータスコードへの変換、API スキーマとの変換のような、API 境界でしか確かめられないものです。 先ほどのテストのファイルで test_200_1 がレスポンス本文の各フィールドまで検証していたのも、API 境界がスキーマどおりの値を返すかを確かめるためです。 検索条件やバリデーションの細かいロジックは、repository や domain の単体テストに寄せます。 このテストにすべてを背負わせない、という線引きを置いています。

service/:「どう呼ぶか」だけを引き受ける

「どう呼ぶか」を引き受けるのが service/ の runbook です。 先ほどのテストが include していた GetTask の呼び出し定義を見ます。

desc: |
  POST /example.task.v1.TaskService/GetTask
  タスクを取得する

vars:
  auth:
    tenantID: # テナントID
    userID: # ログインしているユーザーID
  taskID: # 取得するタスクID

if: included
steps:
  - bind:
      auth: vars.auth
      taskID: vars.taskID
    test: |
      auth.tenantID != nil
      && auth.userID != nil
      && taskID != nil

  - req:
      /example.task.v1.TaskService/GetTask:
        post:
          headers:
            DummyTenantID: "{{ auth.tenantID }}"
            DummyUserID: "{{ auth.userID }}"
          body:
            application/json:
              id: "{{ taskID }}"
    bind:
      res: current.res

この部品は、リクエストを送ってレスポンスを呼び出し元に返すだけです。 ステータスコードや本文の検証はしません。 検証は呼び出し元の責務で、ここは「呼び方」だけを知っています。

工夫が二つあります。 一つは if: included です。 これは「include されたときだけ実行する」というガードで、service/ の runbook を単体で走らせても何もしません。 runn は runbook/ 配下のファイルをまとめて読み込むので、部品ファイルが単独のテストとして実行されてしまうのを防いでいます。

もう一つは、最初のステップの test: です。 受け取った auth.tenantID auth.userID taskIDnil でないことを検証しています。 YAML にはスキーマがなく、呼び出し元が必須パラメータを渡し忘れても本来は静かに先へ進んでしまいます。 そこで部品の冒頭に存在チェックを置き、渡し忘れをその場で失敗させます。 スキーマがない YAML に、擬似的な型チェックをおこなっています。

seed/ と setup_tenant:データを「部品」から組み上げる

DB への書き込みは seed/ が引き受けます。 1ファイルが1テーブルの1レコードに対応します。 タスクを1件 INSERT する insert_task.yaml はこうなっています。 コード中の faker は、runn が備えるテストデータ生成関数です。

desc: |
  タスクを追加する

vars:
  id: # タスクID
  tenantID: # テナントID
  groupID: # グループID
  title: # タスク名

if: included
steps:
  - bind:
      task:
        id: "vars.id ?? faker.UUIDv7()"
        tenantID: "vars.tenantID"
        groupID: "vars.groupID"
        title: "vars.title ?? faker.LetterN(10)"
    test: |
      task.tenantID != nil
      && task.groupID != nil

  - db:
      query: |
        INSERT INTO tasks (id, tenant_id, group_id, title)
        VALUES ('{{ task.id }}', '{{ task.tenantID }}', '{{ task.groupID }}', '{{ task.title }}');

vars.id ?? faker.UUIDv7()?? は nil 合体演算子で、呼び出し元が値を渡さなければ faker でデフォルト値を生成します。 ID やタイトルのように検証に関係しないフィールドは、毎回ランダムな値で埋まります。 一方で依存 ID(tenantIDgroupID)は、なければテストが意味をなさないので test: で必須チェックします。 service/ と同じ擬似的な型チェックが、データ生成側にも効いています。

個々の seed をまとめ上げて「会社一式」を作るのが setup/ です。 テストの起点になる setup_tenant.yaml は、テナント、グループ、ユーザーを作ります。 テナントの中に複数のグループがぶら下がる包含関係になっています。

setup_tenant の中身も規約どおりで、db:req: も直接書きません。 seed/ と、もう一つ mock/ を include して組み立てます。

  mock_idp_get_tenant:
    desc: テナント名取得のモックを設定
    include:
      path: ../mock/idp/GET_api_v1_tenants_tenantID.yaml
      vars:
        tenantID: "{{ tenantID }}"
    bind:
      tenantName: current.tenantName

外部の API を呼ぶ箇所は、モックを登録して差し替えます。 そのモック登録も専用の仕組みを用意せず、runbook の 1 ステップで済ませています。 実行基盤の runn.Option で登録した mock runner が、ここで使われます。 mock/ の runbook が mock の登録 API を叩き、テナント名取得のレスポンスを差し込みます。

この setup_tenant には、テスト同士を干渉させない仕掛けも仕込まれています。 私たちは runbook を並列実行しているので、共有の PostgreSQL に複数のテストが同時にアクセスします。 それでもテストの独立性が保たれるのは、テナント ID の発番の作りによります。

発番の起点となる seed が、テナント ID を未指定なら毎回ユニークな値を生成するので、setup_tenant を include したテストファイルはそれぞれ別々のテナント ID を持ちます。 そのテナント ID を seed の INSERT がそのまま直書きするので、テストごとにデータが分かれ、後始末も要りません。

まとめ

ここまでの構成は、「何を確かめるかとどう呼ぶかをレイヤーで分け、部品化する」という一つの設計判断に集約できます。

この拠り所は、書き手が人でも AI でも変わりません。 現在 API テストはすべて AI に書かせていますが、テスト本体は何を確かめるかだけを持ち、どう呼ぶかは決められた部品を include する、という規約をあらかじめ固めてあるおかげで、AI に任せても形がぶれにくくなっています。 規約だけで一貫性がすべて保証されるわけではありませんが、人にも AI にも同じ拠り所があると、ファイル数が増えても構成を揃えやすくなる、というのが今のところの手応えです。