🏃

「runn」で API のリプレースを安全におこなう

2024/12/14に公開

この記事は「レバテック開発部 Advent Calendar 2024」の 14 日目の記事です!
昨日の記事は、dema96さんの「E2Eテストのテスト範囲と優先順位を考えてみた」でした。

TL;DR

  • API のリプレースを計画しているが、更新系の API の同等性検証をどうやるか悩んでいた
  • API シナリオテストツール「runn」の「HTTP の API を実行する機能」と「データベースへのクエリを実行する機能」を使って、更新系 API の同等性検証をやってみた
  • 実務で活用に向けてより詳細な検証が必要だが、API 実行後のデータベースの状態の検証やデータ駆動テストなど、API の同等性検証に活用できそうに感じた

はじめに

こんにちは、レバテック開発部の塚原です!
今回は、以前から気になっていた「runn」というツールの検証記事です!

背景: PHP から TypeScript へ API のリプレースを計画しています

自チームで担当しているシステムに 運用年数が 10 年間程度のレガシーシステムがあり、以下の問題を抱えています。

  • 技術負債が溜まっており、変更容易性が低い
  • フレームワークやライブラリが古く、セキュリティリスクがある

これらの問題を解決するために、レガシーシステムのリプレースを計画しています。
実際のシステム構成よりかなり簡易的な図ですが、PHP で実装された旧 API を TypeScript の 新 API に実装し直し、フロントエンドサーバーから呼び出す API を切り替えていくイメージです。

また、その際に 新旧 API がアクセスするデータベースのテーブル定義や API の入出力・振る舞い (データベースへの読み書きなど) は変更しないですが、REST API から GraphQL に変更することを検討しています。

課題: API のリプレースの同等性検証どうやろう 🤔

API のリプレースは「実装工程ごとに検証工程を設けて、手戻り発生リスクを少なく安全に進めたい」と考えていました。ただし、API 単位の振る舞いを検証する統合テストをどのような方法で実施するか悩んでいました。

# 実装工程 検証工程
1 API に必要な各コンポーネントの実装 [単体テスト] テストコードを実装して、各コンポーネントの振る舞いを検証
2 1 で実装したコンポーネントを利用して API を完成させる [統合テスト] API 単位の振る舞いの同等性を検証したいけど、どうやってやろう🤔
3 旧 API の呼び出しを 2 で実装した新 API の呼び出しに切り替える [システムテスト] 新 API に切り替え後も同等の操作ができるかブラウザから検証

というのも、GET などの参照系の API では、リクエストのパターンを洗い出して、「新旧 API に同じリクエストを与えたときのレスポンスが同一であること」を確認することで同等性検証ができますが、更新系の API (POST, PUT など) では、レスポンスの同等性だけではなく、データ更新の同等性も検証する必要があるため、検証が難しそうなイメージがありました。

例えば、更新系の API の例として、タスクを登録する POST の API を考えると、新旧 API にタスクのタイトル・説明など同じリクエストを送信したとき、データベース上に保存されるタスクが同等であることを検証する必要があります。

対応案: 「runn」 で API のリプレースを安全におこなう

そこで、runn (ランエヌ) というツールを使って、更新系の API でも同等性検証できるか試してみます。

API シナリオテストツール「runn」の紹介

runn を使って API の同等性検証を試す前に、まずは runn の基本的な機能について簡単に紹介していきます。

「API シナリオテストツール」と表現されている通り、runn は複数の API をシナリオとして実行することができます。

簡単な例を用いて説明します。runn では、Runbook という Yaml 形式のファイルにシナリオを記述して実行します。簡易的なタスク管理ツールを例にとると、以下のスクリプトは「タスクを登録する → 更新する → 削除する」というシナリオになっています。

このスクリプトでは、はじめにタスク登録 API を実行し、そのレスポンスに含まれるタスクの ID を使って、タスク更新 API やタスク削除 API を実行しています。
前段のレスポンスの値を後続のリクエストに含めることでシナリオを構成していくことができます。

task-sample-scenario.yml
desc: タスク作成 → 更新 → 削除
runners:
  apiUrl: http://localhost:3003
steps:
  createTask:
    desc: "タスクを作成します"
    apiUrl:
      /tasks:
        post:
          body:
            application/json:
              title: "新規タスク"
              description: "新規タスクの説明"
    test: current.res.status == 200
  updateTask:
    desc: "タスクを更新します"
    apiUrl:
      /tasks/{{ steps.createTask.res.body.id }}:
        put:
          body:
            application/json:
              title: "更新されたタスクの説明"
              description: "更新されたタスクの説明"
    test: current.res.status == 200
  deleteTask:
    desc: "タスクを削除します"
    apiUrl:
      /tasks/{{ steps.createTask.res.body.id }}:
        delete:
          body: null
    test: current.res.status == 200

