第7章 AIへの任せ方/2 本目 / 全 6/約30分

テストをAIに書かせる

こより

テストは正しさの証明じゃなく、壊れたら気づく見張り。わざと壊して、落ちるかまで確かめてみて。

案内役 こより
この術でできるようになること

壊れたら気づける

なぜ要るか

6章までで、ゲームには仕組みが増えました。データ、セーブ、記録、判定。触る場所が増えると、直したつもりで別の場所が壊れます。

6-1でこう書きました。

powerを書き忘れた札がある
→ 黙って undefined になり、比較が全部 false になります。
  気づきにくい壊れ方です。

気づきにくい壊れ方を、機械に見張らせます。 それがテストです。

テストというのは、自分のプログラムの一部を自動で呼び出して、期待した答えが返るかを確かめる小さなプログラムです。人が画面を開いて目で確かめる代わりに、機械が一瞬で全部確かめてくれます。

そして幸運なことに、テストはAIが得意な作業です。 判断ではなく手順だからです。

この術で使う Vitest は、書いたテストをまとめて実行して、通った件数と落ちた件数を出してくれる道具です。テストそのものを書くのはAIに任せられます。

使う道具: Vitest

AIプロンプト

この術の作業をAIへ任せるときは、これをそのまま貼ってください。

index.html の takerOf のテストを vitest で書いて。

確かめること:
- 合計が多い方が取る(自分側・相手側の両方向)
- 誰も置いていない的は、どちらのものでもない
- 同点で、あとに置いた方が取る(自分が後・相手が後の両方向)
- 同点で同じ手番なら、どちらのものでもない
- DECK に無いidを渡したときに落ちること

期待値は DECK から導出して、札のidを直書きしないで。
書いたあと、わざと takerOf を壊して落ちることを確認して、元に戻して。

「確かめること」の中身は、自分の作品で壊れたら困る判定に置き換えてください。

テストは「壊れたら気づく仕掛け」です

先に誤解を解きます。テストは正しさを証明するものではありません。壊れたことに気づくための仕掛けです。

だから目標はこうなります。

目標 中身
前に動いていたものが動かなくなったら、すぐ分かる
× バグが1つもないことを証明する

全部にテストを書く必要はありません。 壊れたら困る場所にだけ書きます。

テストは3種類あります

ここは知識の話です。言葉を知らないとAIに頼めないので、先に押さえます。

テストは「どこまでを通して確かめるか」で3つに分かれます。

種類 何を確かめるか 速さ 壊れやすさ
単体(ユニット) 部品1つを単独で takerOf が正しい取り手を返す 速い(1件0.001秒) 壊れにくい
結合(インテグレーション) 部品をつないで セーブして読み直すと同じ
E2E(エンドツーエンド) 利用者と同じ経路で端から端まで ブラウザで札を押して勝敗が出る 遅い(1件数秒) 壊れやすい

E2E は「端から端まで」の意味です。 中身を直接呼ばず、人がやるのと同じ操作をさせます。だから一番信頼できて、一番遅くて、一番壊れます。

数の比は下が多く、上が少ない

これは「テストピラミッド」と呼ばれる形です。僕のCNPトレカアプリで数えると、実際にそうなっています。

単体      198本   ← 一番多い。実装ファイルの隣に置く
結合       22本
E2E        13本   ← 一番少ない

逆にしてはいけません。 E2Eを増やすと、実行に何分もかかり、たまに落ちるようになり、だんだん結果を見なくなっていきます。

理由は単純で、E2Eは通る/落ちるしか分からないからです。落ちても「どこが悪いか」は教えてくれません。単体テストなら、落ちた1件が壊れた場所を指しています。

この講座のサイトの場合

小さい側の実例も出します。いまこのサイトのテストはこうなっています。

単体   35件   会員の処理(暗号化、登録、認証、退会)
データ 16件   記事と道具箱の整合(idの重複、必須項目、図解の実在)
結合   24件   HTTPの経路を通して画面を組み立てる
E2E     0件
合計   75件   テストの実行 約2秒(準備を含めた全体で約9秒)

E2Eはまだ1件も書いていません。 それで困っていません。

規模が小さいうちは、単体と結合で足ります。 E2Eを足すのは、画面の操作が複雑になってからです。

単体テストは外を触りません

単体テストが速い理由は、外部を一切使わないからです。

使わないもの 代わりにすること
データベース メモリ上の入れ物で代用する
通信 呼ばれたことだけ記録する偽物を渡す
現在時刻 固定の時刻を渡す
乱数 決まった値を返すものを渡す

