Azure OpenAI Service を使って OpenAPI と PlantUML からテストコードを生成する

[LLM (大規模言語モデル)](https://learn.microsoft.com/en-us/semantic-kernel/concepts-ai/) を使ったコード生成の文脈において、[「怠惰」で「短気」で「傲慢」](https://thethreevirtues.com/)な職業プログラマがまず到達したいのは、テストコードの自動生成と、そのすべてのテストケースをパスする実装コードの生成だと思います。
本記事ではテストコードの自動生成について、取り組みを紹介します。

本取り組みでは、テストコードを生成するためのプロンプトの一部として、[OpenAPI 仕様](https://www.openapis.org/) と [PlantUML](https://plantuml.com/ja/) を使用します。
実装コードではなくまずテストコードの生成を試みる理由は、[テスト駆動開発](https://ja.wikipedia.org/wiki/%E3%83%86%E3%82%B9%E3%83%88%E9%A7%86%E5%8B%95%E9%96%8B%E7%99%BA) のアプローチを取るためです。
もちろん我々がテストコードを書いて、それをもとに実装コードを生成するというアプローチもあると思いますが、それは別の取り組みとして検証・評価したいと思います。


## ゴール

* [Azure OpenAI Service](https://learn.microsoft.com/ja-jp/azure/cognitive-services/openai/overview) を使ってドキュメントからテストコードを生成し、その内容を評価する。


## 免責

* 本記事の内容は、執筆時点のものです。LLM の変化やゆらぎもあるため、再現性は保証されません。
* 本記事の内容は検証レベルのものです。完全な手法に関する情報を提供するものではありません。
* 本記事で使用する PlantUML は、細部まで作り込んでいるわけではありません。細かい部分で間違いがある可能性があります。
* 本記事で使用されるプロンプトは、特に突き詰めてチューニングを行っているわけではありません。


## 準備

### OpenAPI 仕様

本記事では OpenAPI 公式のサンプル "[petstore-simple.json](https://github.com/OAI/OpenAPI-Specification/blob/main/examples/v2.0/json/petstore-simple.json)" を使用します。
以下に転記します。

```json
{
  "swagger": "2.0",
  "info": {
    "version": "1.0.0",
    "title": "Swagger Petstore",
    "description": "A sample API that uses a petstore as an example to demonstrate features in the swagger-2.0 specification",
    "termsOfService": "http://swagger.io/terms/",
    "contact": {
      "name": "Swagger API Team"
    },
    "license": {
      "name": "MIT"
    }
  },
  "host": "petstore.swagger.io",
  "basePath": "/api",
  "schemes": [
    "http"
  ],
  "consumes": [
    "application/json"
  ],
  "produces": [
    "application/json"
  ],
  "paths": {
    "/pets": {
      "get": {
        "description": "Returns all pets from the system that the user has access to",
        "operationId": "findPets",
        "produces": [
          "application/json",
          "application/xml",
          "text/xml",
          "text/html"
        ],
        "parameters": [
          {
            "name": "tags",
            "in": "query",
            "description": "tags to filter by",
            "required": false,
            "type": "array",
            "items": {
              "type": "string"
            },
            "collectionFormat": "csv"
          },
          {
            "name": "limit",
            "in": "query",
            "description": "maximum number of results to return",
            "required": false,
            "type": "integer",
            "format": "int32"
          }
        ],
        "responses": {
          "200": {
            "description": "pet response",
            "schema": {
              "type": "array",
              "items": {
                "$ref": "#/definitions/Pet"
              }
            }
          },
          "default": {
            "description": "unexpected error",
            "schema": {
              "$ref": "#/definitions/ErrorModel"
            }
          }
        }
      },
      "post": {
        "description": "Creates a new pet in the store.  Duplicates are allowed",
        "operationId": "addPet",
        "produces": [
          "application/json"
        ],
        "parameters": [
          {
            "name": "pet",
            "in": "body",
            "description": "Pet to add to the store",
            "required": true,
            "schema": {
              "$ref": "#/definitions/NewPet"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "pet response",
            "schema": {
              "$ref": "#/definitions/Pet"
            }
          },
          "default": {
            "description": "unexpected error",
            "schema": {
              "$ref": "#/definitions/ErrorModel"
            }
          }
        }
      }
    },
    "/pets/{id}": {
      "get": {
        "description": "Returns a user based on a single ID, if the user does not have access to the pet",
        "operationId": "findPetById",
        "produces": [
          "application/json",
          "application/xml",
          "text/xml",
          "text/html"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "ID of pet to fetch",
            "required": true,
            "type": "integer",
            "format": "int64"
          }
        ],
        "responses": {
          "200": {
            "description": "pet response",
            "schema": {
              "$ref": "#/definitions/Pet"
            }
          },
          "default": {
            "description": "unexpected error",
            "schema": {
              "$ref": "#/definitions/ErrorModel"
            }
          }
        }
      },
      "delete": {
        "description": "deletes a single pet based on the ID supplied",
        "operationId": "deletePet",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "ID of pet to delete",
            "required": true,
            "type": "integer",
            "format": "int64"
          }
        ],
        "responses": {
          "204": {
            "description": "pet deleted"
          },
          "default": {
            "description": "unexpected error",
            "schema": {
              "$ref": "#/definitions/ErrorModel"
            }
          }
        }
      }
    }
  },
  "definitions": {
    "Pet": {
      "type": "object",
      "allOf": [
        {
          "$ref": "#/definitions/NewPet"
        },
        {
          "required": [
            "id"
          ],
          "properties": {
            "id": {
              "type": "integer",
              "format": "int64"
            }
          }
        }
      ]
    },
    "NewPet": {
      "type": "object",
      "required": [
        "name"
      ],
      "properties": {
        "name": {
          "type": "string"
        },
        "tag": {
          "type": "string"
        }
      }
    },
    "ErrorModel": {
      "type": "object",
      "required": [
        "code",
        "message"
      ],
      "properties": {
        "code": {
          "type": "integer",
          "format": "int32"
        },
        "message": {
          "type": "string"
        }
      }
    }
  }
}
```


### PlantUML のシーケンス図

上記の OpenAPI 仕様に基づき、簡単なシーケンス図を PlantUML で作成しました。

```
@startuml

title ペット
header %page% of %lastpage%
footer Copyright(c) All rights reserved.

autoactivate on
autonumber "<b>[00]"

actor ユーザー as user
entity ペットストアUI as ui
entity ペットストアAPI as api
database データベース as db

== ペットのリストを取得 ==

user -> ui : ペットリストボタンをクリック
ui -> api : findPets
api -> db : SELECT * FROM pets
return
return 200, pet response
return ペットのリストを表示

== ペットの詳細を取得 ==

user -> ui : ペットの詳細ボタンをクリック
ui -> api : findPetById
api -> db : SELECT * FROM pets WHERE id = ${pet_id}
return
return 200, pet response
return ペットの詳細を表示

== ペットを購入 ==

user -> ui : ペットの購入ボタンをクリック
ui -> api : addPet
group transaction
    api -> db : transaction
    api -> db : INSERT INTO orders VALUES (${user_id}, ${pet_id}, ${datetime}, ${created_at}, ${updated_at})
    api -> db : DELETE FROM pets WHERE pet_id = ${pet_id}
    api -> db : commit
    return
    return
    return
    return
end
return 200, pet response
return 購入完了画面を表示

== ペットの登録を削除 ==

user -> ui : ペットの登録削除ボタンをクリック
ui -> api : deletePet
group transaction
    api -> db : DELETE FROM pets WHERE pet_id = (SELECT id FROM orders WHERE pet_id = ${pet_id} AND user_id = ${user_id})
    api -> db : DELETE FROM orders WHERE pet_id = ${pet_id} AND user_id = ${user_id}
    return
    return
end
return 204, pet deleted
return 削除完了画面を表示

== ペットの購入に失敗 ==

user -> ui : ペットの購入ボタンをクリック
ui -> api : addPet
group transaction
    api -> db : transaction
    api -> db : INSERT INTO orders VALUES (${user_id}, ${pet_id}, ${datetime}, ${created_at}, ${updated_at})
    api -> db !! : DELETE FROM pets WHERE pet_id = ${pet_id}
    api -> db : rollback
    return
    return
    return
end
return 500, unexpected error
return 購入失敗画面を表示

@enduml
```

シーケンス図は以下の通りです。

```plantuml
@startuml

title ペット
header %page% of %lastpage%
footer Copyright(c) All rights reserved.

autoactivate on
autonumber "<b>[00]"

actor ユーザー as user
entity ペットストアUI as ui
entity ペットストアAPI as api
database データベース as db

== ペットのリストを取得 ==

user -> ui : ペットリストボタンをクリック
ui -> api : findPets
api -> db : SELECT * FROM pets
return
return 200, pet response
return ペットのリストを表示

== ペットの詳細を取得 ==

user -> ui : ペットの詳細ボタンをクリック
ui -> api : findPetById
api -> db : SELECT * FROM pets WHERE id = ${pet_id}
return
return 200, pet response
return ペットの詳細を表示

== ペットを購入 ==

user -> ui : ペットの購入ボタンをクリック
ui -> api : addPet
group transaction
    api -> db : transaction
    api -> db : INSERT INTO orders VALUES (${user_id}, ${pet_id}, ${datetime}, ${created_at}, ${updated_at})
    api -> db : DELETE FROM pets WHERE pet_id = ${pet_id}
    api -> db : commit
    return
    return
    return
    return
end
return 200, pet response
return 購入完了画面を表示

== ペットの登録を削除 ==

user -> ui : ペットの登録削除ボタンをクリック
ui -> api : deletePet
group transaction
    api -> db : DELETE FROM pets WHERE pet_id = (SELECT id FROM orders WHERE pet_id = ${pet_id} AND user_id = ${user_id})
    api -> db : DELETE FROM orders WHERE pet_id = ${pet_id} AND user_id = ${user_id}
    return
    return
end
return 204, pet deleted
return 削除完了画面を表示

== ペットの購入に失敗 ==

user -> ui : ペットの購入ボタンをクリック
ui -> api : addPet
group transaction
    api -> db : transaction
    api -> db : INSERT INTO orders VALUES (${user_id}, ${pet_id}, ${datetime}, ${created_at}, ${updated_at})
    api -> db !! : DELETE FROM pets WHERE pet_id = ${pet_id}
    api -> db : rollback
    return
    return
    return
end
return 500, unexpected error
return 購入失敗画面を表示

@enduml
```

### PlantUML のクラス図

同様に、今回使用する OpenAPI 仕様に基づき、PlantUML で簡単なクラス図を作成しました。

```
@startuml

class User {
    - id
    - name
}

class Pet {
    - id
    - name
    - type
    - tag
}

class Order {
    - id
    - user_id
    - pet_id
}

Order "1" -- "*" User
Order "1" -- "*" Pet

@enduml
```

クラス図は以下の通りです。

```plantuml
@startuml

class User {
    - id
    - name
}

class Pet {
    - id
    - name
    - type
    - tag
}

class Order {
    - id
    - user_id
    - pet_id
}

Order "1" -- "*" User
Order "1" -- "*" Pet

@enduml
```

### プロンプト

プロンプトは以下の通りです。免責にある通り、チューニングは特にしていません。適当に作文したものです。
日本語でも良いのですが、意図がより正しく伝わるよう、英語で作文しています。

非常に長くなってしまうため、上記の 3点については、ここでは記述を省略しています。後で別途 `# NOTE: ここに〜を入れる` と書いてある箇所にコピペをして、プロンプトを完成させます。

なお、本記事では Python を指定しています。簡単な修正を行うことで他の言語にも対応可能です。

```
/*
You are a professional programmer.
Please create test codes in Python by importing unittest module along with the REST API definition written in Swagger, the sequence diagram in PlantUML, and the class diagram in PlantUML below:

The REST API definition written in Swagger
"""
# NOTE: ここに OpenAPI 仕様を入れる
"""

The sequence diagram in PlantUML
"""
# NOTE: ここに PlantUML のシーケンス図を入れる
"""

The class diagram in PlantUML
"""
# NOTE: ここに PlantUML のクラス図を入れる
"""

To give me a solution, you can consider that step-by-step.
Assume the usages of the objects.
Prepare test data, test doubles such as stubs, mocks, dummies, spies, and the rest, and test cases for meaningful test codes.
You can use `from unittest.mock import patch` to use mocks.
*/
```

参考までに、和訳を以下に掲載します。

```
あなたはプロのプログラマーです。
以下の Swagger で記述された REST API 定義、PlantUML でのシーケンス図、PlantUML でのクラス図に沿って、 unittest モジュールをインポートして Python でテスト コードを作成してください。

Swagger で記述された REST API 定義
"""
# NOTE: ここに OpenAPI 仕様を入れる
"""

PlantUML のシーケンス図
"""
# NOTE: ここに PlantUML のシーケンス図を入れる
"""

PlantUML のクラス図
"""
# NOTE: ここに PlantUML のクラス図を入れる
"""

私に解決策を与えるために、あなたはそれを段階的に検討することができます。
オブジェクトの用途を想定してください。
テストコードを意味のあるものにするために、テスト データ、スタブ、モック、ダミー、スパイなどのテスト ダブル、そしてテスト ケースを準備してください。
モックを使用するために `from unittest.mock import patch` を使用して構いません。
```

### モデル

本記事執筆時点では、以下のモデルが使用可能です。

* text-davinci-003
* code-davinci-002
* gpt-35-turbo

プロンプトの理解力とコード生成力のバランスから、基本的に `text-davinci-003` を使用します。

### 入出力インターフェース

Azure OpenAI Service の [Azure OpenAI Studio](https://oai.azure.com/portal/) 内にある [Playground](https://oai.azure.com/portal/playground) を使用します。


## 実行手順

### 1. プロンプトを作成・入力する

上記のプロンプトを完成させ、Playground に貼り付けます。

### 2. Generate ボタンを押す

何も生成されなくなるまで、 `Generate` し続けます。

### 3. 生成されたコードを取得する

コピーしてエディタ等に貼り付けます。

### 4. プロンプトの修正と実行を繰り返す

`Temperture` がデフォルトでは `1` となっており、ゆらぎが大きいため、基本的には実行する度に異なる結果となります。
本記事ではあえて `1` のままにしています。
最良の結果が得られるまで繰り返し実行し、試行錯誤します。


## 結果

生成されたテストコードの中で一番良かったのは以下のコードでした。

```python
import unittest
from unittest.mock import patch

# Test data
pets = [
    {
        'id': 1,
        'name': 'Fluffy',
        'type': 'cat',
        'tag': 'feline'
    },
    {
        'id': 2,
        'name': 'Fido',
        'type': 'dog',
        'tag': 'canine'
    }
]

orders = [
    {
        'id': 1,
        'user_id': 1,
        'pet_id': 1
    },
    {
        'id': 2,
        'user_id': 2,
        'pet_id': 2
    }
]

# Test doubles
class PetStoreUI:
    def find_pets(self, tags, limit):
        return pets

    def find_pet_by_id(self, pet_id):
        for pet in pets:
            if pet['id'] == pet_id:
                return pet
        return None

    def add_pet(self, pet):
        pets.append(pet)
        return pet

    def delete_pet(self, pet_id):
        for pet in pets:
            if pet['id'] == pet_id:
                pets.remove(pet)
                return

class PetStoreAPI:
    def find_pets(self, tags, limit):
        return pets

    def find_pet_by_id(self, pet_id):
        for pet in pets:
            if pet['id'] == pet_id:
                return pet
        return None

    def add_pet(self, pet):
        pets.append(pet)
        return pet

    def delete_pet(self, pet_id):
        for pet in pets:
            if pet['id'] == pet_id:
                pets.remove(pet)
                return

class Database:
    def select_all_pets(self):
        return pets

    def select_pet_by_id(self, pet_id):
        for pet in pets:
            if pet['id'] == pet_id:
                return pet
        return None

    def insert_order(self, user_id, pet_id, datetime, created_at, updated_at):
        orders.append({
            'id': len(orders) + 1,
            'user_id': user_id,
            'pet_id': pet_id
        })

    def delete_pet(self, pet_id):
        for pet in pets:
            if pet['id'] == pet_id:
                pets.remove(pet)
                return

# Test cases
class TestPetStore(unittest.TestCase):
    def setUp(self):
        self.ui = PetStoreUI()
        self.api = PetStoreAPI()
        self.db = Database()

    def test_find_pets(self):
        # Arrange
        tags = ['feline', 'canine']
        limit = 10

        # Act
        result = self.ui.find_pets(tags, limit)

        # Assert
        self.assertEqual(result, pets)

    def test_find_pet_by_id(self):
        # Arrange
        pet_id = 1

        # Act
        result = self.ui.find_pet_by_id(pet_id)

        # Assert
        self.assertEqual(result, pets[0])

    def test_add_pet(self):
        # Arrange
        pet = {
            'id': 3,
            'name': 'Buddy',
            'type': 'dog',
            'tag': 'canine'
        }

        # Act
        result = self.ui.add_pet(pet)

        # Assert
        self.assertEqual(result, pet)
        self.assertEqual(pets[-1], pet)

    def test_delete_pet(self):
        # Arrange
        pet_id = 1

        # Act
        self.ui.delete_pet(pet_id)

        # Assert
        self.assertEqual(len(pets), 1)
        self.assertEqual(pets[0], pets[1])

    def test_add_pet_with_transaction(self):
        # Arrange
        pet = {
            'id': 3,
            'name': 'Buddy',
            'type': 'dog',
            'tag': 'canine'
        }
        user_id = 1
        pet_id = 3
        datetime = '2020-08-20'
        created_at = '2020-08-20'
        updated_at = '2020-08-20'

        # Act
        with patch.object(self.db, 'insert_order', return_value=None) as mock_insert_order:
            result = self.api.add_pet(pet)

            # Assert
            mock_insert_order.assert_called_once_with(user_id, pet_id, datetime, created_at, updated_at)
            self.assertEqual(result, pet)
            self.assertEqual(pets[-1], pet)

    def test_delete_pet_with_transaction(self):
        # Arrange
        pet_id = 1
        user_id = 1

        # Act
        with patch.object(self.db, 'delete_pet', return_value=None) as mock_delete_pet:
            self.api.delete_pet(pet_id)

            # Assert
            mock_delete_pet.assert_called_once_with(pet_id)
            self.assertEqual(len(pets), 1)
            self.assertEqual(pets[0], pets[1])
```

実は、上記のテストコードには続きがあり、 `test_add_pet_with_transaction_failure` というテストケースが定義されていました。
ただ、トークン数の上限に達し、コード生成が途中で終了していました。
参考までに、その内容は以下の通りです。

```python
    def test_add_pet_with_transaction_failure(self):
        # Arrange
        pet = {
            'id': 3,
            'name': 'Buddy',
            'type': 'dog',
            'tag': 'canine'
        }
        user_id = 1
        pet_id = 3
        datetime = '2020-08-20'
        created_at = '2020-08-20'
        updated_at = '2020-08-20'

        # Act
        with patch.object(self.db, 'insert_order', return_value=None) as mock_insert_order:
            with patch.object(self.db, 'delete_pet', side_effect=Exception('Unexpected error')) as mock_delete_pet:
                result = self.api.add_pet(pet)

                # Assert
                mock_insert_order.assert_called_once_with(user_id, pet_id, datetime, created_at, updated_at)
                mock_delete_pet.assert_called_once_with(pet_id)
                self.assertEqual(result, None)
                self.assertEqual(len(pets), 2)
```

おそらく、この後に `self.assertEqual(pets[0], pets[1])` が続くと考えられますし、テストコードとしては一応成立してはいますが、不完全である可能性があるため、本検証においては本テストケースを除外しました。


## 評価

テストコードをテストするテストコードを書いても仕方ないので、テストコードのレビューを人が行う形式で評価します。

### 良い点

* プロンプトでの指示通りに生成されている点:
  - `unittest` モジュールおよびその `patch` を使用している。
  - テストデータを用意している。
  - `test_find_pets` の `limit = 10` など、想定に基づくアレンジが加えられている。
  - `assert_called_once_with` を使い、的確に spy を使用している。
  - シーケンス図の内容とテストケースの内容が合致している。
* `unittest.TestCase` を継承させたり `setUP` 関数を使ったりと、 `unittest` モジュールを的確に使用している。
* [AAA (Arrange-Act-Assert) パターン](https://automationpanda.com/2020/07/07/arrange-act-assert-a-pattern-for-writing-good-tests/)を使用している。
* 記法が Python でメジャーなものとなっている (スネークケースの命名やスペース 4個のインデント等)。

### 改善点

* プロンプトでの指示通りになっていない点:
    - クラス図が一部しか考慮されていない。
    - OpenAPI 仕様が考慮されていないかもしれない。
* テストダブルとしてクラスが定義されているが、これはほぼ実装コードである。テストコードだけあればよく、ここまでは求めていない。また、実装コードに対するテストコードとしては、このままでは使えない。実装コードとしても、変数のスコープの関係から、そのまま流用はできない。
* 各所のコメントについて、記述内容を鑑みるに、これらは不要である。
* `datetime` 等の値が datetime ではない。
* テストケースとしては足りていない。プロンプトで渡す情報を精緻化することで、テストケースも精緻化され、網羅的になることを期待。
* トークン数制限を超過してしまった。


## 補足

`text-davinci-003` 以外に、 `code-davinci-002` と `gpt-35-turbo` のモデルでも生成を試みました。
`code-davinci-002` はプロンプトの読解力で `text-davinci-003` に大きく劣る感触で、その出力は採用しませんでした。
`gpt-35-turbo` はチャットで使われていることもあるためか、コメント行が大変おしゃべりです。そして、`import unittest` していなかったり、クラスを使用していなかったりと、テストコードとしてはいまいちでした。


## まとめ

[Azure OpenAI Service](https://learn.microsoft.com/ja-jp/azure/cognitive-services/openai/overview) を使ってドキュメント (OpenAPI 仕様, PlantUML のシーケンス図およびクラス図) からテストコードを生成し、その内容を評価しました。

個人的には期待以上のアウトプットが得られたと感じていますが、しかしながらまだ「コードスニペット」としての用途にしか使えないと評価しています。
プロンプトをチューニングしたりドキュメントを精緻化することでより良い結果が出てくることが期待されますが、本検証で問題となったように、トークン数制限に容易に到達してしまうことが想定されます。
今回使用したモデルよりも GPT-4 の方がトークン数制限が緩いので、トークン数制限はサービスの発展とともに解消されると思われます。

とはいえ、職業プログラマとしてはテストコードだけでなくドキュメントも作成するものなので、ドキュメントからこれだけの「コードスニペット」が生成されるだけでも、価値はあるのではないでしょうか。