runn run task-sample.scenario.yml --debug でシナリオを実行すると、以下のような結果が出力されます。各 API の実行結果が出力されており、最終行に検証結果が出力されていることがわかります。

$ runn run task-sample.scenario.yml --debug

Run "タスクを作成します" on "タスク作成 → 更新 → 削除".steps.createTask
-----START HTTP REQUEST-----
POST /tasks HTTP/1.1
Host: localhost:3003
Content-Type: application/json

{"description":"新規タスクの説明","title":"新規タスク"}
-----END HTTP REQUEST-----
-----START HTTP RESPONSE-----
HTTP/1.1 200 OK
Content-Length: 153
Content-Type: application/json; charset=UTF-8
Date: Thu, 05 Dec 2024 16:19:12 GMT

{"id":1,"title":"新規タスク","description":"新規タスクの説明","createdAt":"2024-12-05T16:19:12.431Z","updatedAt":"2024-12-05T16:19:12.431Z"}
-----END HTTP RESPONSE-----
Run "test" on "タスク作成 → 更新 → 削除".steps.createTask

Run "タスクを更新します" on "タスク作成 → 更新 → 削除".steps.updateTask
-----START HTTP REQUEST-----
PUT /tasks/1 HTTP/1.1
Host: localhost:3003
Content-Type: application/json

{"description":"更新されたタスクの説明","title":"更新されたタスクの説明"}
-----END HTTP REQUEST-----
-----START HTTP RESPONSE-----
HTTP/1.1 200 OK
Content-Length: 180
Content-Type: application/json; charset=UTF-8
Date: Thu, 05 Dec 2024 16:19:12 GMT

{"id":1,"title":"更新されたタスクの説明","description":"更新されたタスクの説明","createdAt":"2024-12-05T16:19:12.431Z","updatedAt":"2024-12-05T16:19:12.444Z"}
-----END HTTP RESPONSE-----
Run "test" on "タスク作成 → 更新 → 削除".steps.updateTask

Run "タスクを削除します" on "タスク作成 → 更新 → 削除".steps.deleteTask
-----START HTTP REQUEST-----
DELETE /tasks/1 HTTP/1.1
Host: localhost:3003


-----END HTTP REQUEST-----
-----START HTTP RESPONSE-----
HTTP/1.1 200 OK
Content-Length: 39
Content-Type: application/json; charset=UTF-8
Date: Thu, 05 Dec 2024 16:19:12 GMT

{"message":"Task deleted successfully"}
-----END HTTP RESPONSE-----
Run "test" on "タスク作成 → 更新 → 削除".steps.deleteTask
.

1 scenario, 0 skipped, 0 failures

上記の例では REST API を利用したシナリオですが、他さまざまなプロトコルに対応しています。

導入手順やより詳しい使い方は、READMErunn チュートリアルrunn クックブックを参照するのが良いと思います。

API のリプレースの同等性検証に「runn」をどう活用する?

さて、runn の基本的な機能のを紹介したところで、runn を使って「どうやって更新系の API の同等性を検証するか」という課題の解決を試みます。アイデアは簡単で、runn の以下2つの機能を組み合わせることで、「API 実行時にデータベースに保存されるデータの同等性を検証する」というものです。

  • HTTP の API を実行する機能
  • データベースへのクエリを実行する機能

ここでは、以下の例で更新系の API の同等性検証に runn を活用できるか試していきます。

  • タスク登録 API をリプレースする
  • プログラミング言語は、PHP から TypeScript にリプレースする
  • API は REST API から GraphQL にリプレースする
  • タスク登録 API は、リクエストとして title (タスクのタイトル)description (タスクの説明)を受け取る
  • タスク登録 API は、データベースにタスクのタイトルとタスクの説明を保存する

まず、この API の同等性検証に利用する Runbook を載せます。

create-task.test.yml
desc: タスク登録 API の同等性検証
runners:
  oldApiServer: http://localhost:3003
  newApiServer: http://localhost:3004
  oldDb: "mysql://root:root@127.0.0.1:3303/task_management"
  newDb: "mysql://root:root@127.0.0.1:3304/task_management"
vars:
  testData:
    request:
      body:
        title: "タスクのタイトル"
        description: "タスクの説明"
    response:
      status: 200
    checkQuery: "SELECT title, description FROM Task ORDER BY id DESC LIMIT 1"