この「偽物を渡す」仕組みをモックスタブと呼びます。呼び方の違いは気にしなくて構いません。大事なのは、外部を差し替えられる形に作ってあるかです。

差し替えられる形とは、外部を引数で受け取るという意味です。

// 差し替えられない: 中で直接時刻を取っている
function save() { const at = new Date() }

// 差し替えられる: 外から渡す
function save(now) { const at = now }

テストが書きにくいコードは、たいてい外部を中で直接呼んでいます。 テストを書こうとすると、この設計の問題が先に見つかります。それもテストの効果の1つです。

覚えておくと頼みやすい言葉

AIに頼むとき、この言葉を使えると通りが良くなります。

言葉 意味
テストランナー テストを実行する道具(この講座では Vitest)
アサーション 「こうなっているはず」を書く行(expect(...)
フィクスチャ テスト用に用意する前提データ
回帰テスト 前に直した不具合が再発していないかを見るテスト
カバレッジ コードのどれだけをテストが通ったかの割合
フレーキー たまに落ちるテスト。一番厄介な状態

「回帰テスト」が実務では一番出てきます。 7-3で「見落としたものを機械へ移す」と書きますが、あれは全部この回帰テストです。

なおカバレッジは目標にしないでください。 100%にしても壊れは残ります。数字を上げるためのテストは、意味のない見張りになります。

どこに書くか

判断の順番はこれです。上から順に、費用対効果が高い順です。

優先 対象
1 ルールの判定 勝敗、条件、計算
2 データの整合 idの重複、必須項目の欠け
3 保存と復元 セーブして読み直すと同じか
4 画面の見た目 最後。変わりやすいので壊れやすい

1番目から書いてください。 勝敗の判定は、間違うと全部が狂います。しかも入力と出力が明確なので、テストが書きやすいです。

逆に4番目は後回しです。画面はよく変わるので、テストの方が先に壊れます。

僕が今日書いたテストの話

抽象的な話より実例が早いので、この講座のサイトで今日起きたことを書きます。

記事を書いていて、こういう文を入れました。

だから**「同点の的」の扱いを決める場所がありません。**

これは強調が効きません。 日本語だと ** の直後が のとき、Markdownが強調として扱わないルールがあるためです。結果、画面に ** がそのまま出ます。

そして僕は、それを6箇所で見落としていました。 うち4箇所は、すでに公開している記事の中にありました。

目で読んでも気づけないので、機械に見せることにしました。

it('公開済みの術に、強調が崩れて ** が残っていない', () => {
  const broken = WAZA
    .filter((w) => w.status === 'published' && w.html.includes('**'))
    .map((w) => `${w.id}: ...`)

  expect(broken).toEqual([])
})

書き方を先に説明します。it('説明', () => { ... }) が1件のテストで、最初の文字列がそのテストの名前です。落ちたときにこの名前が出ます。expect(A).toEqual(B) は「AはBのはず」という宣言で、違っていればそこで落ちます。ここでは公開済みの記事から崩れが残っているものだけを集め、その一覧が空であることを確かめています。

やっていることは1つです。組み立てたHTMLに ** が残っていたら落とす。

これで以降は、書いた時点で気づけます。目で見て確認するのをやめました。

テストが本当に落ちるか確かめる

ここを飛ばす人が多いです。通っているテストは、何もチェックしていないかもしれません。

だからわざと壊して、落ちることを確認します。

僕は上のテストを入れたあと、記事を1行わざと壊しました。

Tests  1 failed | 13 passed (14)
AssertionError: expected [ Array(1) ] to deeply equal []
+   "6-1: この形だと、的の数がコードに散らばります。だから**「増えるもの」..."

落ちました。 そして元に戻すと通りました。

Tests  14 passed (14)

これで「このテストは本当に見ている」と言えます。この確認をしていないテストは、あってもゼロと同じです。

AIへの頼み方

テストを書かせるとき、そのまま「テストを書いて」と言うと薄いものが出ます。

決めて渡すのはこの3つです。

claude -p "index.html の takerOf のテストを vitest で書いて。

確かめること:
- 合計が多い方が取る(自分側・相手側の両方向)
- 誰も置いていない的は、どちらのものでもない
- 同点で、あとに置いた方が取る(自分が後・相手が後の両方向)
- 同点で同じ手番なら、どちらのものでもない
- DECK に無いidを渡したときに落ちること

期待値は DECK から導出して、札のidを直書きしないで。
書いたあと、わざと takerOf を壊して落ちることを確認して、元に戻して。"

効いているのは後半2行です。

指定 なぜ効くか
期待値をデータから導出 札を増やしたときにテストが勝手に付いてくる
わざと壊して落ちるか確認 通るだけのテストを防ぐ

「わざと壊して確認して」は必ず入れてください。 これを言わないと、AIは通るテストを書いて満足します。

前提を毎回書かせない

テストが増えると、同じ準備を何度も書くようになります。

// 毎回これを書いていると、テストが読めなくなる
const side = { mine: [{ card: 'c3', turn: 0 }, { card: 'c1', turn: 2 }],
               theirs: [{ card: 'c4', turn: 1 }] }

だから前提を作る道具を1つ用意します。

export function makeSide(mine: [string, number][], theirs: [string, number][]) {
  const put = (a: [string, number][]) => a.map(([card, turn]) => ({ card, turn }))
  return { mine: put(mine), theirs: put(theirs) }
}
it('同点なら、あとに置いた方が取る', () => {
  expect(takerOf(makeSide([['c3', 0], ['c1', 2]], [['c4', 1]]))).toBe('me')
})

makeSide は、[札のid, 何手目] の並びを渡すと的1つ分の盤面データに組み立ててくれる道具です。おかげでテスト本文は「自分が3を0手目と1を2手目、相手が4を1手目」という中身だけを書けば済みます。

テスト本文が1行で読めるようになります。 差分だけを引数で渡す形にするのが要点です。

これはAIに任せる観点でも効きます。準備の書き方が1つに決まっていると、出てくるテストが揃います。

やってみる

  1. 判定にテストを書かせる

    上の頼み方をそのまま使ってください。

  2. 落ちることを確認する

    npm test
    

    npm test は、用意したテストを全部まとめて実行するコマンドです。何件通って何件落ちたかが数行で出ます。

    通ったら、わざと壊してください。 takerOf>< にするだけで十分です。落ちなければ、テストが見ていません。

  3. データの整合も見張らせる

    6-2で作った checkDeck を、テストからも呼びます。

    it('札のデータに間違いがない', () => {
      expect(checkDeck()).toEqual([])
    })
    

    起動時のチェックとテストで同じ関数を使うのが要点です。 2つ書くと、片方だけ直る日が来ます。

  4. 保存と復元を1往復で確かめる

    it('保存して読み直すと同じ状態になる', () => {
      const before = snapshot()
      save(); reset(); load()
      expect(snapshot()).toEqual(before)
    })
    

    snapshot() は、いまの状態をまとめて1つのデータとして取り出す関数です。保存して、いったん初期化して、読み直して、元と同じに戻っているかを見ています。

    1往復させるのがコツです。 保存だけ、読込だけを別々に試すより、はるかに壊れが見つかります。

  5. 保存する

    git add .
    git commit -m "判定・データ・セーブにテストを入れた"
    git push
    

つまずきどころ

テストが多すぎて実行が遅い

いまの規模なら数秒で終わります。この講座のサイトは75件でテスト部分が約2秒(準備を含めても約9秒)です。遅いと感じたら、外部と通信しているテストが混ざっています。

画面のテストがすぐ壊れる

画面は変わるものなので、正常です。文字やレイアウトを細かく検査しないでください。「押せる札が3枚ある」のような、意味の単位で見ます。

AIが書いたテストの意味が分からない

分からないテストは消してよいです。 意味の分からない見張りは、落ちたときに直せません。「1テスト1つの意図」に書き直させてください。

乱数が入っていて結果が変わる

6-6で書いた乱数の固定を使ってください。テストで Math.random をそのまま使うと、たまに落ちるテストになります。 たまに落ちるテストは、やがて無視されます。

時刻に依存して落ちる

同じ理由です。時刻は外から渡す形にして、テストでは固定値を入れます。

テストを書く時間がない

1つだけ書いてください。 勝敗の判定です。そこが壊れると全部狂うので、1つでも効果があります。

Windows の場合

npm test は同じです。

次の一歩

判定のテストを1つ書いて、わざと壊して落ちることを確認してください。

大事なのは後半です。落ちるところを1回見ておくと、テストへの信頼が変わります。 見張りが本当に見張っていると分かるからです。

壊れたら気づける形になりました。次は、その確認を人が実行しなくても回るようにします!

術の一覧へ戻る

メルマガ(無料)忖度なしのAI事情を、メールでお届けします。ご登録の方に、プレゼントをお渡ししています