~ skipbit

買い切り技術書 — 制作中

AIの緑を疑う(仮)

実装・テスト・検証を AI が担うとき、何を「実装から独立した正解」として残すか。

テストが緑 それは追認かもしれない 独立した正解を残す verdict + evidence

//なぜこの本か

「テストが緑」と「仕様が守られている」は、同じではありません。AI に実装を書かせ、テストを書かせ、そして検証まで手伝わせると、必ず一度は見る壊れ方があります。落ちたテストの期待値を実装に合わせて書き換えて緑にする。あるいは、実装を先に読んでから、その挙動をそのまま固定するテストを書いてしまう。どちらもテストは通ります。通りますが、守りたかった一線は守られていません。

緑が信用できなくなる原因は、多くの場合ひとつに畳めます。正解を作る経路と、正解を確かめる経路が、同じ間違いを共有している——共通原因の故障です。同じ計算・同じ思い込みから期待値を作れば、実装が間違えたところで検証も同じように間違えます。そこに、何も攻めずに通ってしまう vacuous(空虚)な PASS や、実行経路が実は skip しているのに「PASS」とだけ報告される緑が積み重なります。

この問いは、以前 Zenn に書いた「AIの時代だからこそアーキテクチャを意識しよう」の最後に、「壊れたら終わりの一線を、どのテストで固定するか——それだけで1本の記事になる深さがある」と書いて踏み込まずに残した宿題でもあります。この本は、その回収です。

//本書の問いと目次

実装・テスト・検証を AI が担うとき、何を「実装から独立した正解」として残すか。

各技法(property / オラクル独立 / mutation / model-based / round-trip / differential / 検証の分離とゲート)は、この一つの問いへの答えとして並びます。単なる fast-check 入門ではありません。全体を貫くのは「独立性をどこに置くか」という一つの設計問題です。

はじめに

緑なのに壊れている

「テストが通る」と「仕様が守られる」は別。AI が実装を先に読んでテストを書くと "追認" が起きる。本書の問い=何を独立した正解として残すか、を提示する。

第0章

「独立した正解」とは何か — 独立性をどこに置くか

技法のカタログに入る前に、地図を2枚置く(下記)。以後の技法は「便利なテスト手法」ではなく「正解を実装から独立させる、その置き方の違い」として読める。実装と検証が同じ間違い(共通原因)を共有しないこと——ここに本書の背骨がある。

第1部 — 正解を「値」で残す

1章 1つの不変条件を値で攻める無料先行章

型で守る/値で攻める。守りきれない実行時の一線を property test で固定する(fast-check)。

2章 オラクルを実装から独立させる

追認しにくい property の書き方。実装をなぞった期待値ではなく「関係」で書く。弱める変更が diff に残るようにする。

3章 テストが弱められたことを検出する

vacuous(空虚)な PASS の診断。mutation testing で、テスト自身の検出力を試す。

第2部 — 状態と変換の正解を残す

4章 状態機械を丸ごと攻める — model-based testing

操作列を生成し「到達不能」を反例探索する(手書きループ → 公式の stateful API)。1章=不変条件1本 / 4章=状態機械全体、と役割を分ける。

5章 往復と対称性

round-trip / metamorphic / differential で、オラクルなしに正解を縛る。

6章 複数の不変条件が干渉するとき

複数の一線が互いに干渉するときの、生成器と property の設計。

第3部 — AIに検証させる時代の「ゲート」

7章 生成経路と検証経路を独立させる

実装する側と検証する側を分ける。別の目的・文脈・実行経路を通すことが効く(model / context / prompt / execution independence)。

8章 テスト変更を許可する条件とレビューゲート

diff に「何を犠牲にして緑にしたか」を残す。テストを緩める変更と、実装を直す変更を取り違えない。

9章 CIに残す verdict と evidence

silent failover / skip / 握りつぶし / vacuous PASS を検出する。結論(verdict)と根拠(evidence)を分けて残す。

終章

ケーススタディ

実プロジェクトで「緑を疑う」を回した記録(合成化)。

付録

実行可能な本文(教材コード・最小構成)

各章のコードが動く最小の例。バグ版で赤・修正版で緑になる実行結果を evidence として同梱する。

地図1: 正解の独立のさせ方