steps:
  execOldApi:
    desc: "旧 API を実行します"
    oldApiServer:
      /tasks:
        post:
          body:
            application/json:
              title: "{{ vars.testData.request.body.title }}"
              description: "{{ vars.testData.request.body.description }}"
    test: current.res.status == vars.testData.response.status
  execNewApi:
    desc: "新 API を実行します"
    newApiServer:
      /graphql:
        post:
          body:
            application/json:
              query: |
                mutation createTask($title: String!, $description: String) {
                  createTask(title: $title, description: $description) {
                    id title description 
                  } 
                }
              variables:
                title: "{{ vars.testData.request.body.title }}"
                description: "{{ vars.testData.request.body.description }}"
    test: current.res.status == vars.testData.response.status
  getOldData:
    desc: "旧 API の実行結果を取得します"
    oldDb:
      query: "{{ vars.testData.checkQuery }}"
  getNewData:
    desc: "新 API の実行結果を取得します"
    newDb:
      query: "{{ vars.testData.checkQuery }}"
  compareResult:
    desc: "結果を比較します"
    test: compare(steps.getOldData.rows, steps.getNewData.rows)
実行結果

runn run create-task.test.yml --debug の実行結果は以下のようになります。

$runn run create-task.test.yml --debug
Run "旧 API を実行します" on "タスク登録 API の同等性検証".steps.execOldApi
-----START HTTP REQUEST-----
POST /tasks HTTP/1.1
Host: localhost:3003
Content-Type: application/json

{"description":"タスクの説明","title":"タスクのタイトル"}
-----END HTTP REQUEST-----
-----START HTTP RESPONSE-----
HTTP/1.1 200 OK
Content-Length: 156
Content-Type: application/json; charset=UTF-8
Date: Sat, 07 Dec 2024 12:45:01 GMT

{"id":1,"title":"タスクのタイトル","description":"タスクの説明","createdAt":"2024-12-07T12:45:02.586Z","updatedAt":"2024-12-07T12:45:02.586Z"}
-----END HTTP RESPONSE-----
Run "test" on "タスク登録 API の同等性検証".steps.execOldApi

Run "新 API を実行します" on "タスク登録 API の同等性検証".steps.execNewApi
-----START HTTP REQUEST-----
POST /graphql HTTP/1.1
Host: localhost:3004
Content-Type: application/json

{"query":"mutation createTask($title: String!, $description: String) {\n  createTask(title: $title, description: $description) {\n    id title description \n  } \n}\n","variables":{"description":"タスクの説明","title":"タスクのタイトル"}}
-----END HTTP REQUEST-----
-----START HTTP RESPONSE-----
HTTP/1.1 200 OK
Content-Length: 103
Access-Control-Allow-Origin: *
Content-Type: application/json; charset=utf-8
Date: Sat, 07 Dec 2024 12:45:02 GMT
Etag: W/"67-JIIFXelx9hAflwKcBdQwbQFV8AU"

{"data":{"createTask":{"id":1,"title":"タスクのタイトル","description":"タスクの説明"}}}

-----END HTTP RESPONSE-----
Run "test" on "タスク登録 API の同等性検証".steps.execNewApi

Run "旧 API の実行結果を取得します" on "タスク登録 API の同等性検証".steps.getOldData
-----START QUERY-----
SELECT title, description FROM Task ORDER BY id DESC LIMIT 1
-----END QUERY-----
-----START QUERY RESULT-----
+------------------+--------------+
|      title       | description  |
+------------------+--------------+
| タスクのタイトル | タスクの説明 |
+------------------+--------------+
(1 row)
-----END QUERY RESULT-----

Run "新 API の実行結果を取得します" on "タスク登録 API の同等性検証".steps.getNewData
-----START QUERY-----
SELECT title, description FROM Task ORDER BY id DESC LIMIT 1
-----END QUERY-----
-----START QUERY RESULT-----
+------------------+--------------+
|      title       | description  |
+------------------+--------------+
| タスクのタイトル | タスクの説明 |
+------------------+--------------+
(1 row)
-----END QUERY RESULT-----

Run "結果を比較します" on "タスク登録 API の同等性検証".steps.compareResult
Run "test" on "タスク登録 API の同等性検証".steps.compareResult
.

1 scenario, 0 skipped, 0 failures

