Skip to content

テスト

Kumiki のテストは 3 種類

  1. reducer test — 純粋関数なので入力と期待出力で検証
  2. effect mock — capability ガード境界でモックして dispatcher 動作を検証
  3. episode replay — 実運用 trace を mock effect で再生して回帰検出

すべて Kumiki 言語の中で記述する(外部テストフレームワーク不要)。

8.1 テスト定義レイヤ

test-def ::= 'test' identifier '=' test-expr
test-expr ::= reducer-test | tile-test | episode-test | property-test

test 定義は 6 つ目のレイヤ。CRDT graph に格納され、kumiki test で実行される。本番ビルドには含まれない。

実装状況. 実装済み:reducer-testtile-testproperty-testProperty テスト)、kumiki test ランナー(名前 / prefix* フィルタ、テストごとの時間表示 (1ms) / (100 cases, 23ms)--coverage--watch)、kumiki fix --auto-patch <test-name>失敗テストからの修正)、expectワイルドカード<any-id> / <slots.X>ワイルドカード)、reducer-test 内の effect 結果モックgiven.mocksEffect mock)、および episode-testEpisode リプレイ、ランタイムの Episode Loop に支えられる)。ランナーは PASS / FAIL 行と、失敗時に expected / actual / diff at <path>、およびスカラーのリーフを特定できる場合は値矢印("a" -> "b")を表示する。

8.1.1 テスト本体が書く名前

テスト本体は式ではなくスキーマである。したがって各位置は、それが何であるかに従って解決される:

位置名前の正体報告
given.slots / expect.slots のキーslotE0103
given.event.targettypeui.* のとき)tileE0105
expect.effects の要素effect(宣言されたもの、または標準 effect)E0104
given.mocks のキーeffectE0104
すべての式——slot の値、given.inexpect.panicinvariant、モックのペイロード、episode-testexpect式レイヤの規則どおりE0103 / E0116 など

given.event.type が名指すのは event であり、その語彙は式レイヤではなくトリガ文法のものである。target が tile なのはその event が ui.* のときだけで、timer で駆動される reducer は timer 名を書き、effect の結果で駆動される reducer には書く名前が無い。どちらのフィールドも生成されたテストには届かない——payload は event のその他のフィールドから作られ、ランナーが適用する reducer はテスト自身の target である——ので、この規則はテストが何をするかではなく何を言っているかについてのものである。

テスト本体から slot は読める(その slot が保持する値になる)。for-all の名前は giveninvariant の両方でスコープに入り、generator が宣言した型を持つ。run-reducer(<reducer>) が取るのは値ではなく reducer 名であり、呼べるのは property-test の invariant だけである(§8.3):trial の束縛を読む形に lowering されるため、それ以外の場所では、生成モジュールがどのテストも結果を出す前に死ぬ。

2 つの位置は名前ではなくを検査する。認識できない形に対して lowering が別の主張をしてしまうからである(E0713):reducer-test のモックが ok(...) / err(...) / delay(...) でなければ成功モックになり、expect.effects がリストでなければ「effect は何も emit されなかった」という主張になる。

これらが解決されるまで、テスト本体の名前は何を書いても受理され、lowering は読めないものを捨てていた:何も名指さない slot キーはテストを slot の既定値のまま走らせる——成功しながら、自分が用意していない前提を主張していた。invariant の中の未定義呼び出しはさらに悪い。property ランナーが trial の例外を捕まえて invariant の反証として描画するため、出力は無実のコードを犯人に仕立てていた。

8.2 Reducer テスト

kumiki
test addTodo-basic =
    reducer-test addTodo
        given = {
            slots: {todos: {}, draft: "Hello"},
            event: {type: ui.submit, target: NewTodoForm}
        }
        expect = {
            slots: {todos: {<any-id>: {text: "Hello", done: false}}, draft: ""},
            effects: [persist(<slots.todos>)]
        }

8.2.1 構文

reducer-test ::= 'reducer-test' identifier
                 'given'  '=' '{' 'slots' ':' record-lit ',' 'event' ':' event-lit '}'
                 'expect' '=' '{' 'slots' ':' record-lit ',' 'effects' ':' effect-list '}'