正解の作り方独立性代表例
実装の出力や同じ計算から期待値を作る弱い(追認しやすい)実装をなぞった example テスト
実装とは別の関係を書く中〜強property / metamorphic
別実装と比較するdifferential
簡潔なモデルと比較するmodel-based
意図的に壊して検出力を測るメタmutation testing
制約を実行環境で強制する構造architecture test / CI gate
結論と根拠を分離して残す運用verdict / evidence

地図2: 何から独立させるのか

独立対象典型的な失敗
実装から独立実装を読んで期待値を書く
アルゴリズムから独立同じ計算をテスト側でも再実装する
生成モデルから独立同じ生成器が自己レビューする
コンテキストから独立実装理由まで読んで追認する
実行経路から独立test command 自体が skip している
verdict から独立「PASS」という文字だけを信じる

//無料先行章(全文)

第1部 1章

この章は、買い切りの技術書の無料先行章です。本を買うかどうか決める前に、中身の深さと、借り物でない手触りを確かめてもらうために置いています。

以前 Zenn に「AIの時代だからこそアーキテクチャを意識しよう」という記事を書きました。その最後で、「壊れたら終わりの一線を、どのテストで固定するか」という問いは「それだけで1本の記事になる深さがある」と書いて、踏み込まずに終えました。この章はその回収です。

AIにコードを書かせ、AIにテストを書かせていると、必ず一度は見る壊れ方があります。落ちたテストの期待値を、実装に合わせて書き換えて緑にする。あるいは、AIが実装を先に読んでから、その実装の挙動をそのまま固定するテストを書いてしまう。どちらも、テストは通ります。通りますが、守りたかった一線は守られていません。

この章を読み終えると、あなたは自分のドメインの「絶対に壊れてはいけない1つの不変条件」を、AIが実装をそのまま追認しにくい property test として書けるようになります。「絶対に追認できない」テストはありません——追認するにはテスト側を目に見えて弱めるしかない、という状態に持っていくのがこの章のゴールです。使う道具は fast-check 一つ、言語は TypeScript です。

この章のコードはすべて合成の最小例です。自分のプロダクト開発で反復して踏んだ失敗の「型」を、注文ドメインという誰にでも通じる題材に翻訳して示します。実際のドメインやログは出しません。他人の匿名化ログは、読者から足場を奪うだけだからです。

AIが書いたテストが「実装の追認」になる瞬間

題材はどこにでもある注文です。仕様に、絶対に譲れない一線が1つあるとします。

確定した注文の金額は、二度と変わらない。

確定前の下書き状態なら明細を足したり消したりできる。しかし一度確定したら、金額は凍結される。会計とも在庫とも突き合わさっている数字なので、確定後に動いたら整合が崩れます。これが「壊れたら終わりの一線」です。

AIに Order を実装させます。返ってきたのはこういうコードでした。

// order.ts
export type OrderStatus = "draft" | "confirmed" | "cancelled";

export interface OrderLine {
  readonly sku: string;
  readonly quantity: number;
  readonly unitPrice: number;
}

export class Order {
  private _status: OrderStatus = "draft";
  private readonly _lines: OrderLine[] = [];

  get status(): OrderStatus {
    return this._status;
  }

  get total(): number {
    return this._lines.reduce((sum, l) => sum + l.quantity * l.unitPrice, 0);
  }

  addLine(line: OrderLine): void {
    this._lines.push(line); // ← 確定後もガードしていない(これがバグ)
  }

  confirm(): void {
    if (this._status !== "draft") {
      throw new Error(`確定できるのは draft の注文だけです: ${this._status}`);
    }
    this._status = "confirmed";
  }

  cancel(): void {
    this._status = "cancelled";
  }
}

confirm() には「draft 以外からは確定できない」というガードが入っています。目に見える正しさはあります。ところが addLine() には status のチェックがありません。確定した後でも明細を足せてしまう。守りたかった一線が、静かに開いています。

続けて、AIに「このクラスのテストを書いて」と頼みます。AIは実装を読んでからテストを書くことが多い。読むと、addLine は status を見ずに明細を積む、と書いてある。だからAIはこういうテストを書きます。

it("明細を追加すると合計が増える", () => {
  const order = new Order();
  order.addLine({ sku: "APL-001", quantity: 2, unitPrice: 500 });
  order.confirm();
  order.addLine({ sku: "BRW-014", quantity: 1, unitPrice: 300 }); // 確定後に追加している
  expect(order.total).toBe(1300); // 実装が返す値をそのまま期待値にした
});