この Runbook の内容を上から順に説明していきます。

  1. RunBook の説明

    desc: タスク登録 API の同等性検証
    

    desc を使って RunBook の説明を記載しています。

  2. ランナーの設定

    runners: 
      oldApiServer: http://localhost:3003
      newApiServer: http://localhost:3004
      oldDb: "mysql://root:root@127.0.0.1:3303/task_management"
      newDb: "mysql://root:root@127.0.0.1:3304/task_management"
    

    runners を使って、新旧 API のサーバーの URL とデータベースの設定を宣言しています。

  3. テストデータ・期待値の設定

    vars:
      testData: 
        request:
          body:
            title: "タスクのタイトル"
            description: "タスクの説明"
        response:
          status: 200
        checkQuery: "SELECT title, description FROM Task ORDER BY id DESC LIMIT 1"
    

    varsを使って、新旧 API のリクエストや期待値となるレスポンスのデータを定義します。
    また、checkQuery は、新旧 API がデータベースに保存するデータが同じことを確認するための SQL 定義しています。

  4. 旧 API の実行

    steps:
      execOldApi:
        desc: "旧 API を実行します"
        oldApiServer:
          /tasks:
            post:
              body:
                application/json:
                  title: "{{ vars.request.body.title }}"
                  description: "{{ vars.request.body.description }}"
        test: current.res.status == vars.response.status
    

    ここからは steps を使って、実際のシナリオのステップを記述しています。
    最初のステップとして、runners で定義していた oldApiServer を利用して旧 API (REST API) を実行するステップを記述しています。リクエストボディに入れている titledescription は、vars で定義した変数を利用して値を設定しています。また、test では、HTTP レスポンスステータスが 200 で返却されることを検証しています。

  5. 新 API の実行

      execNewApi:
        desc: "新 API を実行します"
        newApiServer:
          /graphql:
            post:
              body:
                application/json:
                  query: |
                    mutation createTask($title: String!, $description: String) {
                      createTask(title: $title, description: $description) {
                        id title description 
                      } 
                    }
                  variables:
                    title: "{{ vars.request.body.title }}"
                    description: "{{ vars.request.body.description }}"
        test: current.res.status == vars.response.status
    

    次のステップとして、runners で定義していた newApiServer を利用して新 API (GraphQL) を実行するステップを記述しています。新 API は、GraphQL で実装されているため、GraphQL の Mutation を実行するようにリクエストボディを設定しています。

  6. 新旧 API が保存したデータの取得

      getOldData:
        desc: "旧 API の実行結果を取得します"
        oldDb:
          query: "{{ vars.checkQuery }}"
      getNewData:
        desc: "新 API の実行結果を取得します"
        newDb:
          query: "{{ vars.checkQuery }}"
    

    ここでは runners で定義していた oldDbnewDb を使って、新旧 API が保存したデータを取得しています。vars で定義していた checkQuery を使って、データを取得するための SQL を query に設定しています。

  7. 新旧 API が保存したデータの同等性確認

      compareResult:
        desc: "結果を比較します"
        test: compare(steps.getOldData.rows, steps.getNewData.rows)
    

    最後に前段で取得した新旧 API 実行時にデータベースに保存されたデータを compare 関数を使って、比較検証しています。この検証により、新 API が旧 API と同等のデータ更新を行っていることが担保できます。

必要なデータパターンをテストする

runn を使って更新系 API の同等性検証を実施しましたが、現状ではリクエストデータのパターンを一つしか検証できていません。実際には、API の同等性検証をするときは、処理の内容に応じて複数のリクエストデータのパターンを検証したいはずです。ここでは、この問題を解決するために runn でデータ駆動テストを実施してみます。

runn でデータ駆動テストを実施するにあたり必要な機能を紹介しつつ、先ほど紹介したタスク登録 API の同等性検証の RunBook (create-task.test.yml) にデータ駆動テストを適用させてみます。

テストデータを Json ファイルに切り出す

データ駆動テストを実施するにあたり、複数のテストデータを定義する必要があります。この場合 RunBook の vars に直接テストデータを記述すると RunBook が長くなってしまうため、ここでは、Json ファイルからデータを読み込む機能を利用して、テストデータを外部ファイルに切り出して管理できるようにします。まずは、create-task.test.yml のテストデータを外部の Json ファイルに切り出すため、以下の Json ファイルを用意します。

test-data_1.json
{
  "request": {
    "body": {
      "title": "タスクのタイトル1",
      "description": "タスクの説明1"
    }
  },
  "response": {
    "status": 200
  },
  "checkQuery": "SELECT title, description FROM Task ORDER BY id DESC LIMIT 1"
}

そして、 create-task.test.yml を以下のように変更します。

create-task.test.yml
 vars: 
-  testData: 
-    request:
-      body:
-        title: "タスクのタイトル"
-        description: "タスクの説明"
-    response:
-      status: 200
-    checkQuery: "SELECT title, description FROM Task ORDER BY id DESC LIMIT 1"
+  testData: "json://test-data_1.json"

これで、Json ファイルに切り出したテストデータを読み込むことができます。

実行結果

runn run create-task.test.yml --debug の実行結果は以下のようになります。

