- AI ⚙️
生成AIでテストコードを作るプロンプト集|テストケースの洗い出し
生成AIでテストコードを作るときは、いきなりコードを書かせず、「仕様からテストケース表を出させる」「表からテストコードを書かせる」の2段階に分けるのが近道です。 あいだに人が表を確かめる工程を挟むことで、条件の漏れと期待値の誤りを、コードになる前に見つけられます。
この記事では、2段階のプロンプトを pytest・JUnit 5・Vitest の3種類ぶん用意しました。プロンプトに埋め込める観点表(同値分割・境界値・異常系・null・空・重複・権限)、モックの範囲や命名などの条件指定の早見表、架空の送料計算を題材にしたテストケース表(27件)と、Pythonで検算した期待値を載せています。同値分割や境界値分析の技法そのものの説明は扱わず、AIへの頼み方と確かめ方に絞ります。
確認環境:Ubuntu 24.04、Python 3.11.15+pytest 9.1.1、OpenJDK 21.0.10+JUnit Jupiter 5.10.1、Node.js 22.22+Vitest 5.0.3。掲載したテストコードと検算のスクリプトは、この環境で実行した結果を添えています。プロンプトは特定のAI製品に依存しない書き方で、AIの出力例は載せていません。テストコードは、確定したテストケース表をもとにこの記事で用意した見本です。
2段階プロンプトの早見表
| 段階 | AIに渡すもの | AIに出させるもの | 人がすること |
|---|---|---|---|
| 1. 洗い出し | 仕様、観点表、出力形式の指定 | テストケース表(ID・観点・入力・期待値・根拠)と、仕様の質問一覧 | 表の抜けを足す、期待値を検算する、質問に答えて仕様を直す |
| 2. コード化 | 確定したテストケース表、対象コードの関数定義、条件指定 | テストコード(表のIDと1対1) | 件数とIDを突き合わせる、実行する、通らないテストの原因を切り分ける |
フレームワークごとに、段階2で差し替える指定は次のとおりです。
| 項目 | pytest | JUnit 5 | Vitest |
|---|---|---|---|
| 表を流し込む書き方 | @pytest.mark.parametrize と pytest.param(..., id=...) |
@ParameterizedTest と @CsvSource |
it.each |
| 例外の確かめ方 | pytest.raises |
assertThrows |
expect(() => ...).toThrow |
| ケース名の付け方 | id="T07_重量2.0" |
@ParameterizedTest(name = "{0}") で1列目を表示 |
it.each の題名に %s |
| nullの渡し方 | None |
@CsvSource(nullValues = "NULL") |
null、undefined |
| 実行コマンド | python3 -m pytest -q |
ConsoleLauncher、またはビルドツールのテスト実行 | npx vitest run |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「生成AIでテストコードを作るプロンプト集|テストケースの洗い出し」
なぜ2段階に分けるのか
| 頼み方 | 起きやすいこと | 2段階にすると |
|---|---|---|
| 「この関数のテストを書いて」と対象コードだけ渡す | コードの今の動きをそのまま期待値にしたテストになる。仕様とのずれは見つからない | 期待値は仕様から決め、表で人が確かめる |
| 仕様とコードを渡して一度に書かせる | 件数は多いが、どの条件を押さえたのかが読み取りにくい | 表に観点と根拠の列があり、漏れを一覧で探せる |
| 期待値をAIの計算に任せる | 端数や境目で計算違いが混ざることがある | 期待値は別の方法で検算してから確定する |
表はテストコードよりずっと読みやすく、直しやすいのが利点です。27件のテストコードを読んで漏れを探すのは骨が折れますが、27行の表なら数分で見渡せます。
題材:架空の送料計算の仕様
関数:送料計算
入力:小計(円、0以上の整数)、重量(kg、0より大きく30以下、小数可)、
地域(standard または remote)、会員(真偽)
出力:送料(円、整数)
規則1 基本送料は重量で決まる。
2kg以下は500円、2kgを超え10kg以下は800円、10kgを超え30kg以下は1200円。
規則2 小計が5000円以上なら、基本送料は0円。
規則3 地域が remote のとき、400円を加える(規則2で0円になった後にも加える)。
規則4 会員は、規則3までの合計から10%引き。1円未満は切り捨て。
規則5 入力が範囲外(小計が負、重量が0以下または30を超える、地域が2つ以外、
必須の値がない)のときはエラーにする。
プロンプトに埋め込む観点表
段階1のプロンプトに、次の観点表をそのまま貼ります。「該当しない観点は、理由を書いて該当なしと明記させる」のがポイントです。黙って省かれるのを防げます。
| 観点 | 洗い出すケース | 見落としやすい点 |
|---|---|---|
| 同値分割(有効) | 規則ごとに、結果が変わるグループから代表値を1つずつ | 規則の組合せで結果が変わるグループ |
| 同値分割(無効) | 受け付けない値のグループから代表値を1つずつ | 型は合っているが意味が範囲外の値 |
| 境界値 | 各境目の「ちょうど」と「1つ隣」。最小値と最大値 | 以上と超える、以下と未満の取り違え |
| 異常系 | 範囲外、未定義の選択肢、型の違い | エラーの種類とメッセージまで期待値に書く |
| null | 必須の引数それぞれが null(None、undefined) | 言語の型で渡せない場合は「渡せない」と書く |
| 空 | 空文字、空の配列、0件、空白だけの文字列 | 空と null を同じ扱いにするかどうか |
| 重複 | 同じ値が2回、同じ操作が2回、一意にすべき値の重なり | 2回目の結果(同じ結果か、エラーか) |
| 権限 | 役割ごとにできる・できない操作、他人のデータへの操作 | 権限がないときの応答の種類 |
| 組合せ | 複数の規則が同時に当てはまるケース | 規則が適用される順番 |
| 端数・単位 | 割り算や割合で端数が出る値、単位の違い | 切り捨て・切り上げ・四捨五入のどれか |
段階1:仕様からテストケース表を出させるプロンプト
# 依頼
下の仕様について、単体テストのテストケース表を作ってください。
テストコードはまだ書きません。
# 手順
1. 観点表の観点を上から順に当てはめ、ケースを洗い出す。
2. 該当しない観点は、ケースを作らず「該当なし」とし、理由を1行で書く。
3. 仕様から期待値が1つに決められないケースは、期待値を「要確認」とし、
「仕様への質問」に質問を書く。期待値を推測で埋めない。
# 出力形式
## テストケース表(Markdownの表)
| ID | 観点 | 入力(引数ごとに列を分ける) | 期待値 | 根拠にした規則 |
- IDは T01 からの連番。
- 1行は1つの確認だけにする。
- 期待値がエラーのときは ERROR と書く。
- 期待値には、計算の式も添える(例:800+400=1200、会員で1200×0.9=1080)。
## 該当なしの観点(表:観点/理由)
## 仕様への質問(表:番号/質問/関係するケースのID)
## 同じ表のタブ区切り(コードブロックで)
# 観点表
(上の観点表を貼る)
# 仕様
(ここに仕様を貼る)
「期待値には計算の式も添える」と指定すると、AIがどの規則をどの順番で当てはめたかが表に残り、検算がしやすくなります。
人が確かめて確定させたテストケース表(27件)
段階1の出力を人が確かめ、足し引きして確定させた形の表が次のものです。重複と権限は、この関数には当てはまらないため「該当なし」です(1回の計算で完結し、役割による違いもないため)。
| ID | 観点 | 小計 | 重量kg | 地域 | 会員 | 期待値 |
|---|---|---|---|---|---|---|
| T01 | 同値:軽量・通常・非会員 | 3000 | 1 | standard | false | 500 |
| T02 | 同値:中量 | 3000 | 5 | standard | false | 800 |
| T03 | 同値:重量 | 3000 | 20 | standard | false | 1200 |
| T04 | 同値:遠隔地の加算 | 3000 | 1 | remote | false | 900 |
| T05 | 同値:会員10%引き | 3000 | 1 | standard | true | 450 |
| T06 | 同値:送料無料の小計 | 8000 | 5 | standard | false | 0 |
| T07 | 境界:重量2.0 | 3000 | 2 | standard | false | 500 |
| T08 | 境界:重量2.1 | 3000 | 2.1 | standard | false | 800 |
| T09 | 境界:重量10.0 | 3000 | 10 | standard | false | 800 |
| T10 | 境界:重量10.1 | 3000 | 10.1 | standard | false | 1200 |
| T11 | 境界:重量30.0 | 3000 | 30 | standard | false | 1200 |
| T12 | 境界:重量0.1 | 3000 | 0.1 | standard | false | 500 |
| T13 | 境界:小計4999 | 4999 | 1 | standard | false | 500 |
| T14 | 境界:小計5000 | 5000 | 1 | standard | false | 0 |
| T15 | 境界:小計0 | 0 | 1 | standard | false | 500 |
| T16 | 組合せ:無料でも遠隔地加算は残る | 5000 | 1 | remote | false | 400 |
| T17 | 組合せ:無料+遠隔地+会員 | 5000 | 1 | remote | true | 360 |
| T18 | 組合せ:中量+遠隔地+会員 | 3000 | 5 | remote | true | 1080 |
| T19 | 組合せ:重量+遠隔地+会員 | 3000 | 20 | remote | true | 1440 |
| T20 | 異常:小計が負 | -1 | 1 | standard | false | ERROR |
| T21 | 異常:重量0 | 3000 | 0 | standard | false | ERROR |
| T22 | 異常:重量30.1 | 3000 | 30.1 | standard | false | ERROR |
| T23 | 異常:重量が負 | 3000 | -1 | standard | false | ERROR |
| T24 | 異常:地域が未定義の値 | 3000 | 1 | moon | false | ERROR |
| T25 | 空:地域が空文字 | 3000 | 1 | (空文字) | false | ERROR |
| T26 | null:地域がnull | 3000 | 1 | NULL | false | ERROR |
| T27 | null:重量がnull | 3000 | NULL | standard | false | ERROR |
地域の「(空文字)」は長さ0の文字列、NULL は値がないことを表します。表計算ソフトに貼れるタブ区切りです。
ID 観点 小計 重量kg 地域 会員 期待値
T01 同値:軽量・通常・非会員 3000 1 standard false 500
T02 同値:中量 3000 5 standard false 800
T03 同値:重量 3000 20 standard false 1200
T04 同値:遠隔地の加算 3000 1 remote false 900
T05 同値:会員10%引き 3000 1 standard true 450
T06 同値:送料無料の小計 8000 5 standard false 0
T07 境界:重量2.0 3000 2 standard false 500
T08 境界:重量2.1 3000 2.1 standard false 800
T09 境界:重量10.0 3000 10 standard false 800
T10 境界:重量10.1 3000 10.1 standard false 1200
T11 境界:重量30.0 3000 30 standard false 1200
T12 境界:重量0.1 3000 0.1 standard false 500
T13 境界:小計4999 4999 1 standard false 500
T14 境界:小計5000 5000 1 standard false 0
T15 境界:小計0 0 1 standard false 500
T16 組合せ:無料でも遠隔地加算は残る 5000 1 remote false 400
T17 組合せ:無料+遠隔地+会員 5000 1 remote true 360
T18 組合せ:中量+遠隔地+会員 3000 5 remote true 1080
T19 組合せ:重量+遠隔地+会員 3000 20 remote true 1440
T20 異常:小計が負 -1 1 standard false ERROR
T21 異常:重量0 3000 0 standard false ERROR
T22 異常:重量30.1 3000 30.1 standard false ERROR
T23 異常:重量が負 3000 -1 standard false ERROR
T24 異常:地域が未定義の値 3000 1 moon false ERROR
T25 空:地域が空文字 3000 1 false ERROR
T26 null:地域がnull 3000 1 NULL false ERROR
T27 null:重量がnull 3000 NULL standard false ERROR
期待値をPythonで検算する
期待値は、テスト対象のコードとは別に書いた式で検算します。次のスクリプトは、上のタブ区切りを cases.tsv として保存し、仕様の規則から期待値を計算して表と突き合わせます。重量は小数の誤差を避けるため Decimal で読みます。
"""テストケース表の期待値を、仕様の式から独立に計算して突き合わせる"""
import csv
from decimal import Decimal
def expected(subtotal, weight, region, member):
if subtotal is None or weight is None or region is None:
return "ERROR"
if subtotal < 0 or not (Decimal("0") < weight <= Decimal("30")):
return "ERROR"
if region not in ("standard", "remote"):
return "ERROR"
base = 500 if weight <= 2 else 800 if weight <= 10 else 1200
if subtotal >= 5000:
base = 0
fee = base + (400 if region == "remote" else 0)
return str(fee * 90 // 100 if member else fee)
def val(s, conv):
return None if s == "NULL" else conv(s)
ng = 0
with open("cases.tsv", encoding="utf-8", newline="") as f:
for r in csv.DictReader(f, delimiter="\t"):
got = expected(val(r["小計"], int), val(r["重量kg"], Decimal),
val(r["地域"], str), r["会員"] == "true")
mark = "OK" if got == r["期待値"] else "NG"
ng += mark == "NG"
print(f'{r["ID"]} {mark} 表={r["期待値"]:>5} 検算={got:>5} {r["観点"]}')
print("不一致:", ng, "件")
$ python3 check_expected.py
T01 OK 表= 500 検算= 500 同値:軽量・通常・非会員
T02 OK 表= 800 検算= 800 同値:中量
…(省略)…
T17 OK 表= 360 検算= 360 組合せ:無料+遠隔地+会員
T18 OK 表= 1080 検算= 1080 組合せ:中量+遠隔地+会員
T19 OK 表= 1440 検算= 1440 組合せ:重量+遠隔地+会員
…(省略)…
T26 OK 表=ERROR 検算=ERROR null:地域がnull
T27 OK 表=ERROR 検算=ERROR null:重量がnull
不一致: 0 件
表を確かめるときのチェック表
| 確かめること | 見方 | この題材で気づけること |
|---|---|---|
| 規則ごとに、有効なケースが1つ以上ある | 「根拠にした規則」の列で並べ替える | 規則3と規則2が重なるケース(T16)があるか |
| 境目は「ちょうど」と「隣」の両方がある | 境目の値を書き出して対にする | 2.0と2.1、10.0と10.1、4999と5000、30.0と30.1、0と0.1 |
| 範囲外・null・空が引数ごとにある | 引数×観点の表にして空きを探す | 会員が null のケースをどう扱うか(質問に回す) |
| 期待値が検算と一致する | 別に書いた式で計算する | 会員の10%引きの計算違い |
| 仕様から期待値が決まらないケースが質問になっている | 「要確認」を数える | 下の「端数」の件 |
この題材では、規則4の「1円未満は切り捨て」を確かめるケースが作れません。 送料の候補は 0・400・500・800・900・1200・1600 円のどれかで、すべて100円の倍数なので、10%引きで端数が出ないためです。こうした点は「仕様への質問」として残し、「端数が出る料金に将来変わる予定があるか」を決める人に確かめます。表で確かめる工程があると、テストコードにする前にこうした点に気づけます。
段階2:表からテストコードを書かせるプロンプト
共通部分に、フレームワーク別の条件を1つ選んで足します。
# 依頼
下のテストケース表を、そのままテストコードにしてください。
# 共通の条件
- 表の1行を1つのテストケースにする。ケースを足さない、減らさない、まとめない。
- 各ケースの名前に表のIDを入れる。
- 期待値は表の値をそのまま書く。対象コードを読んで期待値を変えない。
- 期待値が ERROR の行は、例外の種類を確かめるテストにする。
- 1つのテストで確かめるのは1つの結果だけにする。
- 対象の関数は置き換えない。外部への通信、現在時刻、乱数、DBがある場合だけ、
その部分を置き換える(今回は該当なし)。
- 言語の型の都合で書けない行は、コードにせず、最後に
「コードにしなかったID/理由」の表で報告する。
- 最後に、表の行数とテストケース数を並べて書く。
# フレームワーク別の条件(どれか1つを貼る)
(下の3つから選ぶ)
# 対象の関数定義
(関数名、引数、戻り値、例外がわかる部分を貼る)
# テストケース表
(確定した表をタブ区切りで貼る)
# フレームワーク別の条件:pytest
- pytest を使う。期待値が数値の行と ERROR の行で、テスト関数を2つに分ける。
- @pytest.mark.parametrize と pytest.param(..., id="T01_説明") で表を流し込む。
- 例外は pytest.raises(ValueError) で確かめる。
- null は None として渡す。
# フレームワーク別の条件:JUnit 5
- JUnit 5(Jupiter)を使う。@ParameterizedTest(name = "{0}") と @CsvSource で表を流し込み、
1列目をIDと説明にする。
- 例外は assertThrows(IllegalArgumentException.class, ...) で確かめる。
- null は @CsvSource(nullValues = "NULL") で渡す。空文字は '' と書く。
- 引数がプリミティブ型で null を渡せない行は、コードにせず報告する。
# フレームワーク別の条件:Vitest
- Vitest を使い、TypeScriptで書く。describe の中で it.each に配列で表を流し込む。
- 題名は "%s" で1列目(IDと説明)を表示する。
- 例外は expect(() => ...).toThrow(RangeError) で確かめる。
- null と undefined の行は、型の変換(as)を付けて渡す。
条件指定の早見表
プロンプトの条件に足す文を、目的別にまとめました。
| 決めたいこと | プロンプトに書く文 | 書かないと起きやすいこと |
|---|---|---|
| ケースの数 | 表の1行を1つのテストケースにする。足さない、減らさない、まとめない | 似たケースがまとめられ、表と対応が取れなくなる |
| 命名 | 各ケースの名前に表のIDを入れる | 通らなかったテストが表のどの行か探しにくい |
| 1テスト1検証 | 1つのテストで確かめるのは1つの結果だけにする | 最初の検証で止まり、後ろの結果が見えない |
| 期待値の出どころ | 期待値は表の値をそのまま書く。対象コードを読んで変えない | コードの今の動きに合わせた期待値になる |
| モックの範囲 | 対象の関数は置き換えない。外部への通信・現在時刻・乱数・DBだけを置き換える | 対象そのものが置き換えられ、何も検証しないテストになる |
| 時刻・乱数 | 現在時刻と乱数は固定の値を外から渡す形にする | 実行するたびに結果が変わる |
| 準備と後始末 | 各テストは単独で動くようにし、ほかのテストの結果に頼らない | 実行の順番で結果が変わる |
| 構成 | 準備・実行・検証の3つの部分がわかるように空行で分ける | どこが検証なのか読み取りにくい |
| 例外 | 例外は種類まで確かめる。メッセージを確かめるかどうかは表に従う | どんな例外でも通るテストになる |
| 浮動小数点 | 小数の比較は、許容する差を指定して比べる | 0.1+0.2 のような誤差で通らなくなる |
| 書けない行 | 型の都合で書けない行は、コードにせず理由を報告する | 黙って省かれ、件数が合わなくなる |
| 件数の報告 | 最後に表の行数とテストケース数を並べて書く | 漏れに気づくのが実行後になる |
テストコードの見本と実行結果
確定した表から起こしたテストコードの見本と、その実行結果です。段階2の出力を受け取ったら、同じ形になっているかを見比べる基準として使えます。
テスト対象(Python)
"""架空の送料計算(題材)"""
REGIONS = ("standard", "remote")
def shipping_fee(subtotal: int, weight_kg: float, region: str, member: bool) -> int:
"""送料(円)を返す。入力が仕様の範囲外なら ValueError。"""
if subtotal is None or weight_kg is None or region is None:
raise ValueError("required")
if subtotal < 0:
raise ValueError("subtotal")
if weight_kg <= 0 or weight_kg > 30:
raise ValueError("weight_kg")
if region not in REGIONS:
raise ValueError("region")
if weight_kg <= 2:
base = 500
elif weight_kg <= 10:
base = 800
else:
base = 1200
if subtotal >= 5000:
base = 0
fee = base + (400 if region == "remote" else 0)
if member:
fee = fee * 90 // 100
return fee
pytest
import pytest
from shipping import shipping_fee
@pytest.mark.parametrize(
"subtotal, weight_kg, region, member, expected",
[
pytest.param(3000, 1, "standard", False, 500, id="T01_軽量_通常_非会員"),
pytest.param(3000, 5, "standard", False, 800, id="T02_中量"),
pytest.param(3000, 20, "standard", False, 1200, id="T03_重量"),
pytest.param(3000, 1, "remote", False, 900, id="T04_遠隔地の加算"),
pytest.param(3000, 1, "standard", True, 450, id="T05_会員10%引き"),
pytest.param(8000, 5, "standard", False, 0, id="T06_送料無料の小計"),
pytest.param(3000, 2, "standard", False, 500, id="T07_重量2.0"),
pytest.param(3000, 2.1, "standard", False, 800, id="T08_重量2.1"),
pytest.param(3000, 10, "standard", False, 800, id="T09_重量10.0"),
pytest.param(3000, 10.1, "standard", False, 1200, id="T10_重量10.1"),
pytest.param(3000, 30, "standard", False, 1200, id="T11_重量30.0"),
pytest.param(3000, 0.1, "standard", False, 500, id="T12_重量0.1"),
pytest.param(4999, 1, "standard", False, 500, id="T13_小計4999"),
pytest.param(5000, 1, "standard", False, 0, id="T14_小計5000"),
pytest.param(0, 1, "standard", False, 500, id="T15_小計0"),
pytest.param(5000, 1, "remote", False, 400, id="T16_無料でも遠隔地加算"),
pytest.param(5000, 1, "remote", True, 360, id="T17_無料_遠隔地_会員"),
pytest.param(3000, 5, "remote", True, 1080, id="T18_中量_遠隔地_会員"),
pytest.param(3000, 20, "remote", True, 1440, id="T19_重量_遠隔地_会員"),
],
)
def test_送料が期待値どおりになる(subtotal, weight_kg, region, member, expected):
assert shipping_fee(subtotal, weight_kg, region, member) == expected
@pytest.mark.parametrize(
"subtotal, weight_kg, region",
[
pytest.param(-1, 1, "standard", id="T20_小計が負"),
pytest.param(3000, 0, "standard", id="T21_重量0"),
pytest.param(3000, 30.1, "standard", id="T22_重量30.1"),
pytest.param(3000, -1, "standard", id="T23_重量が負"),
pytest.param(3000, 1, "moon", id="T24_地域が未定義"),
pytest.param(3000, 1, "", id="T25_地域が空文字"),
pytest.param(3000, 1, None, id="T26_地域がNone"),
pytest.param(3000, None, "standard", id="T27_重量がNone"),
],
)
def test_範囲外の入力はValueErrorになる(subtotal, weight_kg, region):
with pytest.raises(ValueError):
shipping_fee(subtotal, weight_kg, region, False)
$ python3 -m pytest -q
........................... [100%]
27 passed in 0.02s
JUnit 5
テスト対象は次のJavaのクラスです。重量は double、小計は int で受け取ります。
public final class Shipping {
private Shipping() {}
/** 送料(円)を返す。入力が仕様の範囲外なら IllegalArgumentException。 */
public static int fee(int subtotal, double weightKg, String region, boolean member) {
if (region == null) {
throw new IllegalArgumentException("region");
}
if (subtotal < 0) {
throw new IllegalArgumentException("subtotal");
}
if (!(weightKg > 0 && weightKg <= 30)) {
throw new IllegalArgumentException("weightKg");
}
if (!region.equals("standard") && !region.equals("remote")) {
throw new IllegalArgumentException("region");
}
int base = weightKg <= 2 ? 500 : weightKg <= 10 ? 800 : 1200;
if (subtotal >= 5000) {
base = 0;
}
int fee = base + (region.equals("remote") ? 400 : 0);
return member ? fee * 90 / 100 : fee;
}
}
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
class ShippingTest {
@DisplayName("送料が期待値どおりになる")
@ParameterizedTest(name = "{0}")
@CsvSource({
"T01_軽量_通常_非会員, 3000, 1, standard, false, 500",
"T02_中量, 3000, 5, standard, false, 800",
"T03_重量, 3000, 20, standard, false, 1200",
"T04_遠隔地の加算, 3000, 1, remote, false, 900",
"T05_会員10%引き, 3000, 1, standard, true, 450",
"T06_送料無料の小計, 8000, 5, standard, false, 0",
"T07_重量2.0, 3000, 2, standard, false, 500",
"T08_重量2.1, 3000, 2.1, standard, false, 800",
"T09_重量10.0, 3000, 10, standard, false, 800",
"T10_重量10.1, 3000, 10.1, standard, false, 1200",
"T11_重量30.0, 3000, 30, standard, false, 1200",
"T12_重量0.1, 3000, 0.1, standard, false, 500",
"T13_小計4999, 4999, 1, standard, false, 500",
"T14_小計5000, 5000, 1, standard, false, 0",
"T15_小計0, 0, 1, standard, false, 500",
"T16_無料でも遠隔地加算, 5000, 1, remote, false, 400",
"T17_無料_遠隔地_会員, 5000, 1, remote, true, 360",
"T18_中量_遠隔地_会員, 3000, 5, remote, true, 1080",
"T19_重量_遠隔地_会員, 3000, 20, remote, true, 1440"
})
void feeMatchesExpected(String id, int subtotal, double weightKg,
String region, boolean member, int expected) {
assertEquals(expected, Shipping.fee(subtotal, weightKg, region, member));
}
@DisplayName("範囲外の入力は IllegalArgumentException になる")
@ParameterizedTest(name = "{0}")
@CsvSource(nullValues = "NULL", value = {
"T20_小計が負, -1, 1, standard",
"T21_重量0, 3000, 0, standard",
"T22_重量30.1, 3000, 30.1, standard",
"T23_重量が負, 3000, -1, standard",
"T24_地域が未定義, 3000, 1, moon",
"T25_地域が空文字, 3000, 1, ''",
"T26_地域がnull, 3000, 1, NULL"
})
void outOfRangeThrows(String id, int subtotal, double weightKg, String region) {
assertThrows(IllegalArgumentException.class,
() -> Shipping.fee(subtotal, weightKg, region, false));
}
}
[ 26 tests found ]
[ 0 tests skipped ]
[ 26 tests started ]
[ 0 tests aborted ]
[ 26 tests successful ]
[ 0 tests failed ]
件数が26なのは、T27(重量がnull)をコードにしていないためです。 引数が double のため、null を渡すコードはコンパイルできません。型が守ってくれるケースなので、表には残したまま「コードにしなかったID/理由」に記録します。プロンプトの「書けない行は報告する」という条件は、こうした差を見える形にするためのものです。
Vitest
export type Region = "standard" | "remote";
/** 送料(円)を返す。入力が仕様の範囲外なら RangeError。 */
export function shippingFee(
subtotal: number,
weightKg: number,
region: string,
member: boolean,
): number {
if (!Number.isInteger(subtotal) || subtotal < 0) throw new RangeError("subtotal");
if (!(weightKg > 0 && weightKg <= 30)) throw new RangeError("weightKg");
if (region !== "standard" && region !== "remote") throw new RangeError("region");
let base = weightKg <= 2 ? 500 : weightKg <= 10 ? 800 : 1200;
if (subtotal >= 5000) base = 0;
const fee = base + (region === "remote" ? 400 : 0);
return member ? Math.floor((fee * 90) / 100) : fee;
}
import { describe, expect, it } from "vitest";
import { shippingFee } from "./shipping";
describe("shippingFee", () => {
it.each([
["T01_軽量_通常_非会員", 3000, 1, "standard", false, 500],
["T02_中量", 3000, 5, "standard", false, 800],
["T03_重量", 3000, 20, "standard", false, 1200],
["T04_遠隔地の加算", 3000, 1, "remote", false, 900],
["T05_会員10%引き", 3000, 1, "standard", true, 450],
["T06_送料無料の小計", 8000, 5, "standard", false, 0],
["T07_重量2.0", 3000, 2, "standard", false, 500],
["T08_重量2.1", 3000, 2.1, "standard", false, 800],
["T09_重量10.0", 3000, 10, "standard", false, 800],
["T10_重量10.1", 3000, 10.1, "standard", false, 1200],
["T11_重量30.0", 3000, 30, "standard", false, 1200],
["T12_重量0.1", 3000, 0.1, "standard", false, 500],
["T13_小計4999", 4999, 1, "standard", false, 500],
["T14_小計5000", 5000, 1, "standard", false, 0],
["T15_小計0", 0, 1, "standard", false, 500],
["T16_無料でも遠隔地加算", 5000, 1, "remote", false, 400],
["T17_無料_遠隔地_会員", 5000, 1, "remote", true, 360],
["T18_中量_遠隔地_会員", 3000, 5, "remote", true, 1080],
["T19_重量_遠隔地_会員", 3000, 20, "remote", true, 1440],
])("%s", (_id, subtotal, weightKg, region, member, expected) => {
expect(shippingFee(subtotal, weightKg, region, member)).toBe(expected);
});
it.each([
["T20_小計が負", -1, 1, "standard"],
["T21_重量0", 3000, 0, "standard"],
["T22_重量30.1", 3000, 30.1, "standard"],
["T23_重量が負", 3000, -1, "standard"],
["T24_地域が未定義", 3000, 1, "moon"],
["T25_地域が空文字", 3000, 1, ""],
["T26_地域がnull", 3000, 1, null],
["T27_重量がundefined", 3000, undefined, "standard"],
])("%s は RangeError", (_id, subtotal, weightKg, region) => {
expect(() =>
shippingFee(subtotal as number, weightKg as number, region as string, false),
).toThrow(RangeError);
});
});
$ npx vitest run
…(省略)…
Test Files 1 passed (1)
Tests 27 passed (27)
3種類の対応
| 表の内容 | pytest | JUnit 5 | Vitest |
|---|---|---|---|
| 件数 | 27 | 26(T27は型で渡せない) | 27 |
| エラーの種類 | ValueError |
IllegalArgumentException |
RangeError |
| T25 空文字 | "" |
'' |
"" |
| T26 地域がnull | None |
NULL を nullValues で変換 |
null |
| T27 重量がnull | None |
コードにしない | undefined |
境目の取り違えを表のテストが見つける例
テスト対象の「2kg以下」を、わざと「2kg未満」に書き換えて、同じテストを実行します。
if weight_kg < 2: # 仕様は「2kg以下」。わざと未満に変更
base = 500
$ python3 -m pytest -q
…(省略)…
E AssertionError: assert 800 == 500
E + where 800 = shipping_fee(3000, 2, 'standard', False)
1 failed, 26 passed in 0.03s
通らなかったのは T07(重量2.0)の1件だけです。境目の「ちょうど」の値が表に入っていたので見つかりました。 代表値だけ(T01の1kg、T02の5kg)では、この変更は通ってしまいます。段階1で観点表を渡し、境目の対がそろっているかを人が確かめる意味はここにあります。
テストが通らなかったときにAIへ渡す文の例です。
次のテストが通りませんでした。テストケース表の期待値は確定済みなので変えないでください。
1. 通らなかった原因を、対象コードの該当する行を引用して説明する。
2. 仕様のどの規則と食い違っているかを書く。
3. 対象コードの直し方を、該当の行だけ示す。
(通らなかったテストの出力、対象コード、仕様の該当する規則を貼る)
「期待値は変えない」と書いておくと、テストのほうを書き換えて通す、という方向に進むのを防げます。
よくある質問
Q. 既存のコードしかなく、仕様書がない場合はどうすればよいですか? A. 段階1の前に、「このコードから読み取れる規則を、箇条書きの仕様として書き出す。読み取れない意図は質問にする」と頼み、出てきた規則を人が確かめて仕様として確定させます。その仕様を段階1に渡せば、同じ2段階で進められます。
Q. テストケースが多くなりすぎるときは? A. 表の段階で減らします。同じ同値クラスの代表値が重なっている行をまとめ、境目の対と異常系は残します。組合せが増える場合は、結果に影響する規則の重なりだけを残すよう、段階1のプロンプトに条件を足します。
Q. 期待値の検算は毎回必要ですか? A. 計算を含む期待値は検算をおすすめします。表計算ソフトの数式でも、この記事のような短いスクリプトでも構いません。テスト対象と同じコードを使わず、仕様から別に式を起こすのがポイントです。
Q. モックを使うテストでも、この方法は使えますか? A. 使えます。表に「置き換える相手」と「返す値」の列を足し、段階2の条件に「置き換えるのは表に書いた相手だけ」と書きます。対象の関数そのものを置き換えないことを、条件として明記してください。
まとめ
- 仕様→テストケース表→人の確認→テストコードの2段階に分ける
- 段階1には観点表を貼り、該当しない観点は理由つきで「該当なし」と書かせる
- 期待値は推測させず、決まらないものは「要確認」と質問にさせる
- 期待値は、テスト対象とは別に書いた式で検算してから確定する
- 段階2は表の1行=1テスト、名前にID、期待値は表のままを条件にする
- 型の都合で書けない行は理由を報告させ、件数の差を説明できるようにする
- テストが通らないときは、期待値を変えないことを伝えて原因を調べさせる
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「生成AIでテストコードを作るプロンプト集|テストケースの洗い出し」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。