blog

テストが増えたのに判定は弱くなっていた

  • Claude Code
  • テスト
  • コードレビュー

サイトの下端で動かしているミニゲームに、武器を4種類足して10種にした回のことだ。近接武器が強すぎたので弱体化もした。テストは 142 件が全部緑、lint はエラー0、ビルドも通る。前の回が 136 件だったので、件数はむしろ増えていた。自分はそれを見て、通した。

数字がおかしいと気づいたのは次の回だった。強化ツリーの「生存」の枝を全部取った状態の平均生存時間が 10.07 秒で、同じ条件の未強化が 27.79 秒だった。強化したほうが短い。生存を伸ばすための枝を全部取って、寿命が3分の1になっている。

これがテストを素通りしていた。

判定が「それぞれ」から「平均」に変わっていた

受け入れ条件は18の条件で測っていた。プレイ領域の幅3種と、累計撃破数、強化の取り方の組み合わせだ。元は18条件それぞれに対して境界を検査していた。

書き換わっていたのはそこだった。

// 変更前(条件ごと)
expect(results.every((result) => result.maxHorizontalVelocityDelta <= 355)).toBe(true);

// 変更後(グループ平均) — 個別条件が大きく外れても通る

平均で見ると、1つの条件が壊滅的に外れていても、他の17が良ければ合格になる。生存時間が 10.07 秒に落ちた条件は、平均に紛れて見えなくなっていた。

もっと露骨だったのが、追加したアクションが実際に発動しているかの検査だ。こちらは平均ですらなく 全条件の合計 で見ていた。スライディングが1つの条件で1回出れば、残り17条件で一度も出ていなくても通る。「アクションが動いていること」を確かめるつもりで書いた検査が、実質何も確かめていない状態になっていた。

自分が見ていたのは件数と lint だった

ここが一番こたえた部分だ。

レビューのとき自分が確認したのは、テストが全部緑であること、テストの件数が減っていないこと、lint がエラー0であること。この3つで「品質は落ちていない」と判断した。

でも本当にそうか。テストが緑なのは、判定が緩くなっても緑になる。件数が増えているのも、弱い検査を足せば増える。自分が見ていた指標は3つとも、判定の強さとは無関係な量 だった。強さを見ていたつもりで、量しか見ていなかった。

AI に実装を任せるとき、こちらが見るのは差分だ。その差分の中で、新しく足されたコードには目が行く。既に通っていたテストの expect が書き換わっているところには、目が行かなかった。追加は目立つが、既存の緩和は目立たない。

平均へ戻しにくくする

直すこと自体は難しくなかった。全部の判定を条件ごとの検査に戻す。ただ、また同じことが起きないようにする必要がある。

やったのは2つだ。まず条件ごとの判定を1つの関数に集約した。

// 判定対象は必ずこの18条件に集約し、条件をまたぐ平均や合計で不合格を隠せないようにする。
function expectEveryCondition(predicate: (result: ConditionResult) => boolean): void {
  for (const result of results) expect(predicate(result)).toBe(true);
}

もう1つは、テスト名を変えたことだ。

it('全18条件を固定5シードで決定的に測定し、平均・最小・最大・標準偏差を出す', () => {

「18条件それぞれ」という文字列をテスト名とコメントに埋めた。これを平均へ書き換えようとすると、テスト名のほうが嘘になる。名前と中身が食い違えばレビューで気づける可能性が上がる。仕組みで止めているわけではないので完全ではないが、書き換えのコストは少し上がった。

ついでに出てきた、乱数のばらつき

条件ごとに戻してみて、別の問題が見えた。同じ条件でも実行のたびに数字が数倍動く。

原因はごく単純で、ヒットストップのような短い停止をひとつ入れるだけで、そこから先の乱数の引き方がずれるからだった。乱数列が分岐すれば、敵の出方もドロップも全部変わる。1回の測定で出た数字を根拠に「バランスを調整した」と言っていたのは、かなり危うかった。

なので各条件を固定5シードで測って、平均だけでなく最小・最大・標準偏差も出すようにした。

そこで分かったことがもう1つある。平均生存時間は、条件によって標準偏差が平均に匹敵する。裾が重いのだ。この指標で1%未満の調整をしても、それはノイズに合わせているだけになる。判定に使うなら、復帰待ちの割合のような、分布が素直な指標に差し替えるべきだと思っている。ここはまだ直していない。

レビュー手順に1行足した

再発防止として、レビューの手順に「git diff で既存の expect の変化を見る」を明記した。件数ではなく、判定そのものの差分を読む。

似た形の負債はもう1つ残っている。ステージごとのノルマに、測定から逆算しただけの区間分岐が入っていて、なぜその数字なのかを説明できない。数字を合わせるためだけに作った値で、これは調整ではなく曲線あてはめだ。判定を緩めないほうを優先した結果としてそこに置いたものなので、FIXME を付けたまま残してある。説明できない値をコードに置いたことは、正直あまり良い気分ではない。

判定が弱くなったゲームの現物はKludge Worksと同じサイトの、画面下端で常に動いている。

STAGE10/50SCORE0BEST0COMBO×1貯蓄0KILLS0
TAP = FOCUS FIRE
強化ツリー倒した数で買う。1つの強化は1回だけ。枝は左から順に開く。
kill -9 agentKILLS 0
kill -9 agent 停止中