$runn run create-task.test.yml --debug
Run "旧 API を実行します" on "タスク登録 API の同等性検証".steps.execOldApi
-----START HTTP REQUEST-----
POST /tasks HTTP/1.1
Host: localhost:3003
Content-Type: application/json

{"description":"タスクの説明1","title":"タスクのタイトル1"}
-----END HTTP REQUEST-----
-----START HTTP RESPONSE-----
HTTP/1.1 200 OK
Content-Length: 158
Content-Type: application/json; charset=UTF-8
Date: Sat, 07 Dec 2024 14:39:32 GMT

{"id":1,"title":"タスクのタイトル1","description":"タスクの説明1","createdAt":"2024-12-07T14:39:32.931Z","updatedAt":"2024-12-07T14:39:32.931Z"}
-----END HTTP RESPONSE-----
Run "test" on "タスク登録 API の同等性検証".steps.execOldApi

Run "新 API を実行します" on "タスク登録 API の同等性検証".steps.execNewApi
-----START HTTP REQUEST-----
POST /graphql HTTP/1.1
Host: localhost:3004
Content-Type: application/json

{"query":"mutation createTask($title: String!, $description: String) {\n  createTask(title: $title, description: $description) {\n    id title description \n  } \n}\n","variables":{"description":"タスクの説明1","title":"タスクのタイトル1"}}
-----END HTTP REQUEST-----
-----START HTTP RESPONSE-----
HTTP/1.1 200 OK
Content-Length: 105
Access-Control-Allow-Origin: *
Content-Type: application/json; charset=utf-8
Date: Sat, 07 Dec 2024 14:39:32 GMT
Etag: W/"69-LrkAWGPUbl3zA37rbxIqGjXxFKs"

{"data":{"createTask":{"id":1,"title":"タスクのタイトル1","description":"タスクの説明1"}}}

-----END HTTP RESPONSE-----
Run "test" on "タスク登録 API の同等性検証".steps.execNewApi

Run "旧 API の実行結果を取得します" on "タスク登録 API の同等性検証".steps.getOldData
-----START QUERY-----
SELECT title, description FROM Task ORDER BY id DESC LIMIT 1
-----END QUERY-----
-----START QUERY RESULT-----
+-------------------+---------------+
|       title       |  description  |
+-------------------+---------------+
| タスクのタイトル1 | タスクの説明1 |
+-------------------+---------------+
(1 row)
-----END QUERY RESULT-----

Run "新 API の実行結果を取得します" on "タスク登録 API の同等性検証".steps.getNewData
-----START QUERY-----
SELECT title, description FROM Task ORDER BY id DESC LIMIT 1
-----END QUERY-----
-----START QUERY RESULT-----
+-------------------+---------------+
|       title       |  description  |
+-------------------+---------------+
| タスクのタイトル1 | タスクの説明1 |
+-------------------+---------------+
(1 row)
-----END QUERY RESULT-----

Run "結果を比較します" on "タスク登録 API の同等性検証".steps.compareResult
Run "test" on "タスク登録 API の同等性検証".steps.compareResult
.

1 scenario, 0 skipped, 0 failures

親から子の RunBook を呼び出す

runn では 特定の Runbook から別の RunBook を呼び出すことができます。その際に、親から子に変数を渡すこともできます。create-task.test.yml にテストデータを変数として渡して実行する 親の RunBook (create-task_parent.test.yml) を作成してみましょう。

create-task_parent.test.yml
vars:
  testData: "json://test-data_1.json"
steps:
  exec:
    desc: "子 RunBook を実行します"
    include:
      path: create-task.test.yml
      vars:
        testData: "{{ vars.testData }}"

この RunBook では、include を使って親の RunBook から 子の RunBook にテストデータを渡して呼び出しています。渡されるテストデータを受け取れるように子 RunBook である create-task.test.yml も修正が必要です。

create-task.test.yml
  vars: 
-   testData: "json://test-data_1.json"
+   testData: "{{ parent.vars.testData }}"
+ if: included

このように親 RunBook から変数を受け取るには parent を使います。また、if: included は、子 RunBook 単体で実行された場合に実行をスキップする設定です。
これで、親 RunBook から子 RunBook を呼び出せます。

実行結果

runn run create-task_parent.test.yml --debug の実行結果は以下のようになります。

$runn run create-task_parent.test.yml --debug
Run "子 RunBook を実行します" on "[No Description]".steps.exec
Run "旧 API を実行します" on "タスク登録 API の同等性検証".steps.execOldApi
-----START HTTP REQUEST-----
POST /tasks HTTP/1.1
Host: localhost:3003
Content-Type: application/json