このテストは通ります。緑です。しかしこのテストは、確定後に明細を足せてしまうバグを「正しい仕様」として固定していますtotal が 1300 になることを期待している。つまり「確定後も金額は動く」と宣言している。守りたかった一線と正反対のことを、テストが保証してしまいました。

これが追認です。実装が先にあり、テストが後から実装に姿を合わせる。実装が間違っていれば、間違いをそのまま固定したテストができあがります。

追認は、もっと露骨な形でも起きます。あなたが先に正しいテストを書いたとします。

it("確定後に明細を追加すると拒否される", () => {
  const order = new Order();
  order.confirm();
  expect(() =>
    order.addLine({ sku: "BRW-014", quantity: 1, unitPrice: 300 }),
  ).toThrow();
});

これは落ちます。実装が拒否しないからです。ここで「テストを通して」とAIに頼むと、AIには二つの道があります。実装を直すか、テストを書き換えるか。落ちたテストの期待値を実装に合わせて書き換えるほうが、局所的で安全に見える。toThrow() を消し、expect(order.status).toBe("confirmed") あたりに書き換えれば、テストは緑に戻ります。一線は開いたまま、レビューには「テスト、通りました」とだけ表示されます。

なぜ example テストだけでは不変条件を守りきれないのか

いま見た二つの失敗は、どちらも example テスト、つまり「具体的な1ケースを書き下すテスト」で起きています。example テストが悪いわけではありません(この章の後半では、急所を固定するのに example テストを使います)。守りきれなくなるのは、example テストだけに頼ったときに現れる、次の二つの使い方のときです。

一つ目。オラクル(正解の判定基準)を実装からコピーしてしまう。仕様から独立に決めた期待値(たとえば税込は 110 円)なら、それは強いオラクルです。弱いのは、toBe(1300) の 1300 のように、実装が返す値や同じ計算からコピーした期待値のとき。オラクルが実装のコピーになると、実装の間違いはそのままテストに透過します。AI に実装とテストの両方を書かせると、これが起きやすい。

二つ目。example テストは書いた人が思いついたケースしか踏まない。「確定 → 追加 → 取消 → もう一度追加」のような操作の並びは、思いつかなければテストされません。不変条件というのは本来、「どんな操作の並びでも」成り立つべき、という全称の主張です。example はそのうちの1つの目撃例にすぎない。目撃していない並びに反例があっても、example テストは何も言いません。

ここを埋めるのが property based testing です。考え方を一つ入れ替えます。

「型で守る」と「値で攻める」

不変条件を守る手段は、大きく二つに分かれます。

型で守る。不正な状態を表現できないようにする。書き換えの入口をメソッドに絞る。コンパイル時にエラーにする。強力ですが、届く範囲に限りがあります。「確定済みかどうか」は実行時の状態です。Order という型は、draft でも confirmed でも同じ Order です。型を見ても、いまこのインスタンスが確定済みかは分からない。ConfirmedOrder という別の型に分ける設計もありますが、状態遷移のあるドメインをすべて型に押し込むのは現実には難しく、既存コードを幽霊型で組み替えるのも割に合わないことが多い。

値で攻める。禁止したい状態へ「到達しようと試みる」値を大量に生成し、探索した範囲では一度も到達しないことを確かめる。型が届かなかった領域を、値でパトロールする。これが property based testing のやることです。

この章のメンタルモデルは、この一行に畳めます。

不変条件は、型で守れる分は型で守る。守りきれない実行時の一線は、値で攻めて守る。

「守る」と「攻める」は反対語に見えますが、同じことです。自分のコードに向かって、壊しにいく値を投げ続ける。壊れなければ、その一線は(投げた範囲で)守られている。

fast-check で不変条件を"値で攻める"(手順)

ここから手を動かします。順に踏めば、あなたのコードでも同じことができます。

1. 守る一線を1つに絞る

まず一線を1つだけ選びます。「確定した注文の金額は二度と変わらない」。関係のない別々の一線を1本のテストに詰め込まない。落ちたときに「どの一線が破れたか」が分かるようにするためです。

