Taste of Tech Topics

Acroquest Technology株式会社のエンジニアが書く技術ブログ

ChatGPTでOpenAPI定義からKarateのテストスクリプトを自動生成する

最近久々に近所のお祭りに行ってきました、屋台の食べ物ではりんご飴が好きな菅野です。

皆さん、普段APIのテストはどのように行っておりますか?
最近は、APIのテスト自動化を行えるようなツールやサービスも増えてきているように思いますが、当社では、OSSのテスティングフレームワークである「Karate」を用いることが多いです。
比較的簡単な構文で直感的にAPIのテストができる点がよいと思います。

しかし、いかに簡単な方法でAPIのテストが記述できるからといっても、APIの数が多いとテストを作成するのは一苦労です。
今回は、そんなKarateのテストスクリプトをChatGPTを活用して作成してみようと思います。

まず、REST-APIの仕様を定義する場合、OpenAPIを利用することが多いのではないか、と思います。
ChatGPTの開発元である「OpenAI」ではないです。自分も書いていて、紛らわしくなってきましたが、「OpenAPI」です。

OpenAPI形式のYAMLファイルからAPIの実装コードはSwaggerCodegenや、OpenAPIGenerator といった自動生成ツールを用いることで作成できます。
つまり、ChatGPTでテストコードを自動生成することができれば、API仕様からソースコード生成を生成し、そのテストを行うことがほぼ自動でできることになります。

今回の検証ではGPT-4を用います。

Karateとは?

Karateは、Gherkin(ガーキン)形式を用いて記述するAPIテストのためのフレームワークで、テストスクリプトの作成を簡単にすることができます。
APIのテストだけでなく、UIテストやGatlingと連携した負荷試験等も幅広くできるツールです。
詳細は公式のGitHubやドキュメントを参照してください。
github.com

Karate | Test Automation Made Simple.

テスト対象のAPI設計

APIのテストを作成する上で、どういったAPIがあるかをChatGPTに教えてあげる必要があります。

タスク管理アプリを想定したOpenAPIの定義を以下のように作成しました。

openapi: 3.0.0  
info:  
  title: Task Management API  
  version: 1.0.0  
  description: An API for managing tasks  
  
paths:  
  /tasks:  
    get:  
      summary: Get all tasks  
      responses:  
        '200':  
          description: A list of tasks  
          content:  
            application/json:  
              schema:  
                type: array  
                items:  
                  $ref: '#/components/schemas/Task'  
    post:  
      summary: Create a new task  
      requestBody:  
        required: true  
        content:  
          application/json:  
            schema:  
              $ref: '#/components/schemas/TaskInput'  
      responses:  
        '201':  
          description: Task created successfully  
          content:  
            application/json:  
              schema:  
                $ref: '#/components/schemas/Task'  
        '400':  
          description: Invalid input  
  /tasks/{taskId}:  
    get:  
      summary: Get a specific task  
      parameters:  
        - name: taskId  
          in: path  
          required: true  
          description: ID of the task to retrieve  
          schema:  
            type: string  
      responses:  
        '200':  
          description: Task details  
          content:  
            application/json:  
              schema:  
                $ref: '#/components/schemas/Task'  
        '404':  
          description: Task not found  
    put:  
      summary: Update a task  
      parameters:  
        - name: taskId  
          in: path  
          required: true  
          description: ID of the task to update  
          schema:  
            type: string  
      requestBody:  
        required: true  
        content:  
          application/json:  
            schema:  
              $ref: '#/components/schemas/TaskInput'  
      responses:  
        '200':  
          description: Task updated successfully  
          content:  
            application/json:  
              schema:  
                $ref: '#/components/schemas/Task'  
        '400':  
          description: Invalid input  
        '404':  
          description: Task not found  
    delete:  
      summary: Delete a task  
      parameters:  
        - name: taskId  
          in: path  
          required: true  
          description: ID of the task to delete  
          schema:  
            type: string  
      responses:  
        '204':  
          description: Task deleted successfully  
        '404':  
          description: Task not found  
  
