SWVを使ってフォームバリデーションのテストをスキーマから自動生成する
フォームのバリデーションテストを書くのは、最初は簡単です。maxlength: 80 のフィールドに 81 文字を入れて、エラーが返ることを確認すればいいだけです。 問題はその後にやってきます。仕様変更で maxleng […]
目次
フォームのバリデーションテストを書くのは、最初は簡単です。maxlength: 80 のフィールドに 81 文字を入れて、エラーが返ることを確認すればいいだけです。
問題はその後にやってきます。仕様変更で maxlength が 80 から 50 に変わったとき、テストコードに散らばった "a".repeat(80) をすべて "a".repeat(50) に直す必要があります。1 箇所でも直し忘れると、テストが落ちます。でもそれはバリデーションの不備ではなく、テストの修正漏れです。本来見つけたいバグとは無関係な「偽の失敗」に時間を取られることになります。
フォームのフィールドが増え、ルールが増えるほど、この同期コストは膨らんでいきます。
この記事では、Schema-Woven Validation(SWV) の JSON スキーマからバリデーションテストケースを自動生成し、Vitest で実行する仕組みを紹介します。スキーマを変えればテストも自動的に追従するため、しきい値の同期管理から解放されます。
SWV(Schema-Woven Validation)とは
SWV は、WordPress の Contact Form 7 が採用しているバリデーション機構です。作者の三好さんが設計し、npm パッケージ @rocklobsterinc/swv として WordPress 非依存の JavaScript 実装が公開されています。
SWV の特徴は、バリデーションルールをコードにハードコードするのではなく、JSON スキーマとして宣言的に定義する点です。例えばこれは、お問い合わせフォーム用スキーマの例です。
{
"version": "Contact Form 7 SWV Schema 2022-03",
"locale": "ja",
"rules": [
{
"rule": "required",
"field": "your-name",
"error": "お名前は必須です。"
},
{
"rule": "maxlength",
"field": "your-name",
"threshold": "80",
"error": "お名前は80文字以内で入力してください。"
},
{
"rule": "email",
"field": "your-email",
"error": "正しいメールアドレスを入力してください。"
},
{
"rule": "enum",
"field": "your-category",
"accept": ["general", "support", "sales"],
"error": "無効な種別が選択されています。"
}
]
}
各ルールには rule(種別)、field(対象フィールド)、threshold(しきい値)、accept(許容値)、error(エラーメッセージ)が定義されています。SWV ではこのスキーマを読み取り、サーバーサイドでもクライアントサイドでも同一のバリデーションを実行します。
ここで重要なのは、このスキーマが SSOT(Single Source of Truth)として機能するということです。バリデーションの定義がすべてこの JSON に集約されているなら、テストケースもここから導出することができそうです。
スキーマからテストを導出するという考え方
従来のバリデーションテストでは、開発者がスキーマとテストの両方を管理します。
スキーマ: maxlength = 80
テスト: "a".repeat(80) → pass, "a".repeat(81) → fail
この 2 つは同じ情報の二重管理です。スキーマが変われば、テストも変えなければなりません。
スキーマ駆動テストでは、テストケースをスキーマから機械的に生成します。
スキーマ: maxlength = 80
↓ Generator が threshold を読む
テスト: "a".repeat(80) → pass, "a".repeat(81) → fail, "a".repeat(79) → pass
しきい値が 50 に変われば、生成されるテストも自動的に 50 を基準にしたものに変わります。テストコードを修正する必要はありません。
ルールタイプ別の Generatorを作ってみる
テストケース生成の中核は、ルールタイプごとに定義した Generator 関数です。各 Generator は SWV のルール定義を受け取り、valid/invalid の入力ペアを返します。
生成される各テストケースは以下の型を持ちます。
type TestCase = {
description: string;
field: string;
input: string | string[];
expected: "pass" | "fail";
errorMessage?: string;
sourceRule: RuleDefinition;
};
required の Generator
最もシンプルな存在チェックです。値があるか空かの 2 パターンを生成します。
export const generateRequiredCases: RuleGenerator = (rule) => {
const field = mustField(rule);
return [
baseCase(rule, {
description: `required: empty is invalid (${field})`,
field,
input: "",
expected: "fail",
errorMessage: rule.error,
}),
baseCase(rule, {
description: `required: non-empty passes (${field})`,
field,
input: "abc",
expected: "pass",
}),
];
};
email の Generator
SWV の EmailRule は WordPress の is_email() に準拠した実装です。正常なアドレス、@ の重複、6 文字未満など、仕様上弾かれるべきパターンを生成します。
export const generateEmailCases: RuleGenerator = (rule) => {
const field = mustField(rule);
return [
baseCase(rule, {
description: `email: typical address passes (${field})`,
field,
input: "user@example.com",
expected: "pass",
}),
baseCase(rule, {
description: `email: malformed fails (${field})`,
field,
input: "not-an-email",
expected: "fail",
errorMessage: rule.error,
}),
baseCase(rule, {
description: `email: double @ fails (${field})`,
field,
input: "user@@double.com",
expected: "fail",
errorMessage: rule.error,
}),
baseCase(rule, {
description: `email: under 6 chars fails (${field})`,
field,
input: "a@b.c",
expected: "fail",
errorMessage: rule.error,
}),
baseCase(rule, {
description: `email: empty passes (${field})`,
field,
input: "",
expected: "pass",
}),
];
};
最後の「空文字は pass」は、SWV の仕様を反映したものです。SWV では email ルール単体は空入力を通します。空をエラーにしたい場合は別途 required ルールを設定します。
maxlength / minlength の Generator
threshold プロパティから境界値を自動算出します。
export const generateMaxLengthCases: RuleGenerator = (rule) => {
const field = mustField(rule);
const t = parseInt(rule.threshold ?? "0", 10);
return [
baseCase(rule, {
description: `maxlength: exactly ${t} passes (${field})`,
field,
input: "a".repeat(t),
expected: "pass",
}),
baseCase(rule, {
description: `maxlength: ${t + 1} fails (${field})`,
field,
input: "a".repeat(t + 1),
expected: "fail",
errorMessage: rule.error,
}),
baseCase(rule, {
description: `maxlength: ${t - 1} passes (${field})`,
field,
input: "a".repeat(Math.max(0, t - 1)),
expected: "pass",
}),
baseCase(rule, {
description: `maxlength: empty passes (${field})`,
field,
input: "",
expected: "pass",
}),
];
};
いわゆる境界値テストをやるイメージですね。maxlength: 80 であれば、80(境界ちょうど)、81(境界+1)、79(境界-1)の 3 点を確認することで、「80 文字以下なら合格、81 文字以上なら不合格」という挙動を保証できます。この ±1 の算出をスキーマの threshold から自動で行っているのがこの Generator の役割です。
enum の Generator
accept 配列の各値を通すテストと、範囲外の値を弾くテストを生成します。
export const generateEnumCases: RuleGenerator = (rule) => {
const field = mustField(rule);
const cases: TestCase[] = (rule.accept ?? []).map((v) =>
baseCase(rule, {
description: `enum: accepted value "${v}" passes (${field})`,
field,
input: v,
expected: "pass",
})
);
cases.push(
baseCase(rule, {
description: `enum: non-accepted value fails (${field})`,
field,
input: "invalid-value",
expected: "fail",
errorMessage: rule.error,
}),
baseCase(rule, {
description: `enum: empty passes (${field})`,
field,
input: "",
expected: "pass",
})
);
return cases;
};
Generator の統合
すべての Generator は generateTestSuite() で統合されます。スキーマの各ルールを走査し、対応する Generator を呼び出してテストケースを連結します。
export function generateTestSuite(schema: SWVSchema): TestCase[] {
return schema.rules.flatMap((rule) => {
if (isCompositeRule(rule)) {
return [];
}
const gen = generators.get(rule.rule);
return gen ? gen(rule) : [];
});
}
この PoC では required, email, enum, minlength, maxlength, number, url, tel, date, minnumber, maxnumber の 11 種類に対応しています。all / any の複合ルール(composite rule)は構造が異なるためスキップしています。
sourceRule:ルール単位で検証する設計判断
maxlength の Generator が「空入力は pass」というケースを生成しますが、同じフィールド your-name には required ルールもあります。フィールド全体に対してバリデーションを実行すると、required が先に空入力を弾いてしまい、maxlength の「空は素通りする」という仕様を検証できません。
この問題を解決するために、各テストケースに sourceRule(そのケースが対象とする 1 ルール)を持たせ、テスト実行時にはそのルールだけでバリデーションを行う設計にしました。
test.each(testCases)("$description", (tc) => {
const errors = validateField(tc.field, tc.input, { rules: [tc.sourceRule] });
if (tc.expected === "pass") {
expect(errors[tc.field]).toBeUndefined();
} else {
expect(errors[tc.field]).toBeDefined();
if (tc.errorMessage) {
expect(errors[tc.field]).toBe(tc.errorMessage);
}
}
});
これにより、各ルールの挙動を独立して検証できます。フォーム全体としての複合挙動(ルール間の優先順位など)は、この仕組みの守備範囲外です。まず「ルール単位で SSOT からテストへ落とし込めるか」を確かめるのが、今回の目的です。
Vitest での 2 つの実行アプローチ
テストの実行方法として、動的生成と静的生成の 2 つを実装しました。
動的生成(ランタイム展開)
テスト実行時にスキーマを読み込み、test.each でテストケースを展開します。
import { generateTestSuite } from "../lib/test-gen";
import { validateField } from "../lib/swv/validate";
import schema from "../lib/schema/contact-form.json";
const testCases = generateTestSuite(schema);
describe("SWV schema-driven tests (dynamic)", () => {
test("contact-form yields at least 30 cases", () => {
expect(testCases.length).toBeGreaterThanOrEqual(30);
});
test.each(testCases)("$description", (tc) => {
const errors = validateField(tc.field, tc.input, { rules: [tc.sourceRule] });
if (tc.expected === "pass") {
expect(errors[tc.field]).toBeUndefined();
} else {
expect(errors[tc.field]).toBeDefined();
}
});
});
スキーマを変更すれば、次のテスト実行で自動的に新しいケースが反映されます。追加のコマンド実行やファイル管理は不要です。
静的生成(CLI でファイル出力)
npm run test:gen を実行すると、テストケースが埋め込まれた .test.ts ファイルが生成されます。
/* Generated by lib/test-gen/cli.ts — do not edit by hand */
import { describe, expect, test } from "vitest";
import type { TestCase } from "../../lib/test-gen/types";
import { validateField } from "../../lib/swv/validate";
const cases: TestCase[] = [
{
"description": "required: empty is invalid (your-name)",
"field": "your-name",
"input": "",
"expected": "fail",
"errorMessage": "お名前は必須です。",
"sourceRule": {
"rule": "required",
"field": "your-name",
"error": "お名前は必須です。"
}
},
// ... 残り 29 ケース
];
テストコードが可読な形でファイルに残るため、コードレビューや diff の確認が容易です。一方で、スキーマを変更したあとに npm run test:gen を再実行する必要があります。
どちらが運用に適するかは、チームの CI 運用やレビュー文化によります。
検証結果
今回のお問い合わせフォームスキーマ(9 ルール)から、30 個のテストケースが生成されました。
| ルール | フィールド | 生成ケース数 |
|---|---|---|
required × 4 |
your-name, your-email, your-category, your-message | 8 |
maxlength × 2 |
your-name (80), your-message (1000) | 8 |
email × 1 |
your-email | 5 |
enum × 1 |
your-category (3 値) | 5 |
minlength × 1 |
your-message (10) | 4 |
| 合計 | 30 |
確認できたこととして、まず 30 ケースすべてが SWV の実際の挙動と一致し、テストが全件パスしています。
次に、しきい値の自動追従です。maxlength の threshold を "80" から "50" に変更すると、生成されるケースが "a".repeat(50) / "a".repeat(51) / "a".repeat(49) に切り替わりました。テストコードの修正は不要です。
さらに、スキーマ改変の検出も確認しました。元スキーマで maxlength=80 の境界値テスト(80 文字 → pass)を取り出し、しきい値を 50 に締めたルールで再検証すると、同じ 80 文字入力が fail に変わりました。「スキーマの変更がテスト失敗として表面化するか」の検証です。
まとめ
SWV のスキーマを SSOT にすることで、テストもそこから導出できます。特に threshold を持つルールの境界値テストは、手書きすると退屈で修正漏れが起きやすい領域ですが、スキーマから自動生成すれば同期コストがなくなります。