ひとつ注意があります。1つの一線でも、突き詰めると複数の不変条件に分かれることがあります。この「金額の凍結」も、2つの主張になります。

  • 不変条件A: 一度確定した金額は、その後の状態(取消も含む)に関わらず変わらない。
  • 不変条件B: draft 以外の注文には、明細を追加できない。

AとBは別の主張です。名前を分けて意識しておくと、落ちたときにどちらが破れたかが分かります。この章では後で1本の property にまとめますが、A と B の assertion は別々に置きます。

2. ドメインを最小で組む

守る対象を、DBもフレームワークも介さない最小の形で組みます。さきほどの Order クラスがそれです。property test は何百回も回るので、Docker や外部I/Oに依存させない。純粋なオブジェクトかインメモリのフィクスチャで完結させます。速さは正義で、遅いと numRuns を上げられません。

3. 状態遷移の不変条件では、arbitrary は「状態」でなく「操作列」を作る

状態が遷移するドメインの不変条件を攻めるとき、ここが property test でいちばん間違えやすい所です。生成器(arbitrary)が作るべきは「状態」ではなく、「その状態へ至る操作列」です。(この指針は状態遷移の不変条件に限った話です。後半で扱う往復のような別種の不変条件では、整合的な「状態」を直接生成してかまいません。)

なぜか。禁止したい状態を直接でっち上げて生成すると、ドメインが本当は到達しない状態までテストしてしまい、偽陽性が出ます。逆に、生成した状態がドメインの整合性を満たしていないと、テストは不変条件ではなく「壊れた入力をドメインが拒否すること」を検査するだけになり、偽陰性になります。どちらも、テストが嘘をつきます。

正しくは、既知の正しい初期状態(空の下書き注文)から始めて、ドメイン自身の操作を並べて生成します。こうすれば、生成されるどの状態も、ドメインが実際に到達しうる状態です。

import fc from "fast-check";
import { describe, expect, it } from "vitest";
import { Order, type OrderLine } from "./order";

// 注文に対して打てる操作。
type Op =
  | { readonly kind: "addLine"; readonly line: OrderLine }
  | { readonly kind: "confirm" }
  | { readonly kind: "cancel" };

const lineArb: fc.Arbitrary<OrderLine> = fc.record({
  sku: fc.constantFrom("APL-001", "BRW-014", "CFE-220", "DNM-330"),
  quantity: fc.integer({ min: 1, max: 10 }),
  unitPrice: fc.integer({ min: 1, max: 100_000 }),
});

// 1つの操作の生成器。fc.oneof で「明細追加 / 確定 / 取消」から選ぶ。
const opArb: fc.Arbitrary<Op> = fc.oneof(
  lineArb.map((line): Op => ({ kind: "addLine", line })),
  fc.constant<Op>({ kind: "confirm" }),
  fc.constant<Op>({ kind: "cancel" }),
);

fc.record はオブジェクトを、fc.integer は範囲つき整数を、fc.constantFrom は候補からの1つを生成します。fc.oneof は複数の生成器から1つを選ぶ。これらを組んで「1操作」の生成器を作り、次のステップで fc.array に包んで「長さ1〜12の操作列」にします。

補足として、生成器を状態に依存させる書き方もあります。「いま edge が1本もない図に deleteEdge を生成しない」といった、その時点の状態で適用可能な操作だけを出す作り方です。今回は操作列を平らに生成し、適用可能かどうかは次の property 側で捌きます。どちらでもよく、ドメインの拒否ロジックが素直なら平らな生成のほうが読めます。

もう一つ補足を。この章では操作列を手書きのループで回します(そのほうが仕組みが見えるため)。ただし実務では、fc.commands によるモデルベース検査という選択肢があります。各操作を「適用条件 check と実行 run を持つコマンド」として定義し、fast-check にコマンド列を組ませて回す、状態遷移テスト用の公式のやり方です。考え方は同じ(初期状態から操作を積む)で、定型化されている。詳しい読者の「なぜ公式の stateful API を使わないのか」という問いには、ここで先回りしておきます——本編で扱います。

4. property で不変条件を書く

いよいよ本体です。ステップ1で分けた2つの不変条件を、1本の property で毎ステップ観測します。

不変条件Bを書くには、「追加できない」をコードで表す必要があります。確定・取消後の addLine は、黙って何もしないのではなく、専用の例外を投げて拒否する——これを仕様として先に決め、order.ts に置いておきます。

