生成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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?