はじめに
2026-09-06、そろばんアプリ SŌBAN(React Native + Expo・TypeScript)に検査を足した日の話。検査は node --test で書いてあり、npm run check 1本で全部回る。この時点で 568件、全部通っていた。
足した検査が本当に効いているかを確かめるため、本体のコードをわざと壊して、検査が落ちるかを見る。いわゆる改変検査(ミューテーションテスト)を手で回した。ツールは使わず、sed で1行書き換えて npm run check を流し、not ok の行を数える。数えたら元に戻す。
# 実際は4ファイルを退避・復元していた。1ファイルに縮めて載せる
run() {
n=$(npm run check 2>&1 | grep -cE "^not ok")
echo "$n <- $1"
cp /tmp/exam.ts.bak src/core/training/exam.ts # 元に戻す
}
sed -i '' 's| for (const block of form.blocks) {| for (const block of []) {|' src/core/training/exam.ts
run "ブロックを見ない"
7通りの改変を流した結果がこう。
0 <- 字数を合わせない
1 <- 履歴を20件にする
1 <- 新しい順に並べない
1 <- 最後の題でも先に作る
1 <- 大きい桁を渡さない
0 <- ブロックを見ない
1 <- 信用できない秒も出す
0 が2つ。検査に穴が2つある、と読みかけた。
0件の正体
npm run check の中身はこうなっていた。
"check": "npm run typecheck && npm run check:soroban && npm run check:problem && ... && npm run check:exam && ..."
先頭が tsc --noEmit(型検査)で、残りが && でつながっている。
「ブロックを見ない」の改変は form.blocks を [] に置き換える。空の配列リテラルは never[] と推論されるので、ループの中の block は never 型になり、block.count のような参照が全部型エラーになる。型検査が落ちた時点で && の連鎖は止まり、後ろの検査は1本も起動しない。
not ok は検査が落ちたときにだけ出る行だから、検査が走らなければ出ない。grep -c は 0 を返す。「検査が全部通った」と「検査が1本も走らなかった」が、どちらも 0 として出てくる。
同じ改変で、検査ファイルだけを直接動かすとこうなった。
npx tsx scripts/check_exam.ts 2>&1 | grep -cE "^\s*not ok"
# => 3
3件落ちる。検査はこの改変を捕まえていた。穴は無かった。
最小の再現
同じことを、ファイル2本で再現した(Node v20.20.2 / TypeScript 6.0.3 / tsx 4.23.13 / npm 10.8.2)。
// src/exam.ts
export type Block = { count: number; digits: number };
export type Form = { blocks: Block[] };
export function totalProblems(form: Form): number {
let n = 0;
for (const block of form.blocks) {
n += block.count;
}
return n;
}
// scripts/check_exam.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { totalProblems } from '../src/exam.ts';
test('ブロックの題数を合計する', () => {
assert.equal(totalProblems({ blocks: [{ count: 5, digits: 3 }, { count: 10, digits: 4 }] }), 15);
});
"scripts": {
"typecheck": "tsc --noEmit",
"check:exam": "node --import tsx --test scripts/check_exam.ts",
"check": "npm run typecheck && npm run check:exam"
}
of form.blocks を of [] に変えて、3通りの数え方をした。
| 数え方 | 出たもの |
|---|---|
npm run check 2>&1 | grep -cE "^not ok" |
0 |
npm run check の終了コード |
2(型検査の失敗) |
npx tsx --test scripts/check_exam.ts |
pass 0 / fail 1 |
npm run check の出力の最後はこうなっている。
> typecheck
> tsc --noEmit
src/exam.ts(7,16): error TS2339: Property 'count' does not exist on type 'never'.
# pass も # fail も出ていない。検査の結果の行が1つも無い。数える側がそれを見ずに not ok の件数だけを拾っていたので、0 になった。
型検査を外した数え方
この日から、改変を当てるときは型検査を通さずに検査ファイルを直接動かすことにした。再現の構成には、全部の検査ファイルをまとめて流す1行を足した。
"test:only": "node --import tsx --test scripts/check_*.ts"
再現の構成でこれを流すと、同じ改変で # pass 0 / # fail 1 が出る。tsx は型を消してから実行するので、型の合わない改変でも検査まで届く。
件数の拾い方にも差がある。not ok の件数は、検査が走らなくても 0 を返す。# fail の行は検査が最後まで走ったときにしか出ないので、走らなかった場合は「0」ではなく「行が無い」になり、区別がつく。
もう1つの 0
最初の一覧には、0 がもう1つあった。「字数を合わせない」。
字数を合わせにいく処理を弱める改変は、形を変えて2通り試した。ループの上限を 0 にするもの(guard < n * 40 → guard < 0)と、合計が合わなくても計画を返すもの(return sum === spec.charTotal ? plan : null; → return plan;)。どちらも、検査ファイルを直接動かしても 0 のままだった。こちらは型検査のせいではない。
計画そのものを捨てる改変(const plan = null;)に変えると、6件落ちた(先頭の3件)。
not ok 8 - 全珠連の見取算は、桁が揃わないまま字数がちょうど合う
not ok 9 - 全珠連の段位の見取算は5題ごとに字数が増える
not ok 10 - 見取算は「1口の桁 × 口数 = 字数」
弱めただけの2通りがなぜ落ちなかったのかは、この日の記録に残っていない。分かっているのは、型検査で止まった 0 と、検査まで届いたうえでの 0 は、同じ「0」として並んでいた、ということだけだ。前者は数え直せば3件になり、後者は数え直しても 0 のままだった。
0件の失敗は、検査が強い証拠にも、弱い証拠にも見える。走ったかどうかを確かめる前に、どちらかだと読んでいた。
