個人でWebサービスを作っています。Go のサーバと、iOS / Android のアプリです。
このリポジトリには、「テストが通っているのに、何も確かめていなかった」が4回あります。全部わたしの落ち度で、全部まだコードに痕が残っています。
面白いのは、4つとも仕組みが違うことです。
1. 実行されていない
2. 本物でないものを相手にしている
3. 見ているものが浅い
4. 期待値が、たまたま一致している
ひとつずつ書きます。最後の1つは、この記事を書いている日に見つけたものです。
なぜ「緑」が信用できないのか
テストの出力は2値に見えます。通ったか、落ちたか。
でも実際には3つあります。
落ちた 壊れている
通った 確かめて、正しかった
通った ← ここが問題。何も確かめていない
後ろ2つは、画面上で見分けがつきません。 どちらも緑です。
① 実行されていない
いちばん素朴なやつです。
Go の話ですが、他の言語でも形は同じだと思います。
func TestSomethingAgainstPostgres(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL が無いため skip")
}
...
}
環境変数が無ければ skip する。よくある書き方です。
go test ./... は、skip しても ok と表示します。
ok example.com/internal/store 0.012s
2026年8月、複数枚購入の機能を作っていたときにこれで一度だまされました。実 PostgreSQL に対する検証が1件も走っていないのに、全部 ok に見えていたわけです。
直し方:skip を失敗として扱う
make test の中で数えるようにしました。
test: test-env
@out=$$(mktemp); \
$(GO) test ./... -v 2>&1 | tee "$$out" | grep -E '^(ok|FAIL|--- FAIL|--- SKIP)'; \
skipped=$$(grep -cE '^[[:space:]]*--- SKIP: ' "$$out"); \
failed=$$(grep -cE '^FAIL ' "$$out"); \
if [ "$$skipped" -ne 0 ]; then \
echo "検証を $$skipped 件飛ばしました。go test は ok と表示しますが、通っていません。"; \
grep -E '^[[:space:]]*--- SKIP: ' "$$out"; \
fi; \
[ "$$skipped" -eq 0 ] && [ "$$failed" -eq 0 ]
1件でも skip があれば、コマンドが失敗します。
環境変数が足りないときは、テストを走らせる前に止めて、設定方法を出すようにもしました。
検証に必要な環境変数が設定されていません: TEST_DATABASE_URL
設定しないと、該当するテストは skip され、go test は ok と表示します。
通ったように見えて、実際には何も検証されていない状態になります。
「通ったように見えて」まで書いておく。 半年後の自分は、なぜこんな面倒なことになっているのか忘れています。
② 本物でないものを相手にしている
Web のハンドラのテストで、DB を差し替え実装(fake)にしていました。速いので。
srv := New(cfg, Stores{
Cards: &fakeStore{...},
Auth: newFakeAuth(),
...
})
これ自体は普通のことです。問題はどこまでを確かめたつもりになるかでした。
fake は「本物と同じ約束を守るつもりで書いたもの」であって、本物ではありません。ずれていれば、テストは通るのに動きません。
そして厄介なのは、ずれ方です。
ハンドラを直す → fake も一緒に直す → 両方が同じように間違う
→ テストは通る
ハンドラと SQL の境目は、手を入れるたびに書き換わります。fake を書き換えるのは、ハンドラを書き換えた本人です。同じ思い込みが両側に入ります。
具体的には、本物の SELECT の列並びが一度も実行されないままでした。fake は構造体を返すだけなので、列を1つ足し忘れても何も起きません。
直し方:本物を1本通す
api_pg_test.go を足して、本物の store をつないで、アプリがたどる道筋を最初から最後まで通しました。
全部を本物にはしていません。store に触るハンドラを足したら、本物相手の経路を1本足すという規則にしています。
変異テストは、本物だけ壊す
穴が塞がったかを確かめるとき、実 SQL だけを壊します。
fake を無傷にしておくと、「E2E だけが検知できるかどうか」が分かるからです。両方壊すと、どちらが捕まえたのか分かりません。
余談ですが、このとき等価変異も見つかりました。カードの有効化は守りが3枚あります。
Go の判定 → 条件付き UPDATE → テーブルの CHECK 制約
1枚だけ外しても、誰も落ちません。 これはテストの穴ではなく、守りが重なっているだけです。2枚外したら CHECK 制約が拒んで 500 になることまで確かめて、「これは穴ではない」と結論にしました。
「検知されなかった」を全部穴として数えない。 ここを分けないと、意味のないテストが増えます。
③ 見ているものが浅い
いちばん痛かったやつです。
このサービスの公開ページは、JS をほとんど使っていません。トップページの円環アニメーションだけが例外で、orbit.js という1本があります。
これを node --check で見ていました。構文チェックです。
2026年9月23日、一時的に入れていた計測用の変数を、別の消しものと一緒に落としました。
var OD = ...; // ← これを消した
if (OD) { ... } // ← これが残った
構文は通ります。 node --check も通ります。Go のテストは JS を読まないので、当然通ります。デプロイも通ります。
実際に起きたのはこうです。
読み込みの途中で ReferenceError
↓
そこから先が1行も走らない
↓
IntersectionObserver も scroll も、止まったときの起こし直しも、
どれも登録されない
↓
円環は1枚目を描いたきり、二度と動かない
目で見ると「スクロールが効かない」です。PC でも電話でも同じように壊れました。
気づいたのは利用者です。
直し方:実際に走らせる
最小限の窓と書類(DOM)を用意して、node で本当に実行するテストを足しました。
func TestOrbitScriptRuns(t *testing.T) {
node, err := exec.LookPath("node")
if err != nil {
// **飛ばさない。** ①で一度痛い目を見ている。
t.Fatal("node が要る。orbit.js は web で唯一の JS で、" +
"落ちるとトップが動かなくなる。")
}
...
}
ここで①が効いています。node が無ければ skip、にしていません。 失敗させます。skip にすると、CI の設定を変えた日に静かに消えます。
見ているのは2つだけです。
主な道を一通り通して、例外が出ないこと
版面の値に NaN が混じらないこと
見た目は見ません。 それはブラウザで測る仕事です。ここで見たいのは「読み込みが最後まで走るか」だけです。
NaN を見ているのは、ブラウザが黙って捨てるからです。style.height = "NaNpx" は例外を投げません。画面では「なぜか前の形のまま」にしか見えません。
配るものを走らせる
細かい点ですが、大事でした。
src, err := staticFS.ReadFile("static/orbit.js")
作業ツリーのファイルではなく、バイナリに埋め込んである実物を読んでいます。配るのはこちらなので。
④ 期待値が、たまたま一致している
この記事を書いている日(2026-09-28)に踏みました。
管理用の API に、新しい口を2つ足しました。会社(法人)の一覧と、その会社にカードを1枚入れる口です。
テストを4本書きました。そのうち1本がこれです。
// 無い会社には入れられないことを確認する。
func TestAssignRejectsUnknownCompany(t *testing.T) {
...
resp := h.call(t, http.MethodPost, "/organizations/99999/assign", body)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("無い会社: status = %d, want 404", resp.StatusCode)
}
}
通りました。
そして隣のテスト(正常系)が落ちました。
会社の一覧に出ていない: {"error":{"code":"not_found","message":"見つかりません。"}}
正常系も 404 だったのです。
原因はハンドラの先頭でした。
func (s *Server) handleOrganizations(w http.ResponseWriter, r *http.Request) {
if s.orgs == nil {
s.apiNotFound(w) // ← 会社の機能を持たない構成では、経路ごと無い
return
}
...
}
そしてテストの足場が、会社の store を繋いでいませんでした。
srv, err := New(cfg, Stores{
Cards: st, Auth: st, Profiles: st, Orders: st, Admin: st, Public: st,
// Organizations: st, ← 無い
})
つまり、どのパスを叩いても 404 でした。
これが④の形です
「無い会社は 404」というテストは、期待値が 404 で、実際も 404 なので通ります。理由がまったく違っていても通ります。
期待していた理由 会社 99999 が存在しないから
実際の理由 機能ごと無効だったから
正常系が隣に無かったら、永遠に緑のままでした。 そして「無い会社を弾いている」という嘘の安心だけが残ります。
④は、どうすれば自分で気づけるか
正直、単独では気づけません。 これが④の厄介なところです。
わたしが気づけたのは、同じ足場で正常系も書いていたからです。そこが落ちて、初めて足場を疑いました。
なので対策は、テストの書き方の側になります。
否定のテストは、単独で置かない。 「弾かれること」を書いたら、同じ足場で「通ること」も書く。片方だけだと、足場が死んでいることに気づけません。
これは「正常系も書きましょう」という当たり前の話に見えますが、理由が違います。 正常系はカバレッジのためではなく、否定のテストが本物かどうかを保証するために要ります。
4つに共通していたもの
並べてみると、こうなります。
① 実行されていない → skip を失敗にする
② 本物でない相手 → 本物を1本通す
③ 見ているものが浅い → 実際に走らせる
④ たまたま一致している → 否定の隣に肯定を置く
全部違う話に見えますが、問いは1つでした。
このテストは、壊したら落ちるか。
①は壊しても走らないので落ちません。②は本物を壊しても fake が通します。③は壊しても構文が通れば落ちません。④は壊れていることが理由で通っています。
緑を見て安心する前に、一度赤にしてみる。 これが唯一の確かめ方でした。②で変異テストを入れたのは、それを機械にやらせるためです。
最後にもう1つ。4つのうち3つは、テストを書いたその日には気づけませんでした。
① 別の機能を作っている最中に、たまたま
② 新しい層を足したときに、考えて
③ 利用者からの報告で
④ 隣のテストが落ちて
書いた瞬間に「これは何も確かめていない」と分かったものは、1つもありません。 だから、書き方の規則にするしかない、というのが今のところの結論です。
作っているのはこれです → https://tapuda.app