components:  
  schemas:  
    Task:  
      type: object  
      properties:  
        id:  
          type: string  
          description: The unique ID of the task  
        title:  
          type: string  
          description: The title of the task  
        description:  
          type: string  
          description: The description of the task  
        status:  
          type: string  
          description: The status of the task  
          enum: [pending, completed]  
    TaskInput:  
      type: object  
      properties:  
        title:  
          type: string  
          description: The title of the task  
          required: true  
        description:  
          type: string  
          description: The description of the task  
        status:  
          type: string  
          description: The status of the task  
          enum: [pending, completed]  
  

Karateのテストスクリプト作成

早速KarateのテストスクリプトをChatGPTに作成してもらいましょう。
今回は、localhost:5000で動作させたAPIを対象にテスト実行するように依頼をします。

(中略)

(中略)

指定したOpenAPIのYAMLファイルからどういったAPIの種類があるかを理解したうえで、Karateのテストスクリプトを作成しているように見えますね。
YAMLファイルからは作成したタスクのIDがどのように振られるかがわからない為、実装に合わせて適宜置き換えるように記載されています。
今回のサンプルAPI実装ではシーケンシャルにIDを付与するため、sampleIdを1に置換しておきましょう。

出力してくれたfeatureファイルを修正した結果が以下になります。

# KarateのFeatureファイル開始  
Feature: Task Management API  
  
  # ベースURLの設定  
  Background:  
    * url 'http://localhost:5000'  
  
  # すべてのタスクを取得するテスト  
  Scenario: Get all tasks  
    Given path 'tasks'  
    When method get  
    Then status 200  
    # JSONのレスポンスがタスクの配列であることを確認  
    And match each response == {id: '#string', title: '#string', description: '#string', status: '##string'}  
  
  # 新しいタスクを作成するテスト  
  Scenario: Create a new task  
    Given path 'tasks'  
    And request {title: 'Test Task', description: 'This is a test', status: 'pending'}  
    When method post  
    Then status 201  
    # JSONのレスポンスがタスクのオブジェクトであることを確認  
    And match response == {id: '#string', title: 'Test Task', description: 'This is a test', status: 'pending'}  
  
  # 特定のタスクを取得するテスト  
  Scenario: Get a specific task  
    Given path 'tasks', '1'  
    When method get  
    Then status 200  
    And match response == {id: '1', title: '#string', description: '#string', status: '##string'}  
  
  # タスクを更新するテスト  
  Scenario: Update a task  
    Given path 'tasks', '1'  
    And request {title: 'Updated Task', description: 'This task is updated', status: 'completed'}  
    When method put  
    Then status 200  
    And match response == {id: '1', title: 'Updated Task', description: 'This task is updated', status: 'completed'}  
  
  # タスクを削除するテスト  
  Scenario: Delete a task  
    Given path 'tasks', '1'  
    When method delete  
    Then status 204  
  

テスト対象のAPI実装

今回のテストを実行する上で、対象のopenapi.ymlの内容で動作するサンプルAPIをFlaskを用いて作成しました。
こちらを対象にKarateのテストを実行してみます。

from flask import Flask, jsonify, request, abort  
  
app = Flask(__name__)  
  
tasks = []  
  
class Task:  
    def __init__(self, id, title, description, status):  
        self.id = id  
        self.title = title  
        self.description = description  
        self.status = status  
  
    def to_dict(self):  
        return {  
            "id": self.id,  
            "title": self.title,  
            "description": self.description,  
            "status": self.status  
        }  
  
@app.route("/tasks", methods=["GET"])  
def get_tasks():  
    return jsonify([task.to_dict() for task in tasks])  
  
@app.route("/tasks", methods=["POST"])  
def create_task():  
    if not request.json or not "title" in request.json:  
        abort(400)  
  
    title = request.json["title"]  
    description = request.json.get("description", "")  
    status = request.json.get("status", "pending")  
    id = str(len(tasks) + 1)  
  
    new_task = Task(id, title, description, status)  
    tasks.append(new_task)  
  
    return jsonify(new_task.to_dict()), 201  
  