event-lit ::= '{' 'type' ':' event-pattern (',' kv)* '}'
effect-list ::= '[' (effect-call (',' effect-call)*)? ']'

8.2.2 ワイルドカード

<any-id> は「任意の生成 ID」、<slots.todos> は「実行後の slot 値への参照」。

8.2.3 バッチ規則はここにも適用される

reducer テストは実行中のアプリの挙動を表明するものなので、refinement が拒否したバッチはすべての slot を given の値のまま残し、effect も発行しない(batching)。拒否は expect ではなく console.error に報告される。このティアには errorIncludes に相当するものが無いため、expect ブロックだけでは「バッチが拒否された」と「reducer が何もしなかった」を区別できない。

8.2.4 panic を期待

kumiki
test addTodo-empty =
    reducer-test addTodo
        given = {slots: {todos: {}, draft: ""}, event: {type: ui.submit, target: NewTodoForm}}
        expect = {panic: "draft cannot be empty"}

8.3 Property テスト

kumiki
test toggle-is-involution =
    property-test
        for-all = {todoId: TodoId, todos: Map(TodoId, Todo)}
        given = {slots: {todos: todos}, event: {type: ui.click, target: TodoRow, el: {todoId: todoId}}}
        invariant = run-reducer(toggle).run-reducer(toggle).slots.todos == todos

8.3.1 構文

property-test ::= 'property-test'
                  'for-all'    '=' record-lit       ; 生成する変数
                  'given'      '=' record-lit
                  'invariant'  '=' expr
                  ('count'     '=' int)?            ; 試行回数(デフォルト 100)
                  ('shrink'    '=' bool)?           ; 失敗時の最小化(デフォルト true)

8.3.2 ジェネレータ

各型は自動生成器を持つ:

デフォルト生成
Int-1000 ~ 1000
Float-1000.0 ~ 1000.0
Text0~50 文字、ASCII
Booltrue/false
List(T)0~10 要素
Map(K, V)0~10 要素
Set(T)0~10 要素
Option(T)50% None / 50% Some
Result(T, E)50% Ok / 50% Err
nominal TT の生成器
refinement T where pT を生成して p を満たすまで rejection

カスタム生成器:

kumiki
test foo =
    property-test
        for-all = {x: Int where between(0, 100)}
        ...

8.4 Tile snapshot テスト

tile の構造を期待値と比較:

kumiki
test counter-display =
    tile-test App
        given = {slots: {count: 5}, in: ()}
        expect = column(
                   heading("Count: 5"),
                   row(DecBtn, ResetBtn, IncBtn))

snapshot は深い構造比較。クラス名やスタイルは比較対象外(明示指定したものだけ)。

8.5 Effect mock

effect の戻り値を差し替える:

kumiki
test loadUser-success =
    reducer-test fetchUser-flow
        given = {
            slots: {users: {}},
            event: {type: ui.click, target: LoadBtn, el: {userId: "u1"}},
            mocks: {
                loadUser: ok({id: "u1", name: "Alice", email: "[email protected]"})
            }
        }
        expect = {
            slots: {users: {"u1": Loaded({id: "u1", name: "Alice", email: "[email protected]"})}},
            effects: []
        }

mocks: {effect-name: ok(value) | err(error) | delay(ms, ok(value))} で任意の effect の結果を差し替える。

8.6 Episode replay

実運用で記録した episode log を再生して結果を検証:

kumiki
test bug-2026-05-21 =
    episode-test
        load    = "fixtures/episode-2026-05-21.log"
        mocks   = {
            loadUser: from-log,        # ログに記録された結果をそのまま返す
            persist:  ignore
        }
        expect  = {
            slots-equal: from-log,     # 最終 slot がログの記録と一致
            no-panics: true
        }

8.6.1 episode log の形式

ランタイム で詳述。

8.6.2 用途

  • バグ報告に付随した episode log を fixture にして regression test 化
  • モデル / アルゴリズムを変更した後でも同じ入力で同じ結果が出るか確認
  • スキーマ変更時に旧 log が migration できるか検証