{"description":"タスクの説明1","title":"タスクのタイトル1"}
-----END HTTP REQUEST-----
-----START HTTP RESPONSE-----
HTTP/1.1 200 OK
Content-Length: 158
Content-Type: application/json; charset=UTF-8
Date: Sat, 07 Dec 2024 14:41:53 GMT

{"id":1,"title":"タスクのタイトル1","description":"タスクの説明1","createdAt":"2024-12-07T14:41:54.860Z","updatedAt":"2024-12-07T14:41:54.860Z"}
-----END HTTP RESPONSE-----
Run "test" on "タスク登録 API の同等性検証".steps.execOldApi

Run "新 API を実行します" on "タスク登録 API の同等性検証".steps.execNewApi
-----START HTTP REQUEST-----
POST /graphql HTTP/1.1
Host: localhost:3004
Content-Type: application/json

{"query":"mutation createTask($title: String!, $description: String) {\n  createTask(title: $title, description: $description) {\n    id title description \n  } \n}\n","variables":{"description":"タスクの説明1","title":"タスクのタイトル1"}}
-----END HTTP REQUEST-----
-----START HTTP RESPONSE-----
HTTP/1.1 200 OK
Content-Length: 105
Access-Control-Allow-Origin: *
Content-Type: application/json; charset=utf-8
Date: Sat, 07 Dec 2024 14:41:54 GMT
Etag: W/"69-LrkAWGPUbl3zA37rbxIqGjXxFKs"

{"data":{"createTask":{"id":1,"title":"タスクのタイトル1","description":"タスクの説明1"}}}

-----END HTTP RESPONSE-----
Run "test" on "タスク登録 API の同等性検証".steps.execNewApi

Run "旧 API の実行結果を取得します" on "タスク登録 API の同等性検証".steps.getOldData
-----START QUERY-----
SELECT title, description FROM Task ORDER BY id DESC LIMIT 1
-----END QUERY-----
-----START QUERY RESULT-----
+-------------------+---------------+
|       title       |  description  |
+-------------------+---------------+
| タスクのタイトル1 | タスクの説明1 |
+-------------------+---------------+
(1 row)
-----END QUERY RESULT-----

Run "新 API の実行結果を取得します" on "タスク登録 API の同等性検証".steps.getNewData
-----START QUERY-----
SELECT title, description FROM Task ORDER BY id DESC LIMIT 1
-----END QUERY-----
-----START QUERY RESULT-----
+-------------------+---------------+
|       title       |  description  |
+-------------------+---------------+
| タスクのタイトル1 | タスクの説明1 |
+-------------------+---------------+
(1 row)
-----END QUERY RESULT-----

Run "結果を比較します" on "タスク登録 API の同等性検証".steps.compareResult
Run "test" on "タスク登録 API の同等性検証".steps.compareResult
.

1 scenario, 0 skipped, 0 failures

複数テストデータを読み込んで、データ駆動テストを実施する

いよいよ、複数のデータパターンを読み込んで、データパターンごとにテストを実行するデータ駆動テストを作成していきます。create-task_parent.test.yml を以下のように変更します。

create-task_parent.test.yml
 vars:
-  testData: "json://test-data_1.json"
+  testData:
+    - "json://test-data_1.json"
+    - "json://test-data_2.json"
+    - "json://test-data_3.json"
 steps:
   exec:
     desc: "子 RunBook を実行します"
+    loop:
+      count: len(vars.testData)
     include:
       path: create-task.test.yml
       vars:
-        testData: "{{ vars.testData }}"
+        testData: "{{ vars.testData[i] }}"

この RunBook では、vars に複数の Json ファイルから読み込んだテストデータを渡しています。そして、loopcount に テストデータのパターン数 (len(vars.testData)) を渡すことで、テストデータのパターンごとに子 RunBook を実行しています。そして、vars.testData[i] のようにループインデックスを使って、テストデータを順番に子 RunBook に渡しています。

以上で、複数の Json ファイルにテストデータを定義したデータ駆動テストが完成しました。

実行結果

runn run create-task_parent.test.yml --debug の実行結果は以下のようになります。

$runn run create-task_parent.test.yml --debug
Run "子 RunBook を実行します" on "[No Description]".steps.exec
Run "旧 API を実行します" on "タスク登録 API の同等性検証".steps.execOldApi
-----START HTTP REQUEST-----
POST /tasks HTTP/1.1
Host: localhost:3003
Content-Type: application/json

{"description":"タスクの説明1","title":"タスクのタイトル1"}
-----END HTTP REQUEST-----
-----START HTTP RESPONSE-----
HTTP/1.1 200 OK
Content-Length: 158
Content-Type: application/json; charset=UTF-8
Date: Sat, 07 Dec 2024 14:51:25 GMT