@app.route("/tasks/<string:task_id>", methods=["GET"])  
def get_task(task_id):  
    task = next((task for task in tasks if task.id == task_id), None)  
    if not task:  
        abort(404)  
    return jsonify(task.to_dict())  
  
@app.route("/tasks/<string:task_id>", methods=["PUT"])  
def update_task(task_id):  
    task = next((task for task in tasks if task.id == task_id), None)  
    if not task:  
        abort(404)  
  
    if not request.json:  
        abort(400)  
  
    task.title = request.json.get("title", task.title)  
    task.description = request.json.get("description", task.description)  
    task.status = request.json.get("status", task.status)  
  
    return jsonify(task.to_dict())  
  
@app.route("/tasks/<string:task_id>", methods=["DELETE"])  
def delete_task(task_id):  
    global tasks  
    tasks = [task for task in tasks if task.id != task_id]  
    return jsonify({}), 204  
  
if __name__ == "__main__":  
    app.run(debug=True)  
  

テスト実行

アプリを起動したら、Karateのテスト実行してみます。
Karateのテスト実行方法には、Maven, Gradleといった、Javaビルドツールを用いるほかにも、Karate実行用のJarファイルを用いる方法もあります。

Karateのリリースページにアクセスし、Assetsのkarate-X.X.X.jar(2023年8月現在の最新は1.4.0)からダウンロードできます。

ダウンロードしたjarファイルの実行方法は以下のURLに記載されています。
https://github.com/karatelabs/karate/tree/master/karate-netty#standalone-jar

単純なテスト実行方法は以下になります。

# 1ファイルのテスト実行  
java -jar karate-X.X.X.jar my-test.feature   
# ディレクトリに配置されているfeatureファイルのテストをすべて実行  
java -jar karate-X.X.X.jar some/folder  
>java -jar karate-1.4.0.jar task_management.feature
10:04:10.258 [main]  INFO  com.intuit.karate - Karate version: 1.4.0
10:04:10.521 [main]  INFO  com.intuit.karate.Suite - backed up existing 'target\karate-reports' dir to: target\karate-reports_1693357450512
10:04:11.638 [main]  DEBUG com.intuit.karate - request:
1 > GET http://localhost:5000/tasks
1 > Host: localhost:5000
1 > Connection: Keep-Alive
1 > User-Agent: Apache-HttpClient/4.5.14 (Java/20.0.2)
1 > Accept-Encoding: gzip,deflate


10:04:11.689 [main]  DEBUG com.intuit.karate - response time in milliseconds: 50
1 < 200
1 < Server: Werkzeug/2.3.7 Python/3.10.5
1 < Date: Wed, 30 Aug 2023 01:04:11 GMT
1 < Content-Type: application/json
1 < Content-Length: 3
1 < Connection: close
[]


10:04:11.832 [main]  DEBUG com.intuit.karate - request:
1 > POST http://localhost:5000/tasks
1 > Content-Type: application/json; charset=UTF-8
1 > Content-Length: 71
1 > Host: localhost:5000
1 > Connection: Keep-Alive
1 > User-Agent: Apache-HttpClient/4.5.14 (Java/20.0.2)
1 > Accept-Encoding: gzip,deflate
{"title":"Test Task","description":"This is a test","status":"pending"}

10:04:11.835 [main]  DEBUG com.intuit.karate - response time in milliseconds: 3
1 < 201
1 < Server: Werkzeug/2.3.7 Python/3.10.5
1 < Date: Wed, 30 Aug 2023 01:04:11 GMT
1 < Content-Type: application/json
1 < Content-Length: 98
1 < Connection: close
{
  "description": "This is a test",
  "id": "1",
  "status": "pending",
  "title": "Test Task"
}


10:04:11.846 [main]  DEBUG com.intuit.karate - request:
1 > GET http://localhost:5000/tasks/1
1 > Host: localhost:5000
1 > Connection: Keep-Alive
1 > User-Agent: Apache-HttpClient/4.5.14 (Java/20.0.2)
1 > Accept-Encoding: gzip,deflate


