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: "[email protected]",
          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: "[email protected]",
          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 の実際の挙動と一致し、テストが全件パスしています。

    次に、しきい値の自動追従です。maxlengththreshold"80" から "50" に変更すると、生成されるケースが "a".repeat(50) / "a".repeat(51) / "a".repeat(49) に切り替わりました。テストコードの修正は不要です。

    さらに、スキーマ改変の検出も確認しました。元スキーマで maxlength=80 の境界値テスト(80 文字 → pass)を取り出し、しきい値を 50 に締めたルールで再検証すると、同じ 80 文字入力が fail に変わりました。「スキーマの変更がテスト失敗として表面化するか」の検証です。

    まとめ

    SWV のスキーマを SSOT にすることで、テストもそこから導出できます。特に threshold を持つルールの境界値テストは、手書きすると退屈で修正漏れが起きやすい領域ですが、スキーマから自動生成すれば同期コストがなくなります。

    参考リンク

    広告ここから
    広告ここまで
    Home
    Search
    Bookmark