| name | generate-tests |
| description | project.techStack に基づき ProcessFlow JSON → backend integration test (E2E spec)、または Screen JSON → frontend component test (vitest + @testing-library/react)、または Playwright E2E シナリオテスト (multi-screen) を AI が生成する。P1/P2 は TypeScript NestJS + jest、P3 は React + Next.js + vitest、P4 は Playwright E2E (画面遷移シナリオ)、P5 は AI flow mock + 実 API 切替をカバー。 |
| argument-hint | <flowId|screenId> [出力先] / --scenario <fromScreenId> <toScreenId> / --scenario-name "<name>" <screenId-1> ... <screenId-N> |
| disable-model-invocation | true |
$ARGUMENTS から、テストコードを生成します。
Step 0: 引数解析
$ARGUMENTS を以下のように解析する。
0-A. フラグ検出 (P4 E2E ルーティング)
$ARGUMENTS 先頭に以下のフラグが含まれる場合、P4 E2E シナリオ生成ルートへ直接ルーティングする:
--scenario <screenId-from> <screenId-to>
→ P4 E2E シナリオ生成 (2 画面間)
→ シナリオ ID: "scenario-<screenId-from の8桁>-<screenId-to の8桁>"
--scenario-name "<name>" <screenId-1> ... <screenId-N>
→ P4 E2E シナリオ生成 (N 画面、名前付き)
→ シナリオ ID: "<name>" を kebab-case 化 (例: "投稿ライフサイクル" → "post-lifecycle")
上記フラグが含まれる → Step P4 へ直接ジャンプ (Step 1-2 の UUID ルーティングをスキップ)
0-B. 通常引数解析 (P1/P2/P3)
フラグなしの場合:
- 第1引数
<id> (必須): UUID v4 形式
- UUID でない場合は「引数エラー: UUID v4 形式で指定してください」と報告して中止
- 第2引数
<出力先> (任意): ディレクトリパス (default: .tmp/generated-tests/<入力UUID8桁>/)
出力先ディレクトリが存在しない場合はテスト生成前に作成する。
入力 UUID のルーティング (Step 1-2 で決定):
- ProcessFlow ID にマッチ → backend E2E test 生成 (Step 1 → Step 3)
- Screen ID にマッチ → frontend component test 生成 (Step 1 → Step P3)
- どちらにもマッチしない → エラー報告して中止
0-C. 出力先ディレクトリの決定
| 起動形式 | デフォルト出力先 |
|---|
--scenario <from> <to> | .tmp/generated-tests/scenario-<8桁>-<8桁>/ |
--scenario-name "<name>" ... | .tmp/generated-tests/<kebab-case-name>/ |
<UUID> (通常) | .tmp/generated-tests/<UUID8桁>/ |
Step 1: 入力読込
1-1. active workspace の harmony.json から techStack を取得
MCP ツール workspace_status または workspace_inspect で active workspace を特定し、
その harmony.json を Read で読む。
フォールバック: MCP 未接続の場合は examples/diary/harmony.json を読む。
harmony.techStack:
backend.language, backend.framework
database.type, database.version
auth.method
1-2. 入力 UUID のルーティング (ProcessFlow vs Screen)
harmony.json の entities.processFlows[].id と entities.screens[].id を照合する。
if entities.processFlows[].id にマッチ:
→ ProcessFlow → backend test 生成へ (Step 2: techStack 検証 → Step 3)
elif entities.screens[].id にマッチ:
→ Screen → frontend component test 生成へ (Step P3-1: techStack 検証 → Step P3)
else:
「ID が見つかりません: harmony.json の entities.processFlows / entities.screens を確認してください」
と報告して中止
重要: 同じ UUID が ProcessFlow と Screen 両方に存在する可能性は実際にはないが、
processFlows を先にチェックし、マッチしない場合のみ screens をチェックする。
1-3. ProcessFlow JSON を Read で取得
- active workspace:
<workspace>/process-flows/<id>.json
- フォールバック:
examples/<project-id>/harmony/process-flows/<id>.json
1-4. tableId → physicalName index の構築 (D-2)
ProcessFlow に登場する tableId を収集し、各テーブルの physicalName を解決する。
テーブル index 構築手順:
1. ProcessFlow JSON 全体から tableId 値をすべて収集 (steps の lineage.writes / lineage.reads)
2. 各 tableId について harmony/tables/<tableId>.json を Read
3. physicalName を取得して map 化: { "<tableId>": "<physicalName>" }
例: { "79d2c08c-...": "posts", "d8fc5f8a-...": "photos" }
4. Prisma model 名は physicalName の snake_case → PascalCase 変換
例: "posts" → Post, "post_tags" → PostTag, "photos" → Photo
Step 2: techStack 制約検証
ルーティング結果に応じて対応する検証を行う。
2-A. ProcessFlow モード (backend test)
以下を確認し、対象外の場合は中止する。
if techStack.backend.framework !== "nestjs":
「P1 スコープ外: /generate-tests P1/P2 は NestJS + jest のみサポートします。
現在の techStack.backend.framework: "<value>"。
他 techStack は別 ISSUE で対応予定。」と報告して中止
if techStack.backend.language !== "typescript":
「P1 スコープ外: /generate-tests P1/P2 は TypeScript のみサポートします。
現在の techStack.backend.language: "<value>"。」と報告して中止
→ 検証 OK → Step 3 (backend E2E test 生成) へ
2-B. Screen モード (frontend component test)
以下を確認し、対象外の場合は中止する。
if techStack.frontend.framework !== "next":
「P3 スコープ外: /generate-tests P3 は Next.js + React のみサポートします。
現在の techStack.frontend.framework: "<value>"。」と報告して中止
if techStack.frontend.library !== "react":
「P3 スコープ外: /generate-tests P3 は React のみサポートします。
現在の techStack.frontend.library: "<value>"。」と報告して中止
→ 検証 OK → Step P3-1 (Screen component test 生成) へ
Step 3: ProcessFlow → backend E2E test 生成
ゴールデン出力 (.claude/skills/generate-tests/golden-examples/posts-create-e2e/) を参照しながら、以下の変換ルールに従いテストファイルを生成する。
テンプレート規約: .claude/skills/generate-tests/templates/backend/typescript-nestjs/E2E_SPEC.md を Read して参照すること。
3-1. テストファイルの構造
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
const request = require('supertest');
import { AppModule } from '../src/app.module';
import { PrismaClient } from '@prisma/client';
3-2. describe / beforeAll / afterAll / beforeEach / afterEach の命名
describe('<httpRoute.method> <httpRoute.path> (<action.name> E2E)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let accessToken: string;
let createdIds: { [table: string]: (number | string)[] } = {};
beforeAll(async () => {
const moduleFixture = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: false }));
await app.init();
const dbPath = process.env.DATABASE_URL || `file:${require('path').resolve(__dirname, '../prisma/dev.db')}`;
prisma = new PrismaClient({ datasources: { db: { url: dbPath } } });
accessToken = await loginAsAdmin(app);
});
afterAll(async () => {
await prisma.$disconnect();
await app.close();
});
beforeEach(async () => {
});
afterEach(async () => {
});
});
3-3. spec → test 変換ルール (コア知識)
各ルールで生成する it() には必ず Spec: ProcessFlow <flowId> <step-id> コメントを付与すること (D-1)。
A. inputs[].required=true → missing field → 400
ProcessFlow:
inputs[].name="<field>", required=true
生成テスト:
/**
* Spec: ProcessFlow <flowId> <actionId> step-<N> validation rule
* field=<field>, type=required
*/
it('#N validation: <field> 欠落 → 400', async () => {
const res = await request(app.getHttpServer())
.post('<path>')
.set('Authorization', `Bearer ${accessToken}`) // auth=required の場合
.send({ /* <field> を除いた必須フィールドのみ */ });
expect(res.status).toBe(400);
});
B. validation rules[].type ごとの boundary テスト
maxLength, length=N:
→ N+1 文字 → 400 (超過)
→ N 文字 → 201 (境界値 OK)
minLength, length=N:
→ N-1 文字 → 400 (不足)
→ N 文字 → 201 (境界値 OK)
enum, values=[...]:
→ enum 外の値 (例: "invalid") → 400
pattern, regexp="...":
→ pattern 違反の値 → 400
→ pattern に合致する値 → 201
range, min=M, max=N:
→ N+1 → 400
→ M-1 → 400
→ M / N → 201 (boundary OK)
C. outputs[].name → response.body assertion
ProcessFlow:
outputs[].name="<field>", type="integer"
生成テスト (happy path の中に含める):
expect(res.body).toHaveProperty('<field>');
expect(typeof res.body.<field>).toBe('number'); // integer → number
type ↔ JS/TS assertion 対応:
| ProcessFlow type | assertion |
|---|
| integer | typeof res.body.X === 'number' |
| string | typeof res.body.X === 'string' |
| boolean | typeof res.body.X === 'boolean' |
| array | Array.isArray(res.body.X) |
| object | typeof res.body.X === 'object' && res.body.X !== null |
D. responses[].status → status code テスト
各 response に対して status code assertion を生成する。
正常系 (2xx) は happy path it() の中で確認。
エラー系 (4xx/5xx) は専用の it() として生成。
context.catalogs.errors[] の httpStatus を assertion 期待値として使う:
errors.VALIDATION_ERROR.httpStatus=400 → expect(res.status).toBe(400)
errors.UNAUTHORIZED.httpStatus=401 → expect(res.status).toBe(401)
E. httpRoute.auth="required" → JWT なし → 401
ProcessFlow:
httpRoute.auth="required"
context.catalogs.errors.UNAUTHORIZED.httpStatus=401
生成テスト:
/**
* Spec: ProcessFlow <flowId> act-001 httpRoute.auth="required"
* context.catalogs.errors.UNAUTHORIZED.httpStatus=401
*/
it('#N auth: JWT なし → 401', async () => {
const res = await request(app.getHttpServer())
.post('<path>')
.send({ /* 有効なボディ */ });
expect(res.status).toBe(401);
});
F. step.kind=dbAccess (INSERT) → DB 行追加 assertion
ProcessFlow:
step.kind="dbAccess", operation="INSERT"
lineage.writes[].tableId="<tableId>"
生成テスト:
/**
* Spec: ProcessFlow <flowId> <stepId>
* kind=dbAccess, operation=INSERT, lineage.writes=[<physicalName>]
* affectedRowsCheck: expected=1
*/
it('#N DB 副作用: <physicalName> テーブルに row が追加される', async () => {
const res = await request(...).post(...).send({...});
expect(res.status).toBe(201);
const id = res.body.<outputId>;
createdIds['<physicalName>'] = [...(createdIds['<physicalName>'] ?? []), id];
// Prisma で直接確認
const row = await prisma.<ModelName>.findUnique({ where: { id } });
expect(row).not.toBeNull();
expect(row!.<field>).toBe(<expectedValue>);
});
G. step.kind=loop + collectionSource → 配列 N 件 assertion (D-5, Spike L-3)
ProcessFlow:
step.kind="loop", loopKind="collection", collectionSource="@inputs.<field>"
inner step: dbAccess INSERT <childTable>
生成テスト:
/**
* Spec: ProcessFlow <flowId> <loopStepId>
* kind=loop, collectionSource=@inputs.<field>
* <innerStepId>: dbAccess INSERT <childTable>
*/
it('#N DB 副作用: <field> N 件指定 → <physicalName> に N 行 + 親 ID 紐付け', async () => {
const res = await request(...).post(...).send({
...,
<field>: [item1, item2],
});
expect(res.status).toBe(201);
const parentId = res.body.<id>;
const rows = await prisma.<ChildModel>.findMany({ where: { <parentIdField>: parentId } });
expect(rows).toHaveLength(2);
expect(rows[0].<parentIdField>).toBe(parentId);
});
H. step.txBoundary (role∈{begin,member,end}, 同一 txId) → 故意失敗テスト (D-3)
ProcessFlow:
step.txBoundary.role="begin", txId="<txId>" から
step.txBoundary.role="end" まで同一 txId の step 群がある場合
生成テスト:
/**
* Spec: ProcessFlow <flowId> <beginStepId>
* txBoundary.role="begin", txId="<txId>"
* <endStepId>: txBoundary.role="end"
*
* TX rollback 検証: 故意に UNIQUE/CHECK 制約違反を誘起して TX 全体が rollback されるか確認。
* 注: $transaction を使わない実装では rollback が保証されないため「rollback なし」を文書化テストとして生成。
*/
it('#N TX: <constraint 違反説明> → <expectedStatus> + TX rollback 確認', async () => {
// TX 失敗を誘起する入力 (UNIQUE 違反等)
const res = await request(...).post(...).send({ <failurePayload> });
expect(res.status).toBe(500); // or 409 depending on error handling
// TX rollback の検証:
// $transaction 実装あり → 親テーブルに row が残らないこと
// $transaction 未実装 → 親テーブルに row が残ることを文書化
const remainingRows = await prisma.<MainModel>.findMany({ where: { <identifierFilter> } });
if (remainingRows.length > 0) {
console.warn('[TX 文書化] TX が未実装: <stepId> 失敗後も <physicalName> 行が残存している。');
}
// cleanup
});
I. affectedRowsCheck.onViolation="throw" → 0 行誘起 → 5xx テスト
ProcessFlow:
step.affectedRowsCheck.operator="=", expected=1, onViolation="throw"
生成テスト:
/**
* Spec: ProcessFlow <flowId> <stepId>
* affectedRowsCheck: expected=1, onViolation=throw, errorCode=<errorCode>
*/
it('#N affectedRowsCheck: 0 行 UPDATE → 5xx', async () => {
// 存在しない ID を指定して affected rows = 0 を誘起
const res = await request(...).patch('/<path>/999999').send({...});
expect(res.status).toBeGreaterThanOrEqual(500);
});
J. step.runIf → true / false 両分岐のテスト (D-5)
ProcessFlow:
step.runIf="@tag.id == null" (例: step-05-01)
生成テスト (true 分岐: runIf が truthy のケース):
/**
* Spec: ProcessFlow <flowId> <stepId> runIf="@tag.id == null"
* runIf=true: tag.id が未指定 → name で既存タグを検索
*/
it('#N runIf=true: <条件説明> のケース', async () => { ... });
生成テスト (false 分岐: runIf が falsy のケース):
/**
* Spec: ProcessFlow <flowId> <stepId> runIf="@tag.id == null"
* runIf=false: tag.id が指定済み → name 検索をスキップ
*/
it('#N runIf=false: <条件説明> のケース', async () => { ... });
K. step.kind=compute → DB 値で結果を確認 (Spike L-2)
ProcessFlow:
step.kind="compute", expression="@inputs.status == 'published' ? new Date().toISOString() : null"
outputBinding.name="publishedAt"
生成テスト (DB SELECT で null/non-null を assert):
/**
* Spec: ProcessFlow <flowId> <computeStepId>
* kind=compute, outputBinding.name="publishedAt"
* expression="@inputs.status == 'published' ? new Date().toISOString() : null"
*/
// status="draft" → publishedAt=null
it('#N compute: status="draft" → <computedField> が null', async () => {
const res = await request(...).post(...).send({ ..., status: 'draft' });
expect(res.status).toBe(201);
const row = await prisma.<ModelName>.findUnique({ where: { id: res.body.<outputId> } });
expect(row!.<computedField>).toBeNull();
});
// status="published" → publishedAt が non-null かつ現在時刻付近
it('#N compute: status="published" → <computedField> が non-null', async () => {
const before = new Date();
const res = await request(...).post(...).send({ ..., status: 'published' });
expect(res.status).toBe(201);
const after = new Date();
const row = await prisma.<ModelName>.findUnique({ where: { id: res.body.<outputId> } });
expect(row!.<computedField>).not.toBeNull();
const val = row!.<computedField> as Date;
expect(val.getTime()).toBeGreaterThanOrEqual(before.getTime() - 1000);
expect(val.getTime()).toBeLessThanOrEqual(after.getTime() + 1000);
});
P2 追加変換ルール (DB 副作用 + TX 検証) — #871
3-P2-1. lineage.writes[].tableId → 行数増減 SELECT アサーション (D-2)
step.lineage.writes を持つ step は全て「実行前後で行数が変化するか」を SELECT で確認するテストを生成する。
ProcessFlow:
step.kind="dbAccess", operation="INSERT" | "UPDATE" | "DELETE"
lineage.writes[].tableId="<tableId>"
(D-2 で tableId → physicalName 解決済)
生成テスト (INSERT):
/**
* Spec: ProcessFlow <flowId> <stepId>
* kind=dbAccess, operation=INSERT
* lineage.writes=[<physicalName>]
*/
it('#N lineage: INSERT 後に <physicalName> の行数が 1 増加する', async () => {
const countBefore = await prisma.<ModelName>.count({ where: { <filterCondition> } });
const res = await request(...).post(...).send({ <payload> });
expect(res.status).toBe(201);
const countAfter = await prisma.<ModelName>.count({ where: { <filterCondition> } });
expect(countAfter).toBe(countBefore + 1);
});
生成テスト (DELETE):
it('#N lineage: DELETE 後に <physicalName> の行数が 1 減少する', async () => {
const countBefore = await prisma.<ModelName>.count({ where: { <filterCondition> } });
const res = await request(...).delete('/<path>/<id>').set('Authorization', `Bearer ${accessToken}`);
expect(res.status).toBe(200);
const countAfter = await prisma.<ModelName>.count({ where: { <filterCondition> } });
expect(countAfter).toBe(countBefore - 1);
});
tableId → physicalName 解決が失敗した場合:
- physicalName が取得できなかった tableId は「解決不能: tableId=」として申し送りに記載
- テスト生成は一部スキップして続行する (全件未生成にはしない)
3-P2-2. step.kind=loop + collectionSource → 入力配列長 = 挿入行数 (Spike L-3)
loop ステップは、入力配列の要素数が内側 dbAccess INSERT の実行回数と一致することを確認する。
ProcessFlow:
step.kind="loop", loopKind="collection", collectionSource="@inputs.<field>"
collectionItemName="<itemName>"
inner step: kind="dbAccess", operation="INSERT"
inner step lineage.writes[].tableId → <childPhysicalName>
生成テスト:
/**
* Spec: ProcessFlow <flowId> <loopStepId>
* kind=loop, loopKind=collection, collectionSource=@inputs.<field>
* <innerStepId>: kind=dbAccess, operation=INSERT → <childPhysicalName>
*
* 入力配列長 N = <childPhysicalName> 挿入行数 N (Spike L-3)
*/
it('#N loop-insert: <field> N 件 → <childPhysicalName> に N 行 + 親 ID 紐付け', async () => {
const N = 2; // 任意、2 で十分
const res = await request(...).post(...).send({
...,
<field>: Array.from({ length: N }, (_, i) => ({ <childItemField>: `value-${i}` })),
});
expect(res.status).toBe(201);
const parentId = res.body.<outputIdField>;
const childRows = await prisma.<childModelPrisma>.findMany({ where: { <parentFKField>: parentId } });
expect(childRows).toHaveLength(N);
childRows.forEach(row => expect(row.<parentFKField>).toBe(parentId));
});
N = 0 のケース (空配列) も生成する:
it('#N loop-insert: <field> 0 件 → <childPhysicalName> に 0 行', async () => {
const res = await request(...).post(...).send({ ..., <field>: [] });
expect(res.status).toBe(201);
const parentId = res.body.<outputIdField>;
const childRows = await prisma.<childModelPrisma>.findMany({ where: { <parentFKField>: parentId } });
expect(childRows).toHaveLength(0);
});
3-P2-3. txBoundary.role=begin..end → 故意失敗で全 rollback テスト (D-3)
同一 txId を持つ role=begin 〜 role=end の step 群に対し、TX の途中で失敗を誘起し、
begin 時点で書き込まれた行が全て rollback されることを確認するテストを生成する。
ProcessFlow:
step[N].txBoundary.role="begin", txId="<txId>"
step[M].txBoundary.role="end", txId="<txId>" (N < M)
(tx 内に dbAccess INSERT が存在する)
生成テスト:
/**
* Spec: ProcessFlow <flowId> <beginStepId>
* txBoundary.role="begin", txId="<txId>"
* <endStepId>: txBoundary.role="end"
*
* TX rollback 検証 (D-3):
* 故意に <故意失敗パターン> を起こして TX 全体が rollback されるか確認。
*
* 【spec ↔ impl 乖離検出器として機能】
* $transaction が実装されていない場合、begin 側の INSERT が rollback されずに残る。
* この場合テストは「rollback なし」として文書化し、spec ↔ impl 乖離として申し送る。
*/
it('#N TX: <故意失敗説明> → <expectedStatus> + TX rollback 確認', async () => {
// 故意失敗ペイロード (<故意失敗パターン> に従う)
const res = await request(...).post(...).send({ <failPayload> });
// エラー status を確認
expect(res.status).toBe(<expectedHttpStatus>);
// TX rollback 確認: begin 側テーブルに行が残っていないこと
const remainingRows = await prisma.<beginTableModel>.findMany({
where: { <uniqueIdentifierFilter> },
});
if (remainingRows.length > 0) {
// 【TX-1 文書化】 $transaction 未実装を記録
console.warn(
`[TX-1 文書化] TX が未実装: <失敗ステップ>失敗後も <beginTablePhysical> 行が残存。` +
`残存 ID: ${remainingRows.map((r) => r.id).join(', ')}`,
);
}
// このテストは「エラーが返る」ことの確認で pass とし、TX 未実装は申し送り
// cleanup
for (const row of remainingRows) {
createdIds['<beginTablePhysical>'] = [...(createdIds['<beginTablePhysical>'] ?? []), Number(row.id)];
}
});
故意失敗パターンの選択ロジック
TX 内の step 構造を解析して、以下の優先順で「故意失敗パターン」を決定する:
| 優先度 | パターン | 選択条件 | ペイロード例 |
|---|
| 1 | UNIQUE 制約違反 | TX 内の INSERT テーブルに UNIQUE/@@id 制約がある | 同じ ID を 2 回送る |
| 2 | NOT NULL 違反 | TX 内の dbAccess step に必須 JOIN がある | null を渡す |
| 3 | FK 違反 | TX 内に FK 参照がある | 存在しない ID を参照 |
| 4 | value too long | TX 内の INSERT に varchar 上限がある | maxLength+1 文字 |
| 5 | affectedRowsCheck.onViolation=throw | TX 内 step に該当設定あり | DB に存在しない ID |
| 6 | (フォールバック) | 上記が特定できない | mock 経由の失敗を申し送り |
ProcessFlow の lineage.writes / tableId / affectedRowsCheck / sql から解析する。
spec に UNIQUE_VIOLATION errorCode があれば UNIQUE 制約パターンを優先する。
運用ノート: $transaction 未実装 → テストは fail する、それが正しい動作
D-3 の設計意図: TX rollback テストは、$transaction が実装されていない実装バグを検出する
spec ↔ impl 乖離検出器 として意図的に設計されている。
$transaction 実装あり → TX rollback テスト pass (spec と実装が一致)
$transaction 未実装 → TX rollback テスト fail (spec が定義する TX 境界が実装されていない)
テスト fail = バグ検出成功。テスト pass のために実装を改ざんするのは禁止。
fail の場合は「実装側の修正が必要: txBoundary step-N begin ~ step-M end を $transaction でラップする」を
申し送りとして最終レポートに記載する。
3-P2-4. affectedRowsCheck.onViolation=throw → 0 行誘起シナリオ → 5xx
ProcessFlow:
step.affectedRowsCheck.operator="=", expected=N, onViolation="throw"
errorCode="<errorCode>" (context.catalogs.errors.<errorCode>.httpStatus で status 解決)
生成テスト:
/**
* Spec: ProcessFlow <flowId> <stepId>
* affectedRowsCheck: operator="=", expected=<N>, onViolation="throw"
* errorCode="<errorCode>" → httpStatus=<httpStatus>
*
* 0 行誘起 (affected rows が expected を満たさない) → <httpStatus>
*/
it('#N affectedRowsCheck(throw): 0 行誘起 → <httpStatus>', async () => {
// 「存在しない ID」「DB に入らない条件」等で affected rows = 0 を誘起する
// 誘起方法は operation 種別で決定:
// UPDATE/DELETE → 存在しない ID を指定 (例: id=999999999)
// INSERT → DB 制約違反 (UNIQUE 等) が起きるペイロード
const res = await request(app.getHttpServer())
.<method>('<path>/<nonexistentId>')
.set('Authorization', `Bearer ${accessToken}`)
.send({ <validPayload> });
expect(res.status).toBe(<httpStatus>); // errorCode → httpStatus
});
0 行を誘起する方法の判断ロジック:
operation=UPDATE or DELETE → path param / query に存在しない ID (999999999)
operation=INSERT → UNIQUE 違反ペイロード (既存レコードと重複)
- 判断できない場合 → 「0 行誘起方法が未決: 手動でペイロードを調整してください」コメント付きで生成
3-P2-5. affectedRowsCheck.onViolation=log → 0 行/多行を許容、log 出力確認 (optional)
onViolation=log のステップは「違反しても続行する」設計のため、テスト生成は任意 (optional)。
ただし skill ドキュメントとして挙動を明記し、生成する場合は以下のテンプレを使う。
ProcessFlow:
step.affectedRowsCheck.onViolation="log", errorCode="<errorCode>"
生成テスト (optional):
/**
* Spec: ProcessFlow <flowId> <stepId>
* affectedRowsCheck: onViolation="log", errorCode="<errorCode>"
* (onViolation=log なので 0 行でもエラーにならない設計)
*
* 検証方針:
* 1. 0 行になる条件でリクエスト → 200/201 が返る (エラーにならないこと)
* 2. 可能であれば console.warn / logger.warn が呼ばれることを確認
* (jest の spyOn(console, 'warn') または NestJS Logger spy)
*/
// シナリオ A: 0 行でも 201 が返る (エラーにならない)
it('#N affectedRowsCheck(log): 0 行でも 201 が返る', async () => {
// 既存レコードと重複するペイロード (UNIQUE 違反 → INSERT 0 行)
const res = await request(...).post(...).send({ <duplicatePayload> });
// onViolation=log なのでエラーにならずに続行
expect(res.status).toBeLessThan(500);
});
// シナリオ B: 複数行の場合も許容
it('#N affectedRowsCheck(log): 複数行でも 201 が返る (想定範囲外だが log のみ)', async () => {
const res = await request(...).post(...).send({ <multiRowPayload> });
expect(res.status).toBeLessThan(500);
});
3-P2-6. step.kind=compute → DB 値の null/non-null アサーション (Spike L-2 の完全版)
K ルールは compute 全般のテンプレ、本ルールは「DB に永続化される compute 結果」に特化した
詳細版。特に NEW Date() 系の時刻フィールドに対する安全な assertion を定義する。
ProcessFlow:
step.kind="compute"
outputBinding.name="<computedVar>"
expression="<condition> ? <valueA> : <valueB>"
(次の dbAccess INSERT/UPDATE の sql で @<computedVar> が参照される)
生成テスト (null ケース):
/**
* Spec: ProcessFlow <flowId> <computeStepId>
* kind=compute, outputBinding.name="<computedVar>"
* expression="<condition> ? <valueA> : null"
* condition が false → DB の <dbColumn> が null
*/
it('#N compute(<computedVar>): <condition>=false → <dbColumn> が null', async () => {
const res = await request(...).post(...).send({ ..., <conditionFalseField>: '<conditionFalseValue>' });
expect(res.status).toBe(201);
const row = await prisma.<ModelName>.findUnique({ where: { id: res.body.<outputId> } });
expect(row).not.toBeNull();
expect(row!.<dbColumn>).toBeNull();
});
生成テスト (non-null / 時刻ケース):
/**
* Spec: ProcessFlow <flowId> <computeStepId>
* kind=compute, outputBinding.name="<computedVar>"
* expression="<condition> ? new Date().toISOString() : null"
* condition が true → DB の <dbColumn> が現在時刻付近の Date
*/
it('#N compute(<computedVar>): <condition>=true → <dbColumn> が non-null (現在時刻付近)', async () => {
const before = new Date();
const res = await request(...).post(...).send({ ..., <conditionTrueField>: '<conditionTrueValue>' });
expect(res.status).toBe(201);
const after = new Date();
const row = await prisma.<ModelName>.findUnique({ where: { id: res.body.<outputId> } });
expect(row).not.toBeNull();
expect(row!.<dbColumn>).not.toBeNull();
// 時刻範囲確認 (1 秒の clock ずれを許容)
const val = row!.<dbColumn> as Date;
expect(val.getTime()).toBeGreaterThanOrEqual(before.getTime() - 1000);
expect(val.getTime()).toBeLessThanOrEqual(after.getTime() + 1000);
});
非時刻 compute (例: 数値演算、文字列変換) の場合:
// expression の結果を DB で直接確認 (時刻範囲チェックなし)
expect(row!.<dbColumn>).toBe(<expectedComputedValue>);
3-4. テストケース番号付け規約
#1 Happy path (全フィールド指定の正常系 201)
#2〜 validation エラー系 (required → maxLength/minLength → enum → pattern → range の順)
#N auth エラー (JWT なし → 401)
#N+ DB 副作用確認 (INSERT → loop → TX)
#N+ runIf 分岐
#N+ compute 結果確認 (publishedAt 等)
#N+ その他特殊ケース
Step 4: テンプレート詳細参照
テスト生成の品質を担保するため以下のテンプレートを Read で参照すること:
.claude/skills/generate-tests/templates/
backend/typescript-nestjs/
E2E_SPEC.md — テストファイル全体の構造規約 + コードテンプレート
ゴールデン出力も参照:
.claude/skills/generate-tests/golden-examples/
posts-create-e2e/
posts.create.e2e-spec.ts — 投稿作成フロー (0671b051) のゴールデン (抽象化済)
jest-e2e.json — jest 設定テンプレート
README.md — golden の使い方、人手 section 保護方針
screens-list-component/
posts-list.component.test.tsx — 投稿一覧 Screen のゴールデン
vitest.config.ts — vitest 設定
README.md — PLACEHOLDER 解決表、mental invocation 結果
diary-post-lifecycle-e2e/ ← P4 (Playwright E2E) ゴールデン
post-lifecycle.e2e.spec.ts — 投稿ライフサイクルシナリオ (9 steps, 6 tests)
playwright.config.ts — SQLite workers=1 設定
helpers/auth.ts — loginAs() / loginViaUI() helper
helpers/db.ts — seedTestData() / truncateTestData() helper
README.md — PLACEHOLDER 解決表、遷移導出フロー、再 invocation 例
Step 5: spec トレースコメント規約 (D-1 / D-4)
section anchor (D-4)
ファイル冒頭の doc コメントに以下の anchor を必ず含める:
// ===HARMONY_GENERATED_SECTION_START flowId=<flowId> actionId=<actionId>===
// (自動生成セクション: skill 再実行時に flowId / actionId が一致するセクションを overwrite)
// ===HARMONY_GENERATED_SECTION_END===
再生成時のルール:
- 既存ファイルに anchor が存在する場合、
HARMONY_GENERATED_SECTION_START から HARMONY_GENERATED_SECTION_END の間のみ overwrite
- anchor の外 (人手追記 assertion 等) は保護
it() コメント規約 (D-1)
各 it() の直前に JSDoc コメントとして spec の参照先を明記:
it('#N <test description>', async () => { ... });
Step 6: tableId → Prisma model 名解決 (D-2)
Step 1-4 で構築した tableId → physicalName map を使い、Prisma model 名を導出する。
snake_case → PascalCase 変換ルール:
"posts" → Post
"post_tags" → PostTag
"photos" → Photo
"users" → User
"tags" → Tag
Prisma client 呼び出し形式:
prisma.post.findUnique(...)
prisma.postTag.findMany(...)
prisma.photo.findMany(...)
Prisma client プロパティ名は camelCase (PascalCase の先頭を小文字化):
Post → prisma.post
PostTag → prisma.postTag
Step 7: 出力検証
生成したテストファイルを jest で実行して pass を確認する。
7-1. jest 実行コマンド
cd <project_root>/apps/api && npx jest --config test/jest-e2e.json --testPathPattern="<generated-filename>" --runInBand 2>&1 | tail -30
7-2. pass 確認
- 全テストケース pass → 完了
- fail の場合:
- エラーメッセージを確認し原因を特定
- 実装 (service / DTO) の不備か、テストの記述誤りかを判断
- テスト記述誤りならテストを修正して再実行
- 実装の不備なら「実装側の修正が必要: <詳細>」として申し送り
7-3. 実行不可の場合
jest 実行環境が整っていない場合 (DB 未起動等) はスキップし、最終レポートに以下を記載:
smoke 検証: スキップ (<理由>)
推奨コマンド: cd <project>/apps/api && npx jest --config test/jest-e2e.json --runInBand
制約 (必守)
schemas/*.json を変更しない (schema ガバナンス #511)
data/ ディレクトリを変更しない
examples/ 配下の既存ファイルを変更しない
frontend/ / backend/ のソースコードを変更しない
- 生成テストは
.tmp/generated-tests/ または指定した出力先に置く (プロジェクトルート直置き禁止)
- diary
apps/api/ は test ファイル追加 OK、本体実装変更不可
- CI に組み込まない (本スキルは AI 対話駆動、CI 自動化は別 ISSUE)
Playwright E2E トラブルシューティング (#980-A 由来の汎用知見)
各プロダクトでも Playwright E2E を書く際に類似の問題が起きうる。発生時のパターン化された解法:
症状 1: locator.click() が timeout (intercepts pointer events)
TimeoutError: locator.click: Timeout 30000ms exceeded.
- <div class="parent-class">…</div> intercepts pointer events
真因: 親要素 (badge container, label wrapper 等) が document.elementFromPoint で先に
返されるため Playwright は「button が前面でない」と判定して click を待ち続ける。
button 内の <span> (icon + text) が hit test を捕捉している場合や、親 div が
button と同サイズで stacking 上 button を覆ってしまう場合に発生する。
対処順:
- production CSS fix (推奨): button 内の icon / label
<span> に
pointer-events: none を CSS で当てる。button 自身は capture できるよう残す。
force: true を試す: locator.click({ force: true }) で actionability check を
bypass。ただし Playwright は依然座標経由で click するため、親 div が前面の場合は
親の onClick が呼ばれて button onClick に届かない (Designer の .esd-root がこれ)。
page.evaluate で直接 dispatch (最終手段): actionability check を完全 bypass。
await page.evaluate((sel) => (document.querySelector(sel) as HTMLButtonElement | null)?.click(), `[data-testid="${id}"]`);
React の onClick は確実に発火する。helper 関数化して spec 全体で共有するのが望ましい
(Harmony の frontend/e2e/helpers/editSessionDropdown.ts 参照)。
症状 2: Modal backdrop が click を遮蔽する
TimeoutError: locator.click: Timeout 30000ms exceeded.
- <div class="modal-backdrop"> intercepts pointer events
真因: ResumeOrDiscardDialog 等の modal が前面に出て backdrop が click を遮る。
test setup 時に modal を dismiss し忘れた / 別タブから出た modal が想定外に閲覧 tab
にも出ている。
対処:
- 明示 dismiss loop を spec setup に入れる:
for (let i = 0; i < 3; i++) {
if (await page.locator('.modal-backdrop').isVisible().catch(() => false)) {
await page.evaluate(() => (document.querySelector('[data-testid="modal-cancel"]') as HTMLButtonElement | null)?.click());
await page.locator('.modal-backdrop').waitFor({ state: 'hidden', timeout: 5000 }).catch(() => undefined);
} else { break; }
}
- production fix: modal を出すべきでないシーンで出ているなら filter ロジック修正
(例: 「自分の draft」だけ出すべき modal が「全 active session」で出ていた → 自分が
participant の session のみ filter する)。Harmony の ResumeOrDiscardDialog は
participants[mySessionId] filter で多重 tab 検証時に modal が出ない設計。
症状 3: 多重 browser context テストが timeout
真因: 2 ブラウザ context を使う test (collab / take-over 系) は 1 つのページでは
出ない問題が複合する。よくある落とし穴:
- 両 context で同じ data seed を実行する必要がある (片方だけだと UI 状態が異なる)
- broadcast 反映に時間がかかるため
await waitForTimeout(1500) 程度入れる
window.confirm override は 同期 evaluate 内で click と一緒に行う
(page.on("dialog", ...) は async race で取りこぼす)
finally で context.close() に任せる (明示 discard は state race で hang する)
await page.evaluate(() => {
window.confirm = () => true;
(document.querySelector('[data-testid="action-btn"]') as HTMLButtonElement | null)?.click();
});
症状 4: WS 切断時に presence / heartbeat / SessionBadge が残る
真因: backend の ws.on("close") で session 関連の cleanup を呼んでいない。
対処: WS 切断ハンドラで該当 session の presence / lock / draft 等を即時 cleanup +
broadcast する。Harmony 側は presenceUnregisterAllForSession(clientId) を
wsBridge.ts:close で実行している (#980-A の追加 fix)。各プロダクトが独自 WS を
持つ場合は同パターンを参照。
最終レポート
ProcessFlow モード (P1/P2)
## /generate-tests 完了: <processFlow.meta.name>
### 入力
- ProcessFlow ID: <uuid>
- ProcessFlow 名: <name>
- techStack: <backend.language>/<backend.framework>/<database.type>
### 生成ファイル
- `<出力先>/<flowName>.e2e-spec.ts` (N 行, M テストケース)
- `<出力先>/jest-e2e.json` (jest 設定)
### 生成テストケース一覧
| # | description | spec anchor |
|---|---|---|
| 1 | happy path: 全フィールド指定 → 201 | act-001 responses[id="201-created"] |
| 2 | validation: title 欠落 → 400 | act-001 step:step-01 required |
| ...
### smoke 検証
- jest 実行: ✓ N/N pass / スキップ (<理由>) / ❌ (<エラー詳細>)
### 申し送り (P2 以降)
- TX-1: <TX 実装状況>
- その他
Screen モード (P3)
## /generate-tests 完了: <Screen.name> (component test)
### 入力
- Screen ID: <uuid>
- Screen 名: <name>
- Screen kind: <kind>
- techStack: <frontend.library>/<frontend.framework>
### 生成ファイル
- `<出力先>/<screenName>.component.test.tsx` (N 行, M テストケース)
- `<出力先>/vitest.config.ts` (vitest 設定)
### items index
| item.id | direction | type | valueFrom | msw mock URL |
|---|---|---|---|---|
| searchQuery | input | string | なし | — |
| posts | output | array | flowVariable (e6f7a8b9-...) | GET /api/posts/search |
| totalCount | output | integer | flowVariable (e6f7a8b9-...) | GET /api/posts/search (同上) |
| ...
### 生成テストケース一覧
| # | description | spec anchor | Section |
|---|---|---|---|
| 1 | searchQuery が DOM に存在 | Screen <id> item:searchQuery | render |
| ... | ... | ... | ... |
| N (skip) | events テスト (#864 補完待ち) | Screen <id> events[] 空配列 | events |
### smoke 検証
- vitest 実行: ✓ N/N pass, M skip / スキップ (<理由>) / ❌ (<エラー詳細>)
### 申し送り
- EVENTS-1: events[] が空。#864 (events[] 補完) 完了後に再生成すること。
- COMPONENT-1: 実際のコンポーネントパスは PLACEHOLDER。<推測パス> に配置想定。
- その他
Step P3-1: Screen component test 生成 (P3)
Step 1-2 で Screen ID にルーティングされた場合、以下の手順で frontend component test を生成する。
テンプレート規約: .claude/skills/generate-tests/templates/frontend/react-tailwind-next/COMPONENT_SPEC.md を Read して参照すること。
ゴールデン出力も参照: .claude/skills/generate-tests/golden-examples/screens-list-component/
P3-1. Screen JSON を Read で取得
- active workspace:
<workspace>/screens/<id>.json
- フォールバック:
examples/<project-id>/harmony/screens/<id>.json
取得する情報:
Screen:
id, name, kind, path, auth, maturity
items[]:
id, label, type, direction
valueFrom.kind, valueFrom.processFlowId, valueFrom.variableName (output の場合)
options[] (enum の場合)
defaultValue, required, placeholder
events[]:
id, trigger.kind, trigger.itemId, handlerFlowId, description
P3-2. processFlowId → httpRoute index の構築
items[direction=output, valueFrom.kind=flowVariable] の processFlowId を収集し、各フローの httpRoute を解決する。
processFlowId 解決手順:
1. output items から valueFrom.processFlowId を収集 (重複除去)
2. 各 processFlowId について process-flows/<id>.json を Read
3. actions[0].httpRoute.method + actions[0].httpRoute.path を取得
4. map 化: { "<processFlowId>": { method: "GET", path: "/api/posts/search" } }
解決失敗 (httpRoute が空 / JSON が見つからない):
→ PLACEHOLDER "<API_BASE>/PLACEHOLDER_PATH" でテンプレ提示
→ README.md の PLACEHOLDER 解決表に未解決として記録
P3-3. events[].handlerFlowId → httpRoute 解決
events 配列が空でない場合、各 event の handlerFlowId についても httpRoute を解決する。
handlerFlowId 解決手順:
processFlowId 解決と同様 (Step P3-2 の map を再利用)
events 配列が空の場合:
→ Section 4 は skip テスト + 乖離検出ノートのみ生成する (P3 受け入れ基準 d)
P3-4. Screen → component test 変換ルール (4〜6 件)
各ルールで生成する it() には必ず // Spec: Screen <screenId> item:<item.id> コメントを付与すること (D-1)。
SC-A. 全 items → render テスト (DOM 存在確認)
Screen:
items[].id="<itemId>", items[].direction=any
生成テスト (全 items):
/**
* Spec: Screen <screenId> item:<itemId>
* direction=<direction>, type=<type>
*/
it('#N <label> (data-testid="<itemId>") が表示される', () => {
renderWithProviders(<COMPONENT_NAME />);
expect(screen.getByTestId('<itemId>')).toBeInTheDocument();
});
実装前提: コンポーネント側は各 item に data-testid={item.id} を付与する。
data-testid は data-item-id と等価な意味を持つが、@testing-library/react では getByTestId が data-testid をデフォルトで解釈する。
SC-B. items[direction=input] → type 別 state 更新テスト
type に応じて以下のスニペットを選択する:
| items[].type | DOM element | テスト手法 |
|---|
| string | <input type="text"> | userEvent.type() |
| integer / number | <input type="number"> | fireEvent.change() |
| enum (options あり) | <select> | userEvent.selectOptions() |
| array (複数選択) | checkbox 群 / <select multiple> | userEvent.click() / userEvent.selectOptions() |
| boolean | <input type="checkbox"> / toggle | userEvent.click() |
| date | <input type="date"> | fireEvent.change() |
| text (長文) | <textarea> | userEvent.type() |
各 input item について 1〜2 件の it() を生成する (通常の値変更 + defaultValue の初期値確認)。
SC-C. items[direction=output, valueFrom.kind=flowVariable] → msw mock → 表示 assert
Screen:
items[].direction="output"
items[].valueFrom.kind="flowVariable"
items[].valueFrom.processFlowId="<processFlowId>"
items[].valueFrom.variableName="<variableName>"
(Step P3-2 で解決済み: processFlowId → httpRoute)
生成テスト:
/**
* Spec: Screen <screenId> item:<outputItemId>
* direction=output, valueFrom.kind=flowVariable
* processFlowId=<processFlowId>, variableName=<variableName>
*
* msw で <HTTP_METHOD> <HTTP_PATH> をインターセプト → mock レスポンス → 表示 assert
*/
it('#N <label> が API レスポンスから表示される', async () => {
renderWithProviders(<COMPONENT_NAME />);
await waitFor(() => {
// PLACEHOLDER: mock レスポンスのフィールドが表示されること
expect(screen.getByText('<mock_response_field_value>')).toBeInTheDocument();
});
});
同一 processFlowId を参照する複数の output items は、msw handler 1 個で対応する (重複追加しない)。
SC-D. items[direction=output, valueFrom なし] → DOM 存在確認のみ
Screen:
items[].direction="output"
(valueFrom なし、または valueFrom.kind != "flowVariable")
生成テスト:
/**
* Spec: Screen <screenId> item:<outputItemId>
* direction=output (valueFrom なし: コンポーネント内部 state or props)
*/
it('#N <label> (data-testid="<outputItemId>") が DOM に存在する', () => {
renderWithProviders(<COMPONENT_NAME />);
expect(screen.getByTestId('<outputItemId>')).toBeInTheDocument();
});
SC-E. events[].handlerFlowId → click → fetch 発火テスト
events 配列が空でない場合のみ生成する。
Screen:
events[].trigger.kind="click"
events[].trigger.itemId="<triggerItemId>"
events[].handlerFlowId="<handlerFlowId>"
(Step P3-3 で解決済み: handlerFlowId → httpRoute)
生成テスト:
/**
* Spec: Screen <screenId> events[<N>]
* trigger.kind=click, trigger.itemId=<triggerItemId>
* handlerFlowId=<handlerFlowId> → <HTTP_METHOD> <HTTP_PATH>
*/
it('#N <イベント説明> ボタンをクリックすると <HTTP_METHOD> <HTTP_PATH> が呼ばれる', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
vi.stubGlobal('fetch', fetchMock);
const user = userEvent.setup();
renderWithProviders(<COMPONENT_NAME />);
const button = screen.getByTestId('<triggerItemId>');
await user.click(button);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('<HTTP_PATH>'),
expect.objectContaining({ method: '<HTTP_METHOD>' }),
);
});
vi.unstubAllGlobals();
});
SC-F. events[] 空配列 → skip テスト + 乖離検出ノート
Screen:
events[] = [] (空配列)
生成テスト:
/**
* NOTICE: Screen <screenId> の events[] は現在空配列です。
* events[] 補完 (#864) が完了したら再生成してください:
* /generate-tests <screenId>
*
* 【spec ↔ impl 乖離検出ノート】
* events 未定義の状態では、コンポーネントのボタン/アクションが
* 特定の ProcessFlow を呼ぶことを spec で追跡できない。
* 補完後に Section 4 を自動更新する。
*/
it.skip('#N events テストは events[] 補完 (#864) 完了後に生成予定', () => {});
P3-5. テストケース番号付け規約 (Screen)
#1〜#N render: 全 items の DOM 存在確認 (items[] の順序通り)
#N+1〜 input: direction=input の type 別 state 更新
#M+1〜 output: direction=output の API 反映確認
#L+1〜 events: click → fetch 発火 (または skip)
P3-6. テストファイルの構造
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { renderWithProviders } from '@/test/renderWithProviders';
const handlers = [];
const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
afterAll(() => server.close());
beforeEach(() => server.resetHandlers());
describe('<Screen.name> コンポーネント', () => {
describe('Section 1: render', () => { });
describe('Section 2: input', () => { });
describe('Section 3: output', () => { });
describe('Section 4: events', () => { });
});
P3-7. 出力ファイル
<出力先>/
<screenName>.component.test.tsx (本体テストファイル)
vitest.config.ts (最小 vitest 設定)
P3-8. smoke 検証 (vitest)
cd apps/web && npx vitest run --reporter=verbose <generated-filename>
実行環境が整っていない場合はスキップし、最終レポートに以下を記載:
smoke 検証: スキップ (vitest 未設定 / npm install 未実施)
推奨コマンド: cd apps/web && npx vitest <filename>.component.test
Step P4: E2E シナリオテスト生成 (Playwright, multi-screen) — #873
Step 0 で --scenario / --scenario-name フラグが検出された場合、または
フラグなし UUID が ProcessFlow / Screen どちらにもマッチしない場合にこのパスに入る。
D-6 確定: E2E は常に Playwright (vitest / jest ではない)
D-7 確定: SQLite 環境では --workers=1 必須 (playwright.config.ts で設定)
テンプレート規約: .claude/skills/generate-tests/templates/e2e/playwright/SCENARIO.md を Read して参照すること。
ゴールデン出力も参照:
.claude/skills/generate-tests/golden-examples/diary-post-lifecycle-e2e/
post-lifecycle.e2e.spec.ts — 投稿ライフサイクルシナリオ (全 6 テスト, 9 steps)
playwright.config.ts — SQLite workers=1 設定
helpers/auth.ts — loginAs() / loginViaUI() helper
helpers/db.ts — seedTestData() / truncateTestData() helper
README.md — PLACEHOLDER 解決表 + 遷移導出フロー + 再 invocation 例
P4-0. 引数解析 (--scenario フラグ)
$ARGUMENTS に以下のフラグが含まれる場合、P4 E2E シナリオ生成へルーティングする:
フラグ形式:
--scenario <screenId-from> <screenId-to>
--scenario-name "<name>" <screenId-1> ... <screenId-N>
ルーティング判定:
--scenario あり → P4 へ (2 画面間シナリオ)
--scenario-name あり → P4 へ (N 画面シナリオ、名前付き)
フラグなし + UUID → 既存の ProcessFlow / Screen 判定 (P1/P2/P3) へ
シナリオ ID の生成: --scenario-name の値を kebab-case 化 (例: "投稿ライフサイクル" → "post-lifecycle")。
フラグなしの場合は scenario-<8桁UUID> 形式で自動生成。
P4-1. harmony.json 読込 + screen path index 構築
Step 1-1 と同様に active workspace の harmony.json を Read する。
以下の情報を取得:
P4-2. 画面遷移導出 (3段 fallback)
引数の screenId 群から遷移チェーンを導出する。
遷移導出アルゴリズム:
★ 1次ソース: screenTransitions[]
if entities.screenTransitions.length > 0:
指定 screenId に関連する screenTransition を収集し、
from → trigger → to のチェーンを構築する。
anchor: // Spec: Screen <fromId> via screenTransition <transitionId>
★ 2次ソース: screen.events[].handlerFlowId → ProcessFlow → next screen
elif 各 screen の events[] に handlerFlowId あり:
handlerFlowId → ProcessFlow JSON を Read
ProcessFlow 完了後の遷移先 (nextScreen / httpRoute.redirectTo) から導出。
anchor: // Spec: Scenario <id> step:<N> via events[].handlerFlowId
★ 3次ソース: path-based 推測 (kind 慣習)
else (screenTransitions=[] かつ events 未補完):
screen.kind の慣習から遷移先を推測:
"login" → "list" (ログイン成功 → トップの一覧画面)
"list" → "form" (一覧の "新規作成" → フォーム)
"list" → "detail" (一覧の item クリック → 詳細)
"detail" → "form" (詳細の "編集" → フォーム)
"detail" → "list" (詳細の "削除" → 一覧)
"form" → "detail" (作成/更新 → 詳細)
"form" → "list" (キャンセル / 削除後 → 一覧)
anchor 必須:
// TODO: screenTransitions 補完待ち
// ⚠️ 推測で生成: screenTransitions[] または events[] を補完後に再生成すること
P4-3. ProcessFlow → httpRoute index 構築
各 screen に関連する ProcessFlow の httpRoute を収集する (API verify 用)。
ProcessFlow index 構築手順:
1. 引数の screenId に関連する ProcessFlow を収集
- screenTransitions の trigger.processFlowId
- screen.events[].handlerFlowId
- meta.name から推測 (例: "投稿作成" → 投稿フォームの submit)
2. 各 ProcessFlow JSON を Read して httpRoute を取得
3. map 化: { "<processFlowId>": { method, path } }
解決失敗 (ProcessFlow JSON が見つからない / httpRoute が空):
→ PLACEHOLDER "<HTTP_METHOD> <PLACEHOLDER_PATH>" で生成
→ README の PLACEHOLDER 解決表に記録
P4-4. テストファイル構造
import { test, expect, type Page, type BrowserContext } from '@playwright/test';
import { loginAs } from './helpers/auth';
import { seedTestData, truncateTestData } from './helpers/db';
test.describe('<シナリオ名> E2E', () => {
let context: BrowserContext;
let page: Page;
});
P4-5. step anchor 付与規約 (D-1)
各 test() 内の各操作に以下のコメントを付与する:
P4-6. auth-flow 生成ルール
techStack.auth.method に応じて auth helper を選択する:
auth.method = "jwt":
→ loginAs(page, { username, password }) — API 経由 (helpers/auth.ts)
→ PLACEHOLDER: /api/auth/login エンドポイントを確認すること
auth.method = "session":
→ loginViaUI(page, { username, password }) — UI 経由 (helpers/auth.ts)
auth.method = "none":
→ auth step は生成しない
screen.auth が "required" の screen が含まれる場合、beforeEach で loginAs() を呼ぶ。
P4-7. DOM assertion 生成ルール
screen の items[] から DOM assertion を生成する:
items[direction=output]:
→ await expect(page.getByTestId('<itemId>')).toBeVisible()
→ valueFrom.kind=flowVariable かつ API が判明している場合:
→ page.waitForResponse() で API 呼び出しを確認
items[direction=input]:
→ await page.fill('[data-testid="<itemId>"]', '<testValue>')
→ await page.selectOption('[data-testid="<itemId>"]', '<option>') (enum の場合)
items[] が空 / items なし:
→ // PLACEHOLDER: Screen の items が未定義。コンポーネント実装確認後に data-testid を設定すること
P4-8. SQLite --workers=1 設定 (D-7)
生成する playwright.config.ts には必ず以下を設定する:
export default defineConfig({
fullyParallel: false,
workers: 1,
});
database.type = "sqlite" 以外 (Postgres / MySQL) の場合は fullyParallel: true でよい。
P4-9. 出力ファイル
<出力先>/
<scenarioName>.e2e.spec.ts — E2E スペックファイル (本体)
playwright.config.ts — Playwright 設定 (SQLite workers=1)
helpers/
auth.ts — loginAs() / loginViaUI() helper
db.ts — seedTestData() / truncateTestData() helper
README.md — PLACEHOLDER 解決表 + 遷移導出フロー + 再 invocation 例
P4-10. smoke 検証 (playwright headless)
npx playwright test --config=<出力先>/playwright.config.ts --workers=1 2>&1 | tail -30
実行環境が整っていない場合はスキップし、最終レポートに以下を記載:
smoke 検証: スキップ (dev server 未起動 / playwright install 未実施)
推奨コマンド: npx playwright test --config=<出力先>/playwright.config.ts --workers=1
P4-11. 最終レポート (P4 モード)
## /generate-tests 完了: <シナリオ名> (E2E シナリオ)
### 入力
- シナリオ: <シナリオ名>
- 対象 screens: <id1> (<name1>, <path1>) → ... → <idN> (<nameN>, <pathN>)
- 遷移導出: <1次/2次/3次> (<理由>)
### screen path index
| screenId | name | path | kind |
|---|---|---|---|
| <id1> | <name1> | <path1> | <kind1> |
### 生成ファイル
- `<出力先>/<scenarioName>.e2e.spec.ts` (N 行, M テスト)
- `<出力先>/playwright.config.ts` (workers=1)
- `<出力先>/helpers/auth.ts`
- `<出力先>/helpers/db.ts`
- `<出力先>/README.md`
### シナリオステップ一覧
| step | 画面 | 操作 | spec anchor | 遷移導出 |
|---|---|---|---|---|
| 1 | ログイン | loginAs() API 経由 | Scenario <id> step:1 | — |
| 2 | <name1> | page.goto('<path1>') | Screen <id1> step:2 | 3次 |
| ...
### smoke 検証
- playwright 実行: ✓ N/N pass / スキップ (<理由>)
### 申し送り
- SCENARIO-1: screenTransitions 空 → path-based fallback。#864 close 後に再生成推奨。
- PLACEHOLDER: <未解決の PLACEHOLDER 一覧>
Step P5: AI flow mock + 実 API 切替テスト生成 — #874 / Phase 2-B
ProcessFlow に kind ∈ {aiCall, aiAgent} の step が含まれる場合、P5 ルートが自動的に有効化される。
P5 は P1/P2 と並列して生成する (P1/P2 の happy path / validation テストに加えて AI 固有テストを追加)。
Phase 2-A (PR #937, 2026-05-08) の前提: 旧 kind=externalSystem + systemRef=claudeApi /
旧 english-learning:LlmDialog パターンは全 sample で aiCall に移行済。本 skill は
aiCall / aiAgent のみを認識する。旧パターンが残るフローは Phase 2-A 移行漏れなので spec 側で起票。
Phase 2-C (/generate-code) との関係: 実 backend 実装の AI runtime / SDK 切替は
/generate-code Phase 2-C (PR #944 想定) で確定した AiRuntimeService.invoke を使う。
mock target は固定契約として: type=AiRuntimeService / method=invoke /
import path=../src/ai/ai-runtime.service (e2e-spec から見た相対パス)。
旧 PLACEHOLDER 形式 (AI_RUNTIME_SERVICE_*) は廃止。
テンプレート規約: .claude/skills/generate-tests/templates/backend/typescript-nestjs/E2E_SPEC.md の Section 16 (AI flow セクション) を Read して参照すること。
ゴールデン出力も参照:
.claude/skills/generate-tests/golden-examples/diary-ai-tag-suggest/
ai-tag-suggest.e2e-spec.ts — AIタグ提案 (aiCall + structuredObject) mock + 実 API テスト
mocks/ai-runtime.ts — AI runtime service mock helper (provider 中立形式)
README.md — PLACEHOLDER 解決表 + 再 invocation 例
P5-1. AI flow 検出アルゴリズム
ProcessFlow JSON を読み込み、以下の条件で AI flow かどうかを判定する:
AI flow 判定条件:
actions[].steps[] (および inlineBranch.{ok,ng} / branches[].steps / elseBranch.steps を再帰探索)
に step.kind ∈ { "aiCall", "aiAgent" } が 1 件以上含まれる場合 → AI flow
旧パターンの扱い (Phase 2-A 完了後):
- kind="externalSystem" + systemRef がモデル系名 (claudeApi 等) は P5 検出対象外。
Phase 2-A で全 sample 移行済み。残存は移行漏れとして report に warning を出力する。
aiCall vs aiAgent の差分:
- aiCall: single-shot 呼び出し。tools 任意。outputBinding は最終 assistant message。
- aiAgent: tools 必須 (minItems=1) + maxIterations。tool call ループはランタイムが内部処理し、
outputBinding には最終 assistant message のみが入る。途中の tool 呼び出しは
個別 step として現れない (spec §「outputBinding の値構造」)。
→ テスト観点は aiCall と同じ 4 観点 + agent 特有テスト (P5-3 補足) を追加する。
P5-2. modelEndpoints catalog 解決 → mock target 決定
catalog merge ルール (#939 提案 C, 2026-05-08):
1. project level catalog: examples/<project-id>/harmony/catalogs/external.json
(workspace モードでは <workspace>/catalogs/external.json)
2. flow level catalog: <flow>.context.catalogs.modelEndpoints
3. merge: 同一キーなら flow level が project level を override
4. step.modelRef → merged.modelEndpoints[modelRef] = ModelEndpointEntry を取得
ModelEndpointEntry から抽出する情報:
- provider: anthropic / openai / google / aws-bedrock / ollama / azure-openai / namespace:custom
- model: provider 固有 model ID (例: 'claude-opus-4-7')
- endpoint: API base URL (省略時 provider default)
- auth: { kind: bearer/basic/apiKey/oauth2/iamRole/azureAd/none, tokenRef?: '@secret.<key>' }
- defaults: { temperature, maxTokens, topP, topK, stopSequences } (推論パラメータ)
- fallback: primary 失敗時の別 modelEndpoint key (任意)
secrets 解決:
auth.tokenRef = "@secret.<secretKey>" → merged.secrets[secretKey] を取得
secrets.<key>.source = "env" の場合: secrets.<key>.name が env var 名 (例: ANTHROPIC_API_KEY)
Phase 2-B 時点では env source のみ想定 (vault 等は別 ISSUE)
mock target の決定 (Phase 2-C 確定後):
- 実装側 service: AiRuntimeService (固定契約)
type: AiRuntimeService
method: invoke
import: ../src/ai/ai-runtime.service (e2e-spec から見た相対パス)
- mock 対象 method の戻り値型は spec §「outputBinding の値構造」に従う正規化形式:
AiInvocationResult = {
text?: string; // text / streaming / json (人間可読部)
object?: unknown; // json / structuredObject (parse 済み)
raw?: string; // json / structuredObject (provider 生 JSON 文字列)
finishReason?: string;
usage?: { inputTokens?: number; outputTokens?: number };
toolCalls?: Array<{ id: string; name: string; arguments: unknown }>;
}
- jest: jest.spyOn(aiRuntime, 'invoke').mockResolvedValue(<AiInvocationResult>)
- vitest (P3 採用、ただし AI flow は P1/P2 backend なので jest 固定): 同パターン
- mockResolvedValue / mockResolvedValueOnce の選択: 1 テスト 1 mock が原則 (afterEach で
mockRestore() する前提)。Once 系は 1 リクエスト中に複数回 invoke される将来パターン (retry
sequence 等) でのみ使用。
@env.* / @secret.* 参照の扱い (Phase 2-B 時点):
@secret.<secretKey> → merged.secrets[secretKey].name から env var 名を抽出
テスト内では process.env.<env-name> を直接参照 (#859 未解決)
@env.<envKey> → harmony.json の context.envCatalog (#859 解決後) から取得予定。
現状は PLACEHOLDER + literal fallback。
@conv.<key> → conventions catalog から解決 (#859 解決後)。現状は compute step
expression からリテラル抽出 (例: 0.6)。
P5-3. step.kind=aiCall|aiAgent → テスト変換ルール
AI flow 検出後、以下の 4 観点 (AI-1〜AI-4) でテストを自動生成する。各 it() には D-1 anchor
// Spec: ProcessFlow <flowId> step:<step-id> [ai-mode:mock|live] を必ず付与する。
responseFormat 別の mock 戻り値構造
step.responseFormat.kind を見て mock の戻り値を分岐する。未指定は text 扱い。
| responseFormat.kind | mock 戻り値 | 後続 step での参照 |
|---|
text (default) | { text: '<生成テキスト>', finishReason: 'end_turn', usage: {...} } | @<bind>.text |
json | { object: <任意 JSON>, raw: '<JSON 文字列>', ...meta } | @<bind>.object / @<bind>.raw |
structuredObject | { object: <responseFormat.schema 準拠>, raw: '<JSON.stringify(object)>', ...meta } | @<bind>.object.<field> |
streaming | { text: '<完了後 assembled>', ...meta } (本テスト層では partial chunks を扱わない) | @<bind>.text |
tools を含む aiCall / aiAgent で tool call が発生したパスを mock する場合は上記に加え:
{ toolCalls: [{ id, name, arguments }] }
を返す。aiAgent は最終 assistant message を上記正規化形式で返す (途中の tool 呼び出しは
ランタイムが処理するため mock 対象外)。
AI-1: 業務フィルタ / map 検証 (responseFormat-aware)
対象: step.kind=compute で @<aiBinding>.object.* または @<aiBinding>.text を加工する step。
変換ルール:
1. 後続 compute step の expression を解析:
- aiCall.responseFormat=structuredObject → "@aiResponse.object.<field>.filter(...).map(...)" 形式
例 (diary タグ提案): "@aiResponse.object.tags.filter(t => t.confidence >= @conv.limit.tagSuggestThreshold)..."
- aiCall.responseFormat=text/streaming → "@aiResponse.text" を文字列加工する
- aiCall.responseFormat=json → "@aiResponse.object.*" (構造制約なし)
2. filter 条件 (例: confidence >= threshold) を抽出。
- リテラル比較 → 値を直接使用
- @conv.* 参照 → conventions catalog から解決 (Phase 2-B 時点: PLACEHOLDER + 補助 const)
3. mock の object に境界値を含む fixture を構築 (responseFormat.schema 準拠で書く):
- 閾値超過 (採用期待) / 閾値未満 (除外期待) / 閾値ちょうど (採用期待、境界値)
4. 生成テスト:
- aiResponse.object に閾値未満を含む fixture → response から除外されていること
- aiResponse.object に閾値以上を含む fixture → response に含まれていること
- 境界値ちょうど → 含まれていること
5. AI-1 該当 compute が見つからない場合は AI-1 を skip し、申し送りに記録する。
注: 旧版 (externalSystem) の "JSON.parse(@aiResponse.content[0].text).filter(...)" は廃止。
aiCall (structuredObject) では runtime が JSON parse 済み → user code は @<bind>.object.* を直接参照する。
AI-2: secret 未設定 → 503 fallback
変換ルール:
1. step.modelRef → merged.modelEndpoints[modelRef].auth.tokenRef ('@secret.<key>') を取得
2. merged.secrets[<key>].name を env var 名として抽出 (例: ANTHROPIC_API_KEY)
3. mock で API key 未設定状態を再現:
const originalKey = process.env.ANTHROPIC_API_KEY;
process.env.ANTHROPIC_API_KEY = '';
try {
// リクエスト実行
expect(res.status).toBe(503);
} finally {
process.env.ANTHROPIC_API_KEY = originalKey; // 必ず restore
}
4. 期待 HTTP status: 503 (Service Unavailable)
注: API key 未設定時は provider 呼び出し前に 503 を返す実装が前提。
実装が 401 / 500 を返す場合はその status に合わせて修正する旨をコメントに明記。
生成テスト:
#N AI-2: <ENV_VAR_NAME> 未設定 → 503 Service Unavailable
modelEndpoint.auth.kind が "none" / "iamRole" / "azureAd" の場合の扱い:
- "none": AI-2 を skip (env secret 不要のため)
- "iamRole": AI-2 を skip + 申し送りに「IAM ロール解決失敗テストは別 ISSUE 候補」と記載
- "azureAd": 同上 (Azure AD 認証失敗テストは別 ISSUE)
AI-3: response format violation (responseFormat-aware)
旧版 (externalSystem) の「JSON.parse 失敗 → 500」は user code 側で parse していた前提。
aiCall では runtime が parse / schema 検証を担うため、AI-3 の生成可否は responseFormat に依存する。
分岐ルール:
responseFormat.kind = "text" → AI-3 を skip (parse / 検証ステップが無い)
responseFormat.kind = "streaming" → AI-3 を skip (assembled text のみ受け取る)
responseFormat.kind = "json" → AI-3 生成: provider が malformed JSON を返した想定
mock で raw=invalid を返し、runtime parse 失敗 → 502 期待
responseFormat.kind = "structuredObject" → AI-3 生成: provider 出力が schema 違反の想定
mock で object=schema 違反値 (例: required field 欠落) を返し、
runtime schema 検証失敗 → 502 期待
mock 例 (structuredObject、schema 違反):
jest.spyOn(aiRuntime, 'invoke').mockResolvedValue({
object: { tags: [{ slug: 'x' }] }, // confidence 必須が欠落 → schema violation
raw: '{"tags":[{"slug":"x"}]}',
});
生成テスト (該当時):
#N AI-3: AI 応答が responseFormat 不適合 (<kind>) → 502 (provider violation)
注:
- 旧 AI-3 の HTTP 500 は user code parse エラー由来。runtime parse / 検証失敗は仕様上 502
(provider 側エラー) として扱う。実装が 500 を返す場合は申し送り + コメント上書き。
- aiAgent は最終 assistant message のみで判定 (途中 tool call の format violation は対象外)。
AI-4: provider 呼び出し失敗 → outcomes.failure
変換ルール:
1. step.outcomes.failure を取得 (#937 移行後の標準形):
{ action: "abort" | "continue" | "compensate", description?, jumpTo?, sideEffects?, sameAs? }
注: 本フィールドに responseId は直接含まれない。間接解決 (step 2) で求める。
2. action="abort" の場合の responseId 解決パス (間接):
(a) catalog.errors 経由: context.catalogs.errors[<errorCode>].responseId を引き、
action.responses[responseId].status を期待値とする
例: errors.AI_API_ERROR.responseId="502-ai-error" → responses["502-ai-error"].status=502
(b) AI 系の慣例 (catalog に該当 errors entry / responseId が無い場合): 502 をデフォルト
(c) action.responses[] が空 / 未定義の場合: 502 をデフォルト
→ いずれの場合も最終的な期待 status は 502 が典型 (provider 失敗を反映)。
3. mock で provider 呼び出しが reject するよう設定:
jest.spyOn(aiRuntime, 'invoke').mockRejectedValue(
new Error('Mock provider error')
);
※ retry policy は Phase 2-B 時点では modelEndpoint に未定義。SDK 内部の retry は
Phase 2-C で確定するため、AI-4 は単発失敗のみテストし「retry 回数」は assertion しない。
retry が定義された段階で AI-4-b としてサブテストを追加する (申し送り)。
4. 期待 HTTP status: 上記 (a)/(b)/(c) で解決した値。
生成テスト:
#N AI-4: provider 呼び出し失敗 → <statusCode> (<errorCode>)
aiAgent 補足:
- maxIterations 超過パスは現状 outcomes.failure として未確定 (spec 拡張候補)。
Phase 2-B では mock 1 回失敗のみテスト、agent loop は申し送り。
P5-4. AiMessageSpread の検出と fixture 化
検出:
step.messages[] を走査し、各要素について oneOf を判定:
- { role, content } 型 → 通常メッセージ (AiMessage)
- { kind: "spread", ref } → 動的展開 (AiMessageSpread) ★ 検出対象
fixture 化:
ref が "@<varName>" 形式の場合:
1. ref から varName を抽出 (例: "@turnContext" → "turnContext")
2. varName の出所を解決:
- actions[0].inputs[].name == varName → request body / query から渡る
- 直前の compute / dbAccess の outputBinding.name == varName → 直前 step が値を生成
- context.ambientVariables[].name == varName → JWT / session から取得
3. テスト fixture: AiMessage 互換配列 [{ role: 'user'|'assistant'|'system', content: string }, ...]
を input として渡す (typical: 過去 turns 2〜3 件)
mock 戻り値への影響:
spread item は LLM への input 側のみに影響。mock の戻り値 (AiInvocationResult) は responseFormat 通り。
mock 内で input を assert する場合は AI runtime service の引数を spy するが、AiMessageSpread が
正しく展開されたかの検証は本層では skip し、Phase 2-C の runtime ユニットテスト側に委ねる。
例: english-learning 96118ae1 (会話ターン進行)
step-03 messages:
[
{ role: 'system', content: '...' },
{ kind: 'spread', ref: '@turnContext' },
{ role: 'user', content: '@userInput' }
]
→ action.inputs[] に turnContext (string) があるが、spec 上は "DialogTurn[] の JSON 文字列 (暫定)"
と書かれている (#939 提案 A 移行で type を AiMessage[] に変えるのが理想だが、現状は string)。
テスト fixture では JSON.parse 後に AiMessage[] になる文字列を渡す。
例: turnContext = JSON.stringify([
{ role: 'user', content: 'Hello!' },
{ role: 'assistant', content: 'Hi, how are you?' }
])
を request body に含めて送る。
P5-5. AiImageSource variant の生成例
step.messages[].content[] に { type: "image", source: ... } がある場合、source.kind 別に fixture を生成する。
source.kind = "fileRef":
source = { kind: "fileRef", ref: "@inputs.photo" }
→ action.inputs[] に "photo" (file 型) がある前提
→ テスト fixture:
const photoFile = Buffer.from(<test image data>);
request(...).field('title', '...').attach('photo', photoFile, 'test.jpg')
または upload helper 経由で fileRef を obtain して payload に渡す。
→ mock 側は photo の内容を検証しない (provider が実際に画像を解釈しないため)。
source.kind = "url" (literal):
source = { kind: "url", url: "https://cdn.example.com/photo.jpg" }
→ 固定 URL のため fixture は不要。リクエスト body に追加情報は要らない。
→ mock は無条件で AI 応答を返す (URL の到達性は本層では検証しない)。
source.kind = "url" (expression "@<var>"):
source = { kind: "url", url: "@photoRow.url" }
→ @photoRow が直前の dbAccess outputBinding か、@inputs.imageUrl が action input か等を解析:
photoRow = SELECT 結果 → seed data で photos.url が解決される必要あり (P2 連携)
inputs.imageUrl = string → request body に imageUrl を含める
→ テスト fixture: request body / DB seed で URL 値を埋める。
必要に応じて HEAD 200 を返す mock URL を選ぶ (例: https://example.com/test.jpg。
本層では URL 到達は検証せず、provider mock が応答を返すだけ)。
source.kind = "base64":
source = { kind: "base64", data: "<base64>", mediaType: "image/jpeg" }
→ mock 入力に base64 データが含まれる。テスト fixture で短い base64 (1x1 透明 PNG 等) を準備し、
リクエスト body に含めるか step input に直書きする。
例: diary b0c1d2e3 (画像 alt 生成)
step-05 messages[1].content[0].source = { kind: "url", url: "@targetImageUrl" }
step-04 で targetImageUrl = @photoRow.url ?? @inputs.imageUrl と算出
→ test fixture (2 通り):
(a) photoId 指定 → DB seed で photos に該当 row + url を入れる → @photoRow.url が解決される
(b) imageUrl 指定 → request body に imageUrl: 'https://example.com/test.jpg' を直接含める
P5-6. mock helper の構造 (provider 中立)
旧版の mocks/claude-api.ts (Anthropic 形式の HTTP レスポンス mock) は廃止。Phase 2-B では
provider 中立な AI runtime service mock を生成する。
import type { AiRuntimeService } from '../src/ai/ai-runtime.service';
export interface AiInvocationResult {
text?: string;
object?: unknown;
raw?: string;
finishReason?: string;
usage?: { inputTokens?: number; outputTokens?: number };
toolCalls?: Array<{ id: string; name: string; arguments: unknown }>;
}
export function mockAiText(svc: AiRuntimeService, text: string): jest.SpyInstance {
const result: AiInvocationResult = {
text,
finishReason: 'end_turn',
usage: { inputTokens: 50, outputTokens: 100 },
};
return jest.spyOn(svc, 'invoke').mockResolvedValue(result);
}
export function mockAiStructured(svc: AiRuntimeService, object: unknown): jest.SpyInstance {
const result: AiInvocationResult = {
object,
raw: JSON.stringify(object),
finishReason: 'end_turn',
usage: { inputTokens: 50, outputTokens: 100 },
};
return jest.spyOn(svc, 'invoke').mockResolvedValue(result);
}
export function mockAiJson(svc: AiRuntimeService, object: unknown): jest.SpyInstance {
return mockAiStructured(svc, object);
}
export function mockAiStreaming(svc: AiRuntimeService, text: string): jest.SpyInstance {
return mockAiText(svc, text);
}
export function mockAiFailure(svc: AiRuntimeService, error?: Error): jest.SpyInstance {
return jest.spyOn(svc, 'invoke').mockRejectedValue(error ?? new Error('Mock provider error'));
}
export function mockAiFormatViolation(svc: AiRuntimeService): jest.SpyInstance {
return jest.spyOn(svc, 'invoke').mockRejectedValue(
new Error('Mock provider returned response that violates declared responseFormat'),
);
}
export function mockAiWithToolCalls(
svc: AiRuntimeService,
text: string,
toolCalls: Array<{ id: string; name: string; arguments: unknown }>,
): jest.SpyInstance {
const result: AiInvocationResult = {
text,
toolCalls,
finishReason: 'tool_use',
usage: { inputTokens: 50, outputTokens: 100 },
};
return jest.spyOn(svc, 'invoke').mockResolvedValue(result);
}
P5-7. mock mode vs 実 API mode の切替ロジック
ternary パターン (jest + vitest 両互換): 条件付き skip には describe.skipIf (Vitest 専用) ではなく
ternary (cond ? describe : describe.skip)(name, fn) を使う。jest 環境では describe.skipIf が
存在せず TypeError になるため。本 skill の Backend mode は jest 固定 (D-6) だが、vitest でも
動作するこのパターンを推奨形とする。
import { mockAiStructured, mockAiFailure } from './mocks/ai-runtime';
describe('POST <httpRoute.path> (<action.name> E2E) [mock mode]', () => {
let aiRuntimeSpy: jest.SpyInstance | undefined;
afterEach(() => {
if (aiRuntimeSpy) {
aiRuntimeSpy.mockRestore();
aiRuntimeSpy = undefined;
}
});
it('#N AI-1: confidence < threshold は除外', async () => {
aiRuntimeSpy = mockAiStructured(aiRuntime, {
tags: [
{ slug: 'high', name: '高', confidence: 0.9 },
{ slug: 'low', name: '低', confidence: 0.4 },
],
});
});
it('#N AI-4: provider 失敗 → 502', async () => {
aiRuntimeSpy = mockAiFailure(aiRuntime);
});
});
(process.env.RUN_AI_INTEGRATION === '1' ? describe : describe.skip)(
'POST <httpRoute.path> (<action.name> E2E) [live API]',
() => {
},
);
P5-8. 参照解決 index (Phase 2-B 時点)
P5 テスト生成時に参照する外部依存の解決方法を統一する:
@secret.<key>
→ modelEndpoint.auth.tokenRef = "@secret.<key>" → secrets[<key>].name = "<ENV_VAR_NAME>"
→ テスト内記録: process.env.<ENV_VAR_NAME>
→ 例: @secret.anthropicApiKey → ANTHROPIC_API_KEY
→ 例: @secret.openaiApiKey → OPENAI_API_KEY (将来 provider 切替時)
@env.<key>
→ harmony.json の context.envCatalog (#859 解決後) から取得予定。
→ 現状: PLACEHOLDER + literal fallback。
@conv.<dotted.path>
→ conventions catalog から解決 (#859 解決後)。
→ 現状: compute step の expression からリテラル値を抽出 (例: 0.6)。
→ const 化して PLACEHOLDER として README に記録。
@<varName> (action input / step output / ambient variable)
→ action.inputs[] / step.outputBinding / context.ambientVariables から解決。
→ AiMessageSpread.ref / AiImageSource (url=expression) で頻出。
P5-9. ai-specific PLACEHOLDER 解決表 (README 必須項目)
P5 golden 生成時の README には以下の PLACEHOLDER 解決表を含めること。完全リスト (テンプレート展開用 16 項目)
は templates/backend/typescript-nestjs/E2E_SPEC.md の Section 16-E を参照。本表は README 必須記載項目の主要抜粋。
| PLACEHOLDER | 解決元 | 例 | 差替えポイント |
|---|
| AI runtime service クラス名 | Phase 2-C 確定 (固定契約) | AiRuntimeService | — |
| AI runtime service method | Phase 2-C 確定 (固定契約) | invoke | — |
| import path (e2e-spec から) | Phase 2-C 確定 (固定契約) | ../src/ai/ai-runtime.service | — |
AI_STEP_KIND | step.kind | aiCall / aiAgent | — |
AI_MODEL_REF | step.modelRef | tagSuggestModel | — |
AI_PROVIDER | merged.modelEndpoints[modelRef].provider | anthropic | provider 切替時は catalog 編集で完結 |
AI_MODEL_NAME | merged.modelEndpoints[modelRef].model | claude-opus-4-7 | 同上 |
AI_AUTH_KIND | merged.modelEndpoints[modelRef].auth.kind | bearer | — |
AI_SECRET_REF | auth.tokenRef から @secret. 除去 | anthropicApiKey | — |
AI_API_KEY_ENV | merged.secrets[AI_SECRET_REF].name | ANTHROPIC_API_KEY | — (env var は解決不要) |
RESPONSE_FORMAT_KIND | step.responseFormat.kind (default text) | structuredObject | — |
AI_OUTPUT_BINDING | step.outputBinding.name | aiResponse | — |
AI_THRESHOLD_VALUE | compute step の filter expression リテラル | 0.6 | #859 解決後: @conv.<...> catalog 参照 |
STEP_AI_ID / STEP_COMPUTE_ID | step.id (aiCall | aiAgent / 後続 compute) | step-03 / step-04 |
COMPUTE_FILTER_EXPRESSION | compute step の expression | @aiResponse.object.tags.filter(...) | — |
FAILURE_RESPONSE_ID | catalog.errors[].responseId (間接解決、AI-4 step 2 参照) | 502-ai-error | — |
FAILURE_RESPONSE_STATUS | action.responses[FAILURE_RESPONSE_ID].status (catalog 不在時 502 default) | 502 | — |
FAILURE_ERROR_CODE | catalog errors[].code (responseId と紐付く) | AI_API_ERROR | — |
AI_MAX_TOKENS_LIVE | merged.modelEndpoints[modelRef].defaults.maxTokens | 512 | — |
P5-10. 出力ファイル
<出力先>/
<flowName>.e2e-spec.ts — AI flow テスト (mock mode + 実 API mode)
mocks/
ai-runtime.ts — AI runtime service mock helper (provider 中立)
README.md — PLACEHOLDER 解決表 + CI 設定例 + 再 invocation 例
P5-11. 最終レポート (P5 モード)
## /generate-tests 完了: <flowName> (AI flow mock + 実 API 切替、Phase 2-B)
### 入力
- ProcessFlow: <flowId> (<meta.name>)
- 検出 AI steps: <step-id> (kind=<aiCall|aiAgent>, modelRef=<key>, responseFormat=<kind>)
- modelEndpoint: provider=<...>, model=<...>, auth.kind=<...>
### AI flow 検出結果
| step.id | kind | modelRef | responseFormat | tools | 備考 |
|---|---|---|---|---|---|
| <step-id> | aiCall | <modelRef> | structuredObject | — | — |
### 4 観点変換結果
| 観点 | 生成テスト | assertion | 備考 |
|---|---|---|---|
| AI-1: 業務フィルタ | #N threshold 未満 → 除外 / ≥ threshold → 含まれる | length / 個別要素 | compute step が無い場合は skip |
| AI-2: secret 未設定 | #N <ENV>="" → 503 | status | auth.kind=none/iam/azureAd は skip |
| AI-3: format violation | #N runtime 検証失敗 → 502 | status | text/streaming は skip |
| AI-4: provider 失敗 | #N reject → <failureStatus> | status | retry は Phase 2-C で再評価 |
### catalog 解決結果
| 参照 | 解決元 | 解決値 | 備考 |
|---|---|---|---|
| step.modelRef | merged.modelEndpoints | <provider> / <model> | project + flow merge 済 |
| auth.tokenRef | merged.secrets | env var <ENV_NAME> | source=env 前提 |
| AiMessageSpread.ref (該当時) | action.inputs / outputBinding | <fixture 生成方法> | — |
| AiImageSource (該当時) | source.kind 別 | <fileRef|url|base64> | source.kind=url(@<var>) は変数解決済 |
### 生成ファイル
- `<出力先>/<flowName>.e2e-spec.ts` (N 行, M テスト)
- `<出力先>/mocks/ai-runtime.ts`
- `<出力先>/README.md`
### smoke 検証
- jest 実行: 推奨 (Phase 2-C 確定後は AiRuntimeService が `/generate-code` で生成されるため)
- 推奨コマンド: cd apps/api && npx jest <flowName>.e2e-spec.ts --runInBand
### 申し送り
- AI runtime: AiRuntimeService.invoke は固定契約 (Phase 2-C 確定)
- PLACEHOLDER: AI_THRESHOLD_VALUE = <値> → #859 解決後に @conv.<key> 参照へ
- live API テスト: RUN_AI_INTEGRATION=1 <ENV_VAR>=<key> npx jest --runInBand で実行
- aiAgent の場合: maxIterations 超過パスは Phase 2-B では未対応 (spec 拡張候補、別 ISSUE)