本稿は書籍
2022年後半に登場したChatGPTを皮切りに、生成AIは瞬く間に世の中に浸透しました。現在では世界中の投資がこの分野に向けられており、新しいサービスも続々と登場しています。
生成AIが最もその価値を発揮するシーンの一つが、システム開発における活用です。例えばコーディングのアシストやレビュー、ログの分析、画面モックの作成など、生成AIの活用はすでに実用的な段階にあります。
本稿ではその中でも、
1. 本稿で取り上げる生成AI
生成AIと一口に言ってもさまざまな種類がありますが、その代表は何といってもOpen AI社のChatGPTでしょう。
ChatGPTは、大規模言語モデル
なお、ChatGPTが汎用的な利用を目的としたツールであるのに対して、エンジニアリングに特化した生成AIツールにGitHub Copilotがあります。
GitHub Copilotは、VS Codeの拡張機能
ただし本稿の範囲の中でGitHub Copilotの機能をカバーするのは、分量や時間軸の観点から難しかったため、スコープ外としている点をご了承ください。
生成AIをテストに活用するためのアプローチ
本稿では、ChatGPTによってテストコードを生成するための方法をケース別に見ていきます。すべてのケースに通底するのが、テストコードを生成するためのアプローチには以下の二つがある、というものです。
- アプローチ①
- まず開発者がテスト仕様やテストケースを作成し、それをインプットにして、ChatGPTにテストコードを生成させる。
- アプローチ②
- 完成されたプロダクションコードをインプットにして、ChatGPTにテストコードを生成させる。
両者のうち、正攻法と言えるのは①の方です。このアプローチは、プロダクションコードを所与としてないため、テスト駆動開発に近い考え方と見なすこともできます。
ただし、テスト仕様を生成AIが理解できるようにテキストベースの情報
このように考えると、②のアプローチの方が場合によっては効率的かもしれません。しかし②のアプローチには、重大な落とし穴があります。そもそもテストコードとは、開発者自身が作成したプロダクションコードが、仕様通りに実装されていることを検証するために作成するものです。その点、プロダクションコードをChatGPTのインプットにすると、プロダクションコードに何らかの不備があった場合に、生成されるテストコードがそれに引きずられて精度が落ちてしまう、という可能性を排除できないのです。
したがって②のアプローチを採用する場合は、生成されるテストコードを鵜呑みにするのではなく、開発者自身がきちんと精査するというのが大前提です。少なくとも検証のためのコードは、開発者自身で記述した方がよいでしょう。
さて、この二つのアプローチに共通して言えるのは、生成AIによるテストには、効率化の観点で
アプローチ①であれば、ChatGPTのために、テスト仕様やテストケースをわざわざプロンプトとして作ることが求められます。またアプローチ②の場合も、開発者自身が生成されたコードを精査したり、検証コードを追記したりする必要があります。
いずれのアプローチも、下手をすると生成AIを使わずに開発者自身がゼロからテストコードを作成した方が、効率性の観点で上回る可能性があるのです。
本稿では、JUnit単体テスト、REST APIテスト、UIテスト、負荷テストといったケースで、生成AIによるコード生成の事例を紹介します。いずれのケースも、この
本稿の事例を参考にすれば、見極めのためのポイントを理解することが可能になるでしょう。
2. JUnit単体テストでの活用
テスト仕様からテストコード生成(アプローチ①)
この項では、ChatGPTを活用して、JUnitの単体テストコードを生成する事例を紹介します。
ここではまず、アプローチ①、すなわちテスト仕様やテストケースからテストコードを生成するアプローチを取り上げます。テスト対象クラスの題材は、書籍の2.
例えば以下のようなプロンプト
以下の[制約条件]とテスト対象ユニットごとの[テストケース]に従い、単体テストを行います。
Javaのテストコードを生成してください。
# 制約条件
* テスティングフレームワークには、JUnit 5を利用してください。
* テスト対象クラスは`pro.kensait.junit5.fee.FeeServiceTest`です。
* テストクラスは`pro.kensait.junit5.fee.FeeServiceTest`とします。
* @BeforeEachが付与された前処理において、FeeServiceクラスのインスタンスを生成し、feeServiceフィールドに割り当てます。
* テストメソッド名は英語で、コメントは日本語でお願いします。
# テスト対象ユニット共通の情報
* OUR_BANK_CODE(自分の銀行の銀行コード):"B001"
* OTHER_BANK_CODE(他の銀行の銀行コード):"B999"
# テスト対象ユニット
* 期待される振る舞い
指定された銀行コードと振込金額から、手数料を計算して返す。
* インタフェース
`int calcFee(String bankCode, int amount)`
* テストケース
|振込先の銀行|振込金額|手数料|
|---|---|---|
|自分の銀行|30000円以上|0円|
||30000円未満|100円|
|他の銀行|40000円以上|200円|
||40000円未満|500円|
すると、ChatGPTによって以下のようなテストコードが生成されます。
public class FeeServiceTest {
private FeeService feeService;
// 前処理:FeeServiceのインスタンス生成
@BeforeEach
void setUp() {
feeService = new FeeService();
}
// 自分の銀行に対する振込で、30000円以上の場合の手数料テスト
@Test
@DisplayName("Test calcFee with our bank and amount >= 30000")
void testCalcFeeOurBankAboveThreshold() {
assertEquals(0, feeService.calcFee("B001", 30000),
"自分の銀行への振込で30000円以上の場合、手数料は0円であるべき");
}
// 自分の銀行に対する振込で、30000円未満の場合の手数料テスト
@Test
@DisplayName("Test calcFee with our bank and amount < 30000")
void testCalcFeeOurBankBelowThreshold() {
assertEquals(100, feeService.calcFee("B001", 29999),
"自分の銀行への振込で30000円未満の場合、手数料は100円であるべき");
}
........
}
生成されたこのテストコードには、プロンプトに記述したテストケースに則り、4つのテストメソッドが実装されています。
このコードは、FeeServiceクラスのテスト要件を完全に満たしており、生成結果の評価としては
プロダクションコードからテストコード生成(アプローチ②)
次にアプローチ②、すなわちプロダクションコードからテストコードを生成するアプローチを取り上げます。
テスト対象クラスの題材は、書籍の2.
ShippingServiceのテスト仕様を、これらの依存先クラスを意識して作成するのはコストが高いので、プロダクションコードをそのまま使ってみましょう。具体的には、以下のようなプロンプト
以下の[制約条件]に従い、単体テストを行います。
テストコードを、生成してください。
# 制約条件
* テスティングフレームワークには、JUnit 5を利用してください。
* モッキングフレームワークには、Mockitoを利用してください。
* テスト対象クラスは`pro.kensait.java.shipping.ShippingService`です。
* テストクラスは`pro.kensait.java.shipping.ShippingServiceTest`とします。
* @BeforeEachが付与された前処理において、ShippingServiceクラスのインスタンスを生成し、shippingServiceフィールドに割り当てます。
* 以下のクラスをモック化し、カバレッジを意識した振る舞いを実装してください。
* Baggage
* Client
* CostCalculatorIF
* 検証は開発者自身で行うため、コードの生成は不要です。
* テストメソッド名は英語で、コメントは日本語でお願いします。
* すべてのテストメソッドを生成してください。
# テスト対象クラス
....プロダクションコードを貼り付ける....
# 依存先クラス
....プロダクションコードを貼り付ける....
このようにテスト対象クラスはもちろんのこと、依存先クラスのコードもプロンプトに追加する必要があるため、ひと手間かかります。また、プロンプトに入力する情報量も増えるため、ChatGPTの文字数制限である
このプロンプトをChatGPTに投入すると、以下のようなコードが生成されます。
public class ShippingServiceTest {
private ShippingService shippingService;
@Mock
private CostCalculatorIF costCalculator;
@Mock
private Client client;
@Mock
private Baggage baggage1;
@Mock
private Baggage baggage2;
@BeforeEach
public void setUp() {
MockitoAnnotations.openMocks(this);
shippingService = new ShippingService(costCalculator);
}
@Test
public void testOrderShipping_GeneralClient() {
// 前提条件
when(client.clientType()).thenReturn(ClientType.GENERAL);
when(client.originRegion()).thenReturn(RegionType.HONSHU);
when(baggage1.baggageType()).thenReturn(BaggageType.SMALL);
when(baggage2.baggageType()).thenReturn(BaggageType.LARGE);
when(costCalculator.calcShippingCost(
BaggageType.SMALL, RegionType.HONSHU)).thenReturn(1000);
when(costCalculator.calcShippingCost(
BaggageType.LARGE, RegionType.HONSHU)).thenReturn(1500);
List<Baggage> baggageList = Arrays.asList(baggage1, baggage2);
// テスト実行
shippingService.orderShipping(client, LocalDate.now(), baggageList);
// 検証
verify(costCalculator).calcShippingCost(
BaggageType.SMALL, RegionType.HONSHU);
verify(costCalculator).calcShippingCost(
BaggageType.LARGE, RegionType.HONSHU);
}
@Test
public void testOrderShipping_GoldClient_DiscountApplied() {
// 前提条件
when(client.clientType()).thenReturn(ClientType.GOLD);
when(client.originRegion()).thenReturn(RegionType.HONSHU);
when(baggage1.baggageType()).thenReturn(BaggageType.SMALL);
when(baggage2.baggageType()).thenReturn(BaggageType.LARGE);
when(costCalculator.calcShippingCost(
BaggageType.SMALL, RegionType.HONSHU)).thenReturn(2000);
when(costCalculator.calcShippingCost(
BaggageType.LARGE, RegionType.HONSHU)).thenReturn(1500);
List<Baggage> baggageList = Arrays.asList(baggage1, baggage2);
// テスト実行
shippingService.orderShipping(client, LocalDate.now(), baggageList);
// 検証
verify(costCalculator).calcShippingCost(
BaggageType.SMALL, RegionType.HONSHU);
verify(costCalculator).calcShippingCost(
BaggageType.LARGE, RegionType.HONSHU);
}
@Test
public void testOrderShipping_DiamondClient_DiscountApplied() {
// 前提条件
when(client.clientType()).thenReturn(ClientType.DIAMOND);
when(client.originRegion()).thenReturn(RegionType.HONSHU);
when(baggage1.baggageType()).thenReturn(BaggageType.SMALL);
when(baggage2.baggageType()).thenReturn(BaggageType.LARGE);
when(costCalculator.calcShippingCost(
BaggageType.SMALL, RegionType.HONSHU)).thenReturn(3000);
when(costCalculator.calcShippingCost(
BaggageType.LARGE, RegionType.HONSHU)).thenReturn(2000);
List<Baggage> baggageList = Arrays.asList(baggage1, baggage2);
// テスト実行
shippingService.orderShipping(client, LocalDate.now(), baggageList);
// 検証
verify(costCalculator).calcShippingCost(
BaggageType.SMALL, RegionType.HONSHU);
verify(costCalculator).calcShippingCost(
BaggageType.LARGE, RegionType.HONSHU);
}
}
このテストコードに点数をつけるとしたら
実はこのコードのコンパイルは問題なく通りますし、このコードを実行するとすべてのテストが成功します。ただしテストコードとしては、要件を充足しているとは言えません。このコードには、次のような2つの課題があります。
課題①:検証コードの追加が必要
まず前述したように、ChatGPTによって生成されたテストコードに対して、検証のためのコードを開発者自身で実装する必要があります。
今回のプロンプトでは
この部分は特に
テスト対象クラスであるShippingServiceは副作用を発生させるため、以下のような状態ベースの検証コードを追加します。
Shipping actual = ShippingDAO.findAll().get(0); //【1】
assertEquals(3150, actual.totalPrice()); //【2】
ここではShippingDAOが、十分に品質が確保された信頼できるクラスであるという前提の下、ShippingDAOによって実測値を取得しますassertEquals()
を呼び出して、期待値と実測値が一致していることを検証します
このとき
もしChatGPTを使わず、開発者が自分の意図でモックの振る舞いを設定するのであれば、そのコンテキストの中で、期待値を自分で決めることは比較的容易なはずです。ところがChatGPTに振る舞いの設定を委ねると、生成された振る舞いを理解した上で、
この点は、このアプローチの難易度を上げている一つのポイントになるのではないかと思います。
課題②:カバレッジが不十分
ChatGPTによって生成された既出のテストコードは、カバレッジが不十分です。以下に、このテストを実行したときのカバレッジレポートShippingService
のorderShipping()
の部分)
このようにゴールド会員、ダイヤモンド会員ともに、割引後の下限金額に抵触したというケースがカバーされていません。そこで筆者の方で、改めて以下のようにプロンプトに投入してみました。
ゴールド会員、ダイヤモンド会員ともに、「割引したものの下限金額に抵触してそれ以上割引されない」というケースがカバーされていません。
これらのケースもカバーしたテストコードを再度生成してください。
ところが本稿執筆時点では、このプロンプトからは、要件を充足するようなテストコードは生成されませんでした。この課題を解決するためには、モデルの進化を待つか、プロンプトエンジニアリングのさらなる工夫が必要になるものと思われます。
3. REST APIテストでの活用
RestAssuredによるテストコード生成
ここでは、ChatGPTを活用して、REST APIのためのテストコードを生成する事例を紹介します。テストコードは、JUnit 5とRestAssuredを使用して作成するものとします。
REST APIの外部仕様は
「OpenAPI仕様ファイル」
今回テスト対象となるのは、Person
openapi: 3.0.0
info:
title: Person API
version: 1.0.0
servers:
- url: http://localhost:8080
paths:
/persons/{personId}:
get:
operationId: getPersonById
tags:
- Persons
parameters:
- name: personId
in: path
required: true
schema:
type: integer
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/Person'
'404':
description: Person not found
post:
operationId: createPerson
tags:
- Persons
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Person'
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/Person'
'400':
description: Invalid input
put:
........
delete:
........
/persons/query_by_age:
get:
operationId: queryByLowerAge
tags:
- Persons
parameters:
- name: lowerAge
in: query
required: true
schema:
type: integer
responses:
'200':
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Person'
components:
schemas:
Person:
type: object
properties:
personId:
type: integer
personName:
type: string
age:
type: integer
gender:
type: string
required:
- personName
- age
- gender
この
以下の[OpenAPI仕様ファイル]に示したREST APIがあるものとします。
[制約条件]に従い、テストコードを生成してください。
# 制約条件
* テスティングフレームワークには、JUnit 5とRestAssuredを利用してください。
* テストクラスは`pro.kensait.spring.person.rest.test.PersonApiTest`とします。
* REST APIの呼び出し結果は`io.restassured.response.Response`として取得し、レスポンスボディはJUnitのアサーションAPIで検証するものとします。
* テストメソッド名は英語で、コメントは日本語でお願いします。
* すべてのテストメソッドを生成してください。
# OpenAPI仕様ファイル
....既出のOpenAPI仕様を貼り付ける...
このようなプロンプトをChatGPTに投入すると、JUnit 5とRestAssuredによるテストクラスのコードが生成されます。返されるコードはここでは省略しますが、当該REST APIのためのテスト要件を充足した、非常に精度が高いものです。
このケースがうまくいく理由は、REST APIのテスト仕様として
WireMockによるモックサーバーのコード生成
テストとは少し趣旨が変わりますが、ここで取り上げるのはWireMockによるモックサーバーです。
前項と同じ、Personリソースを対象にしたREST APIの
以下の[OpenAPI仕様ファイル]に示したREST APIがあるものとします。
[制約条件]に従い、モックサーバーのコードを生成してください。
# 制約条件
* モックサーバーは、WireMockを利用します。
* モックサーバーのテストクラスは`pro.kensait.spring.person.rest.test.WireMockApp`とします。
# OpenAPI仕様ファイル
....既出のOpenAPI仕様を貼り付ける...
このようなプロンプトをChatGPTに投入すると、WireMockによるモックサーバーのコードが生成されます。生成されるコードはここでは割愛しますが、当該REST APIの振る舞いを忠実に再現した、そのまま動作するモックサーバーが簡単に出来上がります。
もちろん、引数マッチングや返すレスポンスに関しては生成AIに情報を与えていないため、生成されたコードに対して後から開発者が補う必要があります。ただし、それを差し引いても、大幅に効率化できる点は間違いありません。
4. SelenideによるUIテストでの活用
この項では、ChatGPTを活用してUIテストコードを生成する事例を紹介します。
UIテストの題材は、書籍の7.
生成するテストコードは、JUnit 5+Selenideによるものとします。書籍に登場したBookStoreTestクラスが一種の模範解答になります。
ここでは既出のアプローチ①に則り、まずUI操作や検証内容を表す
具体的には、以下のようなプロンプトを投入します。
以下の[制約条件]および[テストシナリオ]に従い、WebブラウザのUIテストを行います。
SelenideとJUnit 5によるJavaのテストコードを、生成してください。
# 制約条件
* パッケージ名は`pro.kensait.selenium.bookstore`、クラス名は`BookStoreTest`とします。
* ベースURLとしてhttp://localhost:8080を設定してください。
* リンクはID属性を指定してクリックしてください。
* ID属性は、"#"で表現してください。
* 要素(HTMLタグ)は、Selenideの"$$"を使って取得してください。
* タイトルの検証には、JUnit 5のアサーションAPIを使ってください。
* タイトル以外の検証には、Selenideの検証API(`shouldHave()`、`shouldBe()`など)を使ってください。
* タイトルの検証した直後に、ページのスクリーンショットを保存してください。
保存名は、"番号-ページタイトル"とします。
# テストシナリオ
|番号|指示|ID|場所|VALUE|
|:--|--|--|--|--|
|1|オープン|||http://localhost:8080/|
|2|検証||title()|TopPage|
|3|入力|email||[email protected]|
|4|入力|password||password|
|5|クリック|loginButton||loginButton|
|6|リダイレクト|||/toSelect|
|7|検証||title()|BookSelectPage|
|8|検証|book-table|ボディ内の1行目の1列目|Java SEディープダイブ|
|9|検証|book-table|ボディの行数|34|
....中略....
|36|検証||title()|OrderSuccessPage|
|37|クリック|logoutButton|||
|38|検証||title()|FinishPage|
「テストシナリオ」
このようなプロンプトを投入すると、JUnit 5とSelenideによるUIテストコードが生成されます。その内容はここでは割愛しますが、模範解答となるBookStoreTestクラスとほぼ同様で、そのまま動作可能な非常に精度が高いものです。
注目していただきたいのが、テストシナリオの
$$("#book-table tbody tr").first().$$("td").first().shouldHave(text("Java SEディープダイブ"));
テストシナリオに記述したボディ内の1行目の1列目
といった日本語が、正しくDOMに変換されていることが分かります。これは
このように
5. Gatlingによる負荷テストでの活用
この項では、ChatGPTを活用して負荷テストコードを生成する事例を紹介します。
負荷テストの題材は、前項と同様に
ここでも既出のアプローチ①に則り、まず
以下の[負荷テストシナリオ]および[シミュレーションの全体設定]に従い、負荷テストを行います。
Gatlingのシミュレーションクラスを、以下の[制約条件]に基づき、概念的なJavaコードとして生成してください。
# 制約条件
* パッケージ名は`pro.kensait.gatling.bookstore.scenario1`、クラス名は`BookStoreSimulation`とします。
* シミュレーションクラスは、`io.gatling.javaapi.core.Simulation`を継承してください。
* ベースURLは、`localhost:8080`に設定してください。
* シナリオは、`ScenarioBuilder`を使って生成してください。
* フィーダーは、CSVファイルを"data/users.csv"から取得し、サイクリックに読み込んでくださいください。
また読み込んだデータは、userId、passwordという名前でセッション変数に保存するものとします。
* `setUp()`には、イニシャライザーを使ってください。
* Titleの検証は、`css("title")`と指定することでTitleを取得してください。
* "Save CSRF"では、`csrfToken`というIDのvalue属性からCSRFトークンを取得し、`sessionCsrfToken`という名前のセッション変数で保持してください。
* シナリオは、`pace()`メソッドによって、シナリオ番号1~12の間を、30秒間のペースに調整してください。
* シナリオは、`forever()`メソッドによって、無限に繰り返してください。
* 個々のアクション間には、2秒の休止時間を入れてください。
# 負荷テストシナリオ
|番号|アクション|メソッド|URL|パラメータ|検証|
|:--|:--|:--|:--|:--|:--|
|1|Open|GET|/||Status: 200, Title: "TopPage", Save CSRF|
|2|Login|POST|/processLogin|email: "#{userId}", password: "#{password}", _csrf: #{sessionCsrfToken}|Status: 200, Title: "BookSelectPage", Save CSRF|
|3|Add Book|POST|/addBook|bookId: "2", _csrf: #{sessionCsrfToken}|Status: 200, Title: "CartViewPage", Save CSRF|
....中略....
|11|Fix|POST|/fix|_csrf: #{sessionCsrfToken}|Status: 200, Title: "BookOrderPage", Save CSRF|
|12|Order|POST|/order1|settlementType: "1", _csrf: #{sessionCsrfToken}|Status: 200, Title: "OrderSuccessPage", Save CSRF|
|13|Logout|POST|/processLogout|_csrf: #{sessionCsrfToken}|Status: 200, Title: "FinishPage"|
# シミュレーションの全体設定
* 最初は1ユーザから始め、100秒かけて最大5ユーザーまで増加させてください。
* 起動してから200秒経過したら、シミュレーション全体を終了してください。
「負荷テストシナリオ」
このようなプロンプトを投入すると、Gatlingによる負荷テストコードが生成されます。その内容はここでは割愛しますが、模範解答であるBookStoreSimulationクラスとほぼ同様のコードであり、仕様通りにそのまま動作します。
一点注意が必要なのは、Gatlingは基本的にScalaベースの負荷テストツールのため、テストコードをJavaで記述できるということを、本稿執筆時点で"gpt4"が理解していなかったという点です。
したがってプロンプトを工夫し、
このように