8.7 ランナー

bash
kumiki test                    # 全テスト実行
kumiki test reducer-test       # reducer-test のみ
kumiki test addTodo-*          # ワイルドカードフィルタ
kumiki test --watch            # 変更時に再実行
kumiki test --coverage         # カバレッジ (reducer/effect/tile 単位)

8.7.1 出力

PASS  addTodo-basic        (1ms)
PASS  toggle-is-involution (100 cases, 23ms)
FAIL  counter-display
  expected: column(heading("Count: 5"), row(...))
  actual:   column(heading("Count: 0"), row(...))
  diff at:  [0].text  "Count: 5" -> "Count: 0"

8.7.2 失敗テストからの修正

kumiki fix <file> --auto-patch <test-name> は名前付きテストを実行し、失敗から修正パッチを提案する。--apply を付けると書き込んで再実行する(テストが通るようになったか、他テストが退行したかを報告)。決定論的に証明できるものだけを修復する:

  • ファイルがコンパイルできなければテストは走れない — fix の型エラー修復(did-you-mean の名前修正、/404 欠落)を再利用してテストを走らせる。
  • tile-test / reducer-test が、実際値が一意のソースリテラルである文字列リーフで失敗した場合、そのリテラルを期待値に置換する(出力 のスナップショット事例)。

リテラルでない乖離(数値 slot、誤った演算子、effect リスト不一致)は推測せず diff として報告する。

8.8 統合テスト(ブラウザ駆動)

E2E はランタイム外で実装する。Playwright / Cypress などの既存ツールを使う。Kumiki 側からは:

  • test-id prop をすべての tile に付けられ、data-kumiki-test 属性になる
  • data-kumiki-tile 属性がランタイムから自動付与され、種別を名乗る
  • window.__kumikiApp.live がアプリの slot マップ——シナリオ層とブラウザ層が読む状態オラクルである
javascript
// Playwright 例
await page.locator('[data-kumiki-test=add-btn]').click()
const todos = await page.evaluate(() => window.__kumikiApp.live.todos)
expect(Object.keys(todos)).toHaveLength(1)

__kumikiApp はコンパイルされたモジュール自身が公開する AppShape であり、live はその背後にある slot マップである。テスト用に取られた複写ではなく、ランタイムがそこから描画しているオブジェクトそのものである——読むのは安全だが、書くのは安全ではない。

8.9 設計上の判断記録

判断理由
テストを言語内に書く別言語にすると AI の学習対象が増える
reducer は純粋関数なので入出力比較で十分mock 不要、決定論的
property test を一級市民にreducer の不変条件を構造で検証
episode replay を一級市民に本番バグを自動的にテスト化できる
E2E は外部ツールKumiki のスコープ外、既存ツールを尊重

8.10 ツールによる検証の 3 層

上記の test 定義(言語内テスト)とは別に、ツールチェインは段階的な検証を提供する。各層は前の層が捕まえられないものを捕まえる。check/build が通っても「動く」ことの証明にはならない点が重要である。

コマンド捕まえるもの捕まえないもの
1. コンパイルkumiki check / kumiki build構文・型・参照解決・codegen実行時の挙動
2. ランタイム smokekumiki smokemount 例外・空描画・未処理 rejection(headless DOM に mount し、全 button/input/select を操作)結果の正しさ
3. 振る舞いアサーションtest 定義 / example 固有テスト「結果が正しいか」(例: select が常に最後の選択肢になる等の非例外バグ)

smoke(層 2)

