1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「テストは通っていた」——何も確かめていなかった4つのテストと、その見分け方

1
Posted at

個人で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

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?