10:04:11.851 [main]  DEBUG com.intuit.karate - response time in milliseconds: 5
1 < 200
1 < Server: Werkzeug/2.3.7 Python/3.10.5
1 < Date: Wed, 30 Aug 2023 01:04:11 GMT
1 < Content-Type: application/json
1 < Content-Length: 98
1 < Connection: close
{
  "description": "This is a test",
  "id": "1",
  "status": "pending",
  "title": "Test Task"
}


10:04:11.857 [main]  DEBUG com.intuit.karate - request:
1 > PUT http://localhost:5000/tasks/1
1 > Content-Type: application/json; charset=UTF-8
1 > Content-Length: 82
1 > Host: localhost:5000
1 > Connection: Keep-Alive
1 > User-Agent: Apache-HttpClient/4.5.14 (Java/20.0.2)
1 > Accept-Encoding: gzip,deflate
{"title":"Updated Task","description":"This task is updated","status":"completed"}

10:04:11.861 [main]  DEBUG com.intuit.karate - response time in milliseconds: 3
1 < 200
1 < Server: Werkzeug/2.3.7 Python/3.10.5
1 < Date: Wed, 30 Aug 2023 01:04:11 GMT
1 < Content-Type: application/json
1 < Content-Length: 109
1 < Connection: close
{
  "description": "This task is updated",
  "id": "1",
  "status": "completed",
  "title": "Updated Task"
}


10:04:11.869 [main]  DEBUG com.intuit.karate - request:
1 > DELETE http://localhost:5000/tasks/1
1 > Host: localhost:5000
1 > Connection: Keep-Alive
1 > User-Agent: Apache-HttpClient/4.5.14 (Java/20.0.2)
1 > Accept-Encoding: gzip,deflate


10:04:11.875 [main]  DEBUG com.intuit.karate - response time in milliseconds: 5
1 < 204
1 < Server: Werkzeug/2.3.7 Python/3.10.5
1 < Date: Wed, 30 Aug 2023 01:04:11 GMT
1 < Content-Type: application/json
1 < Connection: close


---------------------------------------------------------
feature: task_management.feature
scenarios:  5 | passed:  5 | failed:  0 | time: 0.5805
---------------------------------------------------------

10:04:12.574 [main]  INFO  com.intuit.karate.Suite - <<pass>> feature 1 of 1 (0 remaining) task_management.feature
Karate version: 1.4.0
======================================================
elapsed:   2.15 | threads:    1 | thread time: 0.58
features:     1 | skipped:    0 | efficiency: 0.27
scenarios:    5 | passed:     5 | failed: 0
======================================================

HTML report: (paste into browser to view) | Karate version: 1.4.0
file:///path/to/featuresfile//target/karate-reports/karate-summary.html
===================================================================

無事テストが通りました。

テスト実行結果のレポートも出力されています。
Karateは、このようにテストの実行結果もわかりやすく出力されるのがいいですね。

まとめ

今回は、KarateのテストをOpenAI形式のYAMLファイルから自動で生成してみました。
簡単なサンプルアプリではありますが、記載されている全APIの正常系テストを作成して動かすことができました。
OpenAPIの定義から、テストスクリプトの作成も自動で生成できるのは、開発効率がとても上がると感じました。
これで、REST-APIのテスト自動化も、楽に実現ができそうです。
今後もChatGPTの可能性を探っていこうと思います。

Acroquest Technologyでは、キャリア採用を行っています。
  • ディープラーニング等を使った自然言語/画像/音声/動画解析の研究開発
  • Elasticsearch等を使ったデータ収集/分析/可視化
  • マイクロサービス、DevOps、最新のOSSクラウドサービスを利用する開発プロジェクト
  • 書籍・雑誌等の執筆や、社内外での技術の発信・共有によるエンジニアとしての成長
  少しでも上記に興味を持たれた方は、是非以下のページをご覧ください。 www.wantedly.com