kumiki smoke <file> は、コンパイル済みアプリを headless DOM(happy-dom)に mount し、初期描画後にすべての操作可能要素へイベントを発火させ、各ステップでランタイム例外・コンソールエラー・未処理 rejection・空描画を監視する。ここでのとは、テキストも、それ自体が内容となる要素(画像、コントロール、ステータス領域)もない描画を指す。空のコンテナが重なっただけの木は描画ではなく白紙である。フォームは、内側のフィールドを埋めた後に直接 submit される。form タイルは submit ボタンを持たないことが多く、持っていても合成クリックで submit されるかどうかは DOM ごとに異なる activation behaviour である——フォームへの直接 dispatch はどの DOM でも同じ意味になり、ボタンのないフォームで ui.submit reducer に到達する唯一の経路でもある。「型は通るが、ランタイムに存在しないメソッドを呼んで操作時に落ちる」「描画されない」といった、従来は人がブラウザで確認していたクラスのバグを自動で検出する。汎用であり、アプリ固有の知識を持たない。

ブラウザでの実描画(CSS レイアウト・実フォーカス等)は headless DOM では再現しきれない。そのための実ブラウザ tier@kumikijs/e2e(Chromium / Playwright)であり、headless DOM tier と同じシナリオ形式で動く。状態 oracle は同じく window.__kumikiApp.live、表示テキストは innerText(可視のみ)。加えてブラウザ限定アサーションを持つ:

  • focused: 指定セレクタが実際にフォーカスされていること(再レンダリング時のフォーカス奪取バグを検出)
  • visible / hidden: 計算済みスタイル上で本当に見えている/いないこと(display:none 等)

このティアでも expect キーと操作の種類は閉じた集合である——scenario ティアのものから errorIncludeskey / hover 操作を除き、上のブラウザ限定名と setProperty を加えたもの。集合外のキーはページを開く前に、種類だけでなく値も含めて拒否される。errorIncludes は「エラーが報告されること」を要求するが、このティアは報告されたエラーをすべて致命として扱うため、未評価のまま放置するのではなく拒否する。また {submit} はイベントの dispatch ではなく requestSubmit() を呼ぶ。constraint validation を含めて実物を走らせることがこのティアの目的だからである。effects(scenario ティアの capability 境界モック)はサポートしない。実 Chromium を実 DOM/CSS に対して走らせることがこのティアの目的であり、黙って無視すれば「リクエストはスタブされている」と信じたまま実際には外へ出ていく fixture ができてしまうため、拒否する。

重い(ブラウザバイナリ)ため既定の CI テストには含めず、フォーカス・レイアウト・実描画の確認や最終検証で使う opt-in 層。結果の正しさは smoke では判定できず、層 3 のアサーションが担う。

@kumikijs/mcp は同等の kumiki_smoke を提供し、AI エージェントが編集後に自己検証できる。

example コーパスガード:コンパイルでなくランタイム真正性

example コーパス(packages/tests)は「壊れた example は決してマージされない」という常設の保証である。全 example がコンパイルすることだけのアサートでは不十分だ:lowering で落ちた値引数はクリーンにコンパイルされマウントもするが、空だが存在するノードを描画する——「コンパイルは通るが実際は壊れている」状態で、層 1 にも層 2 の「空でない/throw しない」基準にも見えない(03-union-and-match の見出しバグが _s.show(undefined) に lower されて緑で出荷されたのはまさにこれ)。よってコーパスガードは dropped-expression クラスについてランタイム真正性もアサートする:

  • 静的 codegen スキャン。 すべての値保持 display tile(heading / text / button / label / link / markdown / image / icon / inputtextarea の value)は値を show(...) で lower する。これらいずれかの位置で落ちた式は厳密なトークン show(undefined) として現れる。Kumiki ソースに undefined リテラルは無いため、このトークンは dropped expression からしか生じ得ない——偏在する良性 undefined(reducer の読み戻しや selector 無し reducer)とは別物のゼロ誤検出センチネルである。いずれかの example の生成 JS にこれが含まれればコーパスは失敗する。
  • 描画 DOM スキャン。 各 example を headless DOM(happy-dom)にマウントし、リテラル "undefined" であるテキストノードを描画しないことをアサートする。センチネルが拾わない経路で DOM に届く生の undefined を捕捉する。

これらはブラウザバイナリ無しで既定 CI で走り、再混入した dropped-expression バグは緑で出荷されずビルドを失敗させる。

シナリオ実行(層 2→3 の橋渡し)と自律ループ