// order.ts に追加する。実装(addLine)はまだこれを投げない——それが捕まえるバグ。
export class OrderFrozenError extends Error {
  constructor(status: OrderStatus) {
    super(`確定・取消後の注文には明細を追加できません: ${status}`);
    this.name = "OrderFrozenError";
  }
}

型を先に置くだけで、実装はまだ何も変えていません。addLine は相変わらず status を見ずに積む。その食い違いが、これから property で捕まえるバグです。

property はこうなります。import { Order, OrderFrozenError } from "./order"; を足して、長さ1〜12の操作列を回します。

describe("不変条件: 確定後の追加は拒否され、確定金額は不変", () => {
  it("property: 確定後の明細追加は拒否され、確定金額は不変", () => {
    fc.assert(
      fc.property(fc.array(opArb, { minLength: 1, maxLength: 12 }), (ops) => {
        const order = new Order();
        let confirmedTotal: number | null = null;

        for (const op of ops) {
          switch (op.kind) {
            case "addLine":
              if (order.status === "draft") {
                order.addLine(op.line);
              } else {
                // 確定/取消後の追加は「攻め」。拒否され、かつ total が動かないこと。
                const before = order.total;
                expect(() => order.addLine(op.line)).toThrow(OrderFrozenError); // 不変条件B
                expect(order.total).toBe(before);
              }
              break;
            case "confirm":
              if (order.status === "draft") {
                order.confirm();
                confirmedTotal = order.total; // 確定した瞬間の金額を記録する
              }
              break;
            case "cancel":
              order.cancel();
              break;
          }
          // 一度でも確定したら、その後の状態に関わらず total は記録値から動いてはならない。
          if (confirmedTotal !== null) {
            expect(order.total).toBe(confirmedTotal); // 不変条件A
          }
        }
      }),
      { numRuns: 300 },
    );
  });
});

このコードの効きどころを4つ挙げます。

オラクルを実装のコピーにしていないtoBe(1300) のような、人が計算した固定値を書いていません。不変条件Aの判定基準は confirmedTotal、つまり「確定した瞬間に実装自身が出した値」です。その後どんな操作を打とうと、この値から動かないこと、という関係だけを主張しています。AIがこの property を追認しようとしても、書き換えられる魔法の数字がありません。追認するには判定ロジックを目に見えて弱めるしかなく、それはレビューに映ります。

攻めを避けずに、拒否されることを主張する。確定後の addLine を property 側で避けたりはしません。むしろ、わざと呼びます。呼んで、toThrow(OrderFrozenError) で「拒否されること」を確かめ、さらに total が呼ぶ前(before)から動いていないことまで見る。これが不変条件Bです。一方 confirmif (order.status === "draft") の内側でだけ呼びます。確定済みに confirm() すると別の例外で落ちる実装で、そこは守りたい一線ではないからです。ステップ3の「到達しうる状態だけをたどる」を、攻めるべき所は攻め、そうでない所は避ける、と使い分けています。

「二度と変わらない」を状態で絞らない。不変条件Aの判定を if (confirmedTotal !== null) にしているのは意図的です。ここに && order.status === "confirmed" を足したくなりますが、そうすると confirm → cancel → addLine の並びが素通りします(取消後は判定が飛ぶため)。「二度と変わらない」は、確定後どんな状態になっても——取り消されても——金額が動かないことを含みます。だから状態を問わず、一度でも確定していれば毎回見ます。

毎ステップ観測している。不変条件Aをループの最後で確認しています。「操作列を全部流し終えてから最後に1回」ではなく、1操作打つごとに見る。確定した直後に破れる場合も、5操作後に破れる場合も、破れた瞬間に捕まえます。

numRuns は、fast-check が探索する操作列の本数です。既定は 100。ここでは 300 にしています。多く探索するほど珍しい並びに当たる確率が上がりますが、そのぶん時間がかかる。速い純ドメインなら数百から数千まで上げられます。CIで回すなら、遅いテストにしないことと引き換えです。

5. 走らせて、壊す

この property を、さきほどのバグ入り Order(addLine が status を見ずに積む実装)に対して走らせます。落ちます。fast-check は落ちたときに、ただ「失敗」と言うだけではありません。反例を縮小(shrink)して、いちばん小さい失敗ケースを提示します。出力はこうなります。