{"id":1,"title":"タスクのタイトル1","description":"タスクの説明1","createdAt":"2024-12-07T14:51:26.109Z","updatedAt":"2024-12-07T14:51:26.109Z"}
-----END HTTP RESPONSE-----
Run "test" on "タスク登録 API の同等性検証".steps.execOldApi

Run "新 API を実行します" on "タスク登録 API の同等性検証".steps.execNewApi
-----START HTTP REQUEST-----
POST /graphql HTTP/1.1
Host: localhost:3004
Content-Type: application/json

{"query":"mutation createTask($title: String!, $description: String) {\n  createTask(title: $title, description: $description) {\n    id title description \n  } \n}\n","variables":{"description":"タスクの説明1","title":"タスクのタイトル1"}}
-----END HTTP REQUEST-----
-----START HTTP RESPONSE-----
HTTP/1.1 200 OK
Content-Length: 105
Access-Control-Allow-Origin: *
Content-Type: application/json; charset=utf-8
Date: Sat, 07 Dec 2024 14:51:25 GMT
Etag: W/"69-LrkAWGPUbl3zA37rbxIqGjXxFKs"

{"data":{"createTask":{"id":1,"title":"タスクのタイトル1","description":"タスクの説明1"}}}

-----END HTTP RESPONSE-----
Run "test" on "タスク登録 API の同等性検証".steps.execNewApi

Run "旧 API の実行結果を取得します" on "タスク登録 API の同等性検証".steps.getOldData
-----START QUERY-----
SELECT title, description FROM Task ORDER BY id DESC LIMIT 1
-----END QUERY-----
-----START QUERY RESULT-----
+-------------------+---------------+
|       title       |  description  |
+-------------------+---------------+
| タスクのタイトル1 | タスクの説明1 |
+-------------------+---------------+
(1 row)
-----END QUERY RESULT-----

Run "新 API の実行結果を取得します" on "タスク登録 API の同等性検証".steps.getNewData
-----START QUERY-----
SELECT title, description FROM Task ORDER BY id DESC LIMIT 1
-----END QUERY-----
-----START QUERY RESULT-----
+-------------------+---------------+
|       title       |  description  |
+-------------------+---------------+
| タスクのタイトル1 | タスクの説明1 |
+-------------------+---------------+
(1 row)
-----END QUERY RESULT-----

Run "結果を比較します" on "タスク登録 API の同等性検証".steps.compareResult
Run "test" on "タスク登録 API の同等性検証".steps.compareResult
Run "旧 API を実行します" on "タスク登録 API の同等性検証".steps.execOldApi
-----START HTTP REQUEST-----
POST /tasks HTTP/1.1
Host: localhost:3003
Content-Type: application/json

{"description":"タスクの説明2","title":"タスクのタイトル2"}
-----END HTTP REQUEST-----
-----START HTTP RESPONSE-----
HTTP/1.1 200 OK
Content-Length: 158
Content-Type: application/json; charset=UTF-8
Date: Sat, 07 Dec 2024 14:51:25 GMT

{"id":2,"title":"タスクのタイトル2","description":"タスクの説明2","createdAt":"2024-12-07T14:51:26.137Z","updatedAt":"2024-12-07T14:51:26.137Z"}
-----END HTTP RESPONSE-----
Run "test" on "タスク登録 API の同等性検証".steps.execOldApi

Run "新 API を実行します" on "タスク登録 API の同等性検証".steps.execNewApi
-----START HTTP REQUEST-----
POST /graphql HTTP/1.1
Host: localhost:3004
Content-Type: application/json

{"query":"mutation createTask($title: String!, $description: String) {\n  createTask(title: $title, description: $description) {\n    id title description \n  } \n}\n","variables":{"description":"タスクの説明2","title":"タスクのタイトル2"}}
-----END HTTP REQUEST-----
-----START HTTP RESPONSE-----
HTTP/1.1 200 OK
Content-Length: 105
Access-Control-Allow-Origin: *
Content-Type: application/json; charset=utf-8
Date: Sat, 07 Dec 2024 14:51:25 GMT
Etag: W/"69-x5ss5RehvILWahnVA/sLZTavFAI"

{"data":{"createTask":{"id":2,"title":"タスクのタイトル2","description":"タスクの説明2"}}}

-----END HTTP RESPONSE-----
Run "test" on "タスク登録 API の同等性検証".steps.execNewApi