kumiki run <file> <scenario.json>(MCP: kumiki_run_scenario)は、アプリをシナリオで駆動し、毎ステップの構造化 trace を返す。これが「人を介さない生成→実行→観測→修正ループ」の土台になる。

  • 操作(action): {dispatch, payload?}(reducer を名前で発火)/ {clickText} / {click} / {focus} / {blur} / {key, value} / {hover} / {fill, value} / {choose, value} / {navigate} / {submit} / {wait}{focus} {blur} {key} {hover} はセレクタ一致要素に対し実際の DOM イベント——FocusEventvaluekey に持つ KeyboardEventmouseenter——を dispatch するため、ui.focus / ui.blur / ui.key / ui.hover reducer が依存する addEventListener 配線層をシナリオ単独で検証できる。いずれもセレクタ一致要素に対して dispatch する。ランタイムがリスナを張るのがそこだからである。keydown はそこからバブルし、これが ui.key(Container) をフォーカス可能な子孫から駆動できる理由である。focus / blur / mouseenter はバブルしない(ブラウザは 1 つのイベントを伝播させるのではなく、祖先ごとに別々の mouseenter を発火する)。ui.key reducer のペイロードは keycode を運ぶが、この層で設定されるのは key だけである。code は物理キーを指し、"Enter" と書いたシナリオはそれを選んでいないためである。{submit}ui.submit reducer が待ち受けるフォームイベントを dispatch する。form タイルは作者が付けない限り id を持たないため、セレクタはフォーム自身でもその内側の要素でもよい。{wait} はそのステップ本来の settle に指定ミリ秒を上乗せする。debounce の待ち・retry のバックオフ・タイマーはこれで観測する(操作のないステップは settle しない)。
  • 観測: 各ステップ後に state(slot スナップショット)・domTexterrorsemits(発火した effect)を記録。
  • アサーション(expect): { noErrors?, errorIncludes?, state?, domIncludes?, domExcludes? } — 上の操作一覧と同じく閉じた集合である。集合外のキーは無視されるのではなく実行を失敗させ、browser ティアが所有する名前(focused / visible / hidden / animating / elementState、および setProperty 操作)はそのティアを名指しして失敗する。したがって、ブラウザティアのアサーションを含む .browser.jsonkumiki run に渡すと、何も検証しないまま通るのではなく拒否される。headless DOM で答えられるものしか assert していない fixture はそのまま実行される(コーパスに実例がある)。文書自体も同じく閉じている — steps(必須、かつ空でないこと:何も assert しないシナリオが成功を返してはならない)と effects / defaultEffect のみ。したがって steps の綴り間違いは「存在しない」と読まれるのではなく名指しで報告される。シナリオの検証は mount より前に行われ、文書中の問題はすべて一度に報告される。stateslot 状態への部分一致(ドット区切りパス可)。errorIncludesnoErrors の対になるもので、各部分文字列がそのステップ中に報告されたいずれかのエラーに含まれることを要求する。refinement が拒否した reducer バッチ(batching)や、.err reducer が受け取らない effect エラーのように、runtime が報告すること自体が契約であるケース向けである。scenario ティア専用で、browser ティアは報告されたエラーをすべて致命として扱う。DOM テキストではなく状態を検証できるため、「select が常に最後の選択肢になる」ような非例外の振る舞いバグ(人がクリックして気づくクラス)を機械的に検出できる。これは TDD の受け入れ基準(AC)を実行可能にしたものに等しい。
  • effect スクリプト: effects: { <name>: [{outcome, value}, ...] } で HTTP / Storage の結果を順に差し替え、ループを決定論的・ネットワーク非依存に保つ。

なぜ Kumiki でこれが綺麗に成立するか: 状態が明示的(slot)なので oracle が信頼でき、イベントが宣言的(reducer 名)なので正確に駆動でき、effect が capability 境界でモック可能なので再現性がある。エージェントが要件から「アプリ + シナリオ(AC)」を生成し、trace を読んで自己修正することで、人は要件を一度述べるだけでよい。ループの手順は .claude/skills/kumiki-iterate に記述。

8.11 次