Property failed after 1 tests
{ seed: ..., path: "...", endOnFailure: true }
Counterexample: [[{"kind":"confirm"},{"kind":"addLine","line":{"sku":"APL-001","quantity":1,"unitPrice":1}}]]
Shrunk 5 time(s)

Hint: Enable verbose mode in order to have the list of all failing values encountered during the run
Caused by: AssertionError: expected function to throw an error, but it didn't

反例は「空の下書きを確定して、そのあと明細を1つ足そうとした」という2操作です。実装は拒否せず黙って明細を積んでしまうので、expect(() => order.addLine(...)).toThrow(...) が「投げるはずが投げなかった」で落ちます。正直に言えば、この最小例だけなら、さきほどの example を1本書けば人間でも捕まえられます。property based testing が本当に効いてくるのは、ここから先——状態と操作の組み合わせが増え、「確定 → 取消 → 復元 → 編集 → …」のように、どの並びを試すべきかを人間が列挙しきれなくなってからです(本編の状態機械で扱います)。それでも、この小さな例で shrink の効きは見えます。quantityunitPrice も生成範囲の下限 1 まで縮んだ最小形で返ってくる。機械が見つけた反例を、原因が読める最小形まで削って返す——組み合わせが増えるほど、この価値は大きくなります。

seedpath は実行ごとに変わる値なので伏せています。手元では、出力された seed をそのまま fc.assert(..., { seed, path }) に渡せば、同じ反例に一発で戻れます。

緑にする2つの道 — 追認か、修正か

反例が出ました。ここが分岐点です。冒頭で書いた「落ちたテストを実装に合わせて書き換えて通す」の誘惑が、property test でも顔を出します。

追認の道。property を弱める。else 側の expect(() => order.addLine(op.line)).toThrow(OrderFrozenError) を消す、あるいは確定後は addLine を呼ばないよう property 側で避ける。不変条件Aの判定を状態で絞って確定後を見ないようにするのも、同じ穴です。どれもテストは緑に戻り、一線は開いたままです。property test は example テストより追認しにくいだけで、追認できないわけではありません。property は「仕様をレビュアーの言葉で書いた宣言」であって、実装に合わせて書き換える対象ではない。落ちたら、まず疑うのは実装です。

修正の道。実装を直します。§4で仕様として置いた OrderFrozenError を、addLine が実際に投げるようにする。ガードは3行です。

// Order クラスの addLine
addLine(line: OrderLine): void {
  if (this._status !== "draft") {
    throw new OrderFrozenError(this._status);
  }
  this._lines.push(line);
}

これで property は緑になります。ここが大事です。同じ property が、テストを一文字も緩めずに、実装を直しただけで赤から緑に変わった。追認の道でも緑にはできますが、そのときは diff にテストの変更が残る。実装を直したのか、テストを緩めたのか、diff が証言します。AIが緑を報告してきたら、緑そのものではなく「何を犠牲にして緑にしたのか」を diff で問えます。

テストを強くする — vacuous な成功を避ける

実装を直したら property は通ります。ただし、通る property には静かな落とし穴があります。vacuous(空虚)な成功です。

いまの property は、確定後の addLine で不変条件Bを、確定後の各ステップで不変条件Aを見ています。もし生成される操作列が、たまたまほとんど「確定後の addLine」を含まなかったら、攻めの assertion にほとんど入りません。property は「攻めるべきことを一度も攻めずに」通る。緑なのに、何も守っていない。

これは仮説ではなく、property test で最もよく踏む静かな失敗です。だから「通った」だけでは足りません。まず、どれだけ攻めているかを診断します。fast-check には fc.statistics があり、生成物の分布を出力してくれます。

// 操作列が「確定後に addLine を試みる」瞬間を含むか判定する(観測用の純関数)。
// ※ 一度確定したら、その後の addLine は取消後であっても「攻め」として数える
//   (confirm → cancel → addLine も攻めに含める。確定金額不変が主役だから)。
function attacksAfterConfirm(ops: readonly Op[]): boolean {
  let confirmed = false;
  for (const op of ops) {
    if (op.kind === "confirm") confirmed = true;
    else if (op.kind === "addLine" && confirmed) return true;
  }
  return false;
}