Run "旧 API の実行結果を取得します" on "タスク登録 API の同等性検証".steps.getOldData
-----START QUERY-----
SELECT title, description FROM Task ORDER BY id DESC LIMIT 1
-----END QUERY-----
-----START QUERY RESULT-----
+-------------------+---------------+
|       title       |  description  |
+-------------------+---------------+
| タスクのタイトル2 | タスクの説明2 |
+-------------------+---------------+
(1 row)
-----END QUERY RESULT-----

Run "新 API の実行結果を取得します" on "タスク登録 API の同等性検証".steps.getNewData
-----START QUERY-----
SELECT title, description FROM Task ORDER BY id DESC LIMIT 1
-----END QUERY-----
-----START QUERY RESULT-----
+-------------------+---------------+
|       title       |  description  |
+-------------------+---------------+
| タスクのタイトル2 | タスクの説明2 |
+-------------------+---------------+
(1 row)
-----END QUERY RESULT-----

Run "結果を比較します" on "タスク登録 API の同等性検証".steps.compareResult
Run "test" on "タスク登録 API の同等性検証".steps.compareResult
Run "旧 API を実行します" on "タスク登録 API の同等性検証".steps.execOldApi
-----START HTTP REQUEST-----
POST /tasks HTTP/1.1
Host: localhost:3003
Content-Type: application/json

{"description":"タスクの説明3","title":"タスクのタイトル3"}
-----END HTTP REQUEST-----
-----START HTTP RESPONSE-----
HTTP/1.1 200 OK
Content-Length: 158
Content-Type: application/json; charset=UTF-8
Date: Sat, 07 Dec 2024 14:51:25 GMT

{"id":3,"title":"タスクのタイトル3","description":"タスクの説明3","createdAt":"2024-12-07T14:51:26.150Z","updatedAt":"2024-12-07T14:51:26.150Z"}
-----END HTTP RESPONSE-----
Run "test" on "タスク登録 API の同等性検証".steps.execOldApi

Run "新 API を実行します" on "タスク登録 API の同等性検証".steps.execNewApi
-----START HTTP REQUEST-----
POST /graphql HTTP/1.1
Host: localhost:3004
Content-Type: application/json

{"query":"mutation createTask($title: String!, $description: String) {\n  createTask(title: $title, description: $description) {\n    id title description \n  } \n}\n","variables":{"description":"タスクの説明3","title":"タスクのタイトル3"}}
-----END HTTP REQUEST-----
-----START HTTP RESPONSE-----
HTTP/1.1 200 OK
Content-Length: 105
Access-Control-Allow-Origin: *
Content-Type: application/json; charset=utf-8
Date: Sat, 07 Dec 2024 14:51:25 GMT
Etag: W/"69-QopMB4swyYXSqappKjhJic+XLpo"

{"data":{"createTask":{"id":3,"title":"タスクのタイトル3","description":"タスクの説明3"}}}

-----END HTTP RESPONSE-----
Run "test" on "タスク登録 API の同等性検証".steps.execNewApi

Run "旧 API の実行結果を取得します" on "タスク登録 API の同等性検証".steps.getOldData
-----START QUERY-----
SELECT title, description FROM Task ORDER BY id DESC LIMIT 1
-----END QUERY-----
-----START QUERY RESULT-----
+-------------------+---------------+
|       title       |  description  |
+-------------------+---------------+
| タスクのタイトル3 | タスクの説明3 |
+-------------------+---------------+
(1 row)
-----END QUERY RESULT-----

Run "新 API の実行結果を取得します" on "タスク登録 API の同等性検証".steps.getNewData
-----START QUERY-----
SELECT title, description FROM Task ORDER BY id DESC LIMIT 1
-----END QUERY-----
-----START QUERY RESULT-----
+-------------------+---------------+
|       title       |  description  |
+-------------------+---------------+
| タスクのタイトル3 | タスクの説明3 |
+-------------------+---------------+
(1 row)
-----END QUERY RESULT-----

Run "結果を比較します" on "タスク登録 API の同等性検証".steps.compareResult
Run "test" on "タスク登録 API の同等性検証".steps.compareResult
.

1 scenario, 0 skipped, 0 failures

感想

今回は、runn を使った更新系 API の同等性検証を実施してみました。とても簡単にさくさく記述でき、一つのツールで API の実行からデータの確認までできる点がとても魅力的でした!ただし、実際に API の同等性検証に使うには、認証が必要な API への対応や CI 上で実行できるように整備が必要なので、今後試してみたいと思います。

また、今回は API の同等性検証のために runn を使いましたが、API シナリオテストや業務の自動化など広く活用できそうだと感じたので、今後もさまざまな用途での活用を試していきたいと思います!

明日は kima さんが投稿します!
レバテック開発部 Advent Calendar 2024」をぜひご購読ください!

レバテック開発部

Discussion