it("観測: 生成された操作列のうち、確定後に addLine を攻めた割合を測る", () => {
  fc.statistics(
    fc.array(opArb, { minLength: 1, maxLength: 12 }),
    (ops) => (attacksAfterConfirm(ops) ? "確定後にaddLineを攻めた" : "攻めていない"),
    { numRuns: 1000 },
  );
});

これは合否を出さず、こういう分布を印字します。

確定後にaddLineを攻めた......56.90%
攻めていない.............43.10%

半分強が、確定後の addLine を含んでいる。生成器が偏っていないことは、これで分かります。もし数%しか攻めていなかったら、生成器の重みを変える(fc.oneof の重みづけで confirmaddLine を厚くする)か、操作列の長さを伸ばす。

ただし、ここに落とし穴の二段目があります。fc.statistics は「別に生成した分布」を見せているだけで、さっきの property 本体が攻めのケースを踏んだ保証にはなりません。statistics は診断であって、property の中身を担保する仕組みではない。分布が健全でも、たまたま property の 300 本に攻めが薄い、という可能性はゼロにはなりません。

だから、最低限の攻撃経路は example test で決め打ちして固定します。生成器の重みがどう転んでも、この経路だけは必ず踏む、という保険です。property(広く探索)と example(急所を確実に踏む)は役割が違うので、両方置きます。

it("確定 → 追加 は拒否され、total は 0 のまま", () => {
  const order = new Order();
  order.confirm();
  const before = order.total;
  expect(() =>
    order.addLine({ sku: "APL-001", quantity: 1, unitPrice: 1 }),
  ).toThrow(OrderFrozenError);
  expect(order.total).toBe(before);
});

it("追加 → 確定 → 追加 は拒否され、確定金額は不変", () => {
  const order = new Order();
  order.addLine({ sku: "APL-001", quantity: 2, unitPrice: 500 });
  order.confirm();
  const confirmed = order.total; // 1000
  expect(() =>
    order.addLine({ sku: "BRW-014", quantity: 1, unitPrice: 300 }),
  ).toThrow(OrderFrozenError);
  expect(order.total).toBe(confirmed);
});

it("確定 → 取消 → 追加 でも確定金額は動かない(confirm→cancel→addLine の抜け道)", () => {
  const order = new Order();
  order.addLine({ sku: "APL-001", quantity: 1, unitPrice: 1000 });
  order.confirm();
  const confirmed = order.total; // 1000
  order.cancel();
  expect(() =>
    order.addLine({ sku: "BRW-014", quantity: 1, unitPrice: 300 }),
  ).toThrow(OrderFrozenError);
  expect(order.total).toBe(confirmed);
});

3本目が、さきほど不変条件Aの判定を if (confirmedTotal !== null) にした理由そのものです。確定して、取り消して、それから追加する。この並びでも確定金額は動いてはならない。property は多数の操作列を探索して禁止状態への経路を見つけられないことを確かめ、example はこういう「言葉にできる急所」を確実に踏む。緑を信じる前に、緑が何を踏んだかを、診断(statistics)と固定(example)の両輪で確かめます。

もう一つの形: 往復(round-trip)で追認を殺す

不変条件は状態遷移だけではありません。もう一つ、property が強い定番の形が往復(round-trip)です。「直列化して復元すると元に戻る」「取り消して再実行すると元に戻る」。往復が成り立つべき所は、たいてい壊れたら終わりの一線です。保存したファイルが読み込むと欠ける、Undo すると元に戻らない、といった壊れ方は、静かに進行してユーザーのデータを削っていきます。

この節は往復 property の"形"を示す概念例です。以下に出す orderArb / serialize / parse の実装は本章では省き、パターンだけを見せます(手元で動かせる完成コードは、前節までの操作列 property です)。

そして往復こそ、AIの追認が最も効きやすい場所です。AIがシリアライザを書き、パーサを書く。パーサが1つのフィールドを黙って落としていても、example テストがそのフィールドを設定していなければ、テストは通ります。人が思いつく example は、たいてい全フィールドを埋めません。

// 往復不変条件: parse(serialize(order)) は元の order と等価。
it("property: 直列化して復元すると、元の注文に一致する", () => {
  fc.assert(
    fc.property(orderArb, (order) => {
      const restored = parse(serialize(order));
      expect(restored).toEqual(order);
    }),
  );
});

ここでも鍵は生成器です。orderArb は、全フィールドを埋めた、整合性の取れた注文を生成しなければなりません。フィールドを埋めない生成器は、パーサがそのフィールドを落としていても気づけない。逆に、参照の壊れた注文(存在しない明細を指す等)を生成すると、往復ではなく「壊れた入力をどう扱うか」を測ってしまう。生成器が「妥当な値」を作れているかどうかに、この property の価値が丸ごと乗っています。

ステップ3では「状態遷移の不変条件では、状態でなく操作列を生成せよ」と述べました。往復はこれと矛盾しません。往復が見るのは状態遷移ではなく「符号化と復号の対称性」で、こちらでは整合的な「状態」そのものを直接生成してよい。共通するのは一点だけ——生成する値は、ドメインが本当に取りうる妥当な値でなければならない、ということです。状態遷移では「操作列でしか妥当な状態に到達できない」から操作列を作り、往復では「妥当な状態を直接組める」から状態を作る。目的は同じで、手段が違うだけです。

前後の空白を保ったラベルが往復で削れる、空値と未設定が復元後に取り違わる、といった細部の欠けは、toEqual の全等価と、細部まで揺らす生成器の組み合わせでしか捕まりません。往復の property は、その細部を人手のオラクルなしに固定します。

この章で持ち帰るもの

手順(そのまま自分のコードに移せます):

  1. 守る一線を1つ選ぶ。一線が複数の不変条件に分かれるなら(ここでは「金額不変」と「追加拒否」)、それぞれを別々の assertion として名前で分ける。
  2. DBもI/Oも介さない最小ドメインで組む。
  3. 状態遷移の不変条件では、生成器は「状態」でなく「操作列」を作る。初期状態からドメインの操作で到達させ、整合性を保つ(往復のような別種の不変条件では、妥当な状態を直接生成してよい)。
  4. fc.assert(fc.property(...)) で、さまざまな長さ(ここでは 1〜12)の操作列を回し、不変条件を毎ステップ観測する。オラクルに実装のコピー(固定の期待値)を使わず、関係で書く。禁止した操作は避けずに、拒否されること自体を assertion にする。
  5. 落ちたら、shrink された最小反例を読む。緑にするときは、実装を直す道とテストを緩める道を取り違えない(diff が証言する)。
  6. 通ったら fc.statistics で「実際に攻めた割合」を診断し、最低限の攻撃経路は example test で固定して vacuous な成功を防ぐ。

メンタルモデル(別のドメインにも当てはまります):

不変条件は、型で守れる分は型で守る。型が届かない実行時の一線は、値で攻めて守る。

「型で守る」は不正な状態を表現できなくする方向、「値で攻める」は禁止した状態へ到達しようと試み、探索した範囲で一度も届かないことを確かめる方向です。この二つは反対ではなく、担当する領域が違うだけです。property test は、型が届かない領域のパトロールです。

この先(本編)へ

この章では、一線を1つに絞って、それを property で固定しました。現場の難しさはここから先にあります。壊れたら終わりの一線が複数あり、互いに干渉するとき、生成器と property をどう組むか。そして、AIにコードもテストも書かせ、AIに検証まで手伝わせて、全部 PASS が返ってきたとき、その緑をどう疑うか。緑は、守れている証拠にも、追認の産物にもなります。この見分けを、本編で扱います。

まずはこの章の手順を、あなたのドメインの「絶対に壊れてはいけない1つ」に当ててみてください。守る一線を1つ選んで、そこへ至る操作列を生成し、値で攻める。緑になったら、その緑の中身を fc.statistics で測る。それだけで、AIが書いたテストの緑に、一段の疑いを持てるようになります。

//この続きを本で読む

先行章はここまでです。第2章以降では、property 自体のオラクルをどう実装から独立させるか、mutation testing でテスト自身の検出力を測る方法、model-based testing、生成経路と検証経路の分離、CI に残す verdict と evidence まで進みます。

予定価格(税込)
3,980

買い切り。1回の支払いで全編。サブスクではありません。

3,980円なら購入を検討したい

完成したら一度だけお知らせします。登録しても購入義務はありません。

登録後、確認メールが届きます。Powered by Buttondown