mizulba
Goバックエンドのテスト戦略(カバレッジ網羅・モック・E2E)
トランザクションモックのテストヘルパーはコールバックのエラーを伝播させる版を必ず用意する
8日前
RunInTx(ctx, opts, fn) のようなトランザクション抽象をモックする際、「fn を実行して常に nil を返す」ヘルパーだけだと、トランザクション内部の失敗分岐(楽観ロック失敗・一意制約・二重使用検知など)のテストが偽陰性になる。fn が返したエラーがモックで握りつぶされ、呼び出し元には成功として返るため、「エラーになるはず」のテストが「エラーが返らない」で落ちる(または逆に、アサーションが甘いと通ってしまう)。
対処: fn の戻り値をそのまま返すヘルパーを別に用意し、Tx 内の失敗系をテストするときはそちらを使う。
気づき方: 「Tx 内で early return するはずの異常系テストで err が nil になる」場合、まずトランザクションモックがエラーを伝播しているか確認する。mockery 等の生成モックは関数リターナー(.Return(func(...) error))に対応しているので、伝播版は1行で書ける。
適用条件: トランザクション境界をインターフェースで抽象化し、クロージャを渡す設計(Go の RunInTx、TypeScript の prisma.$transaction 相当)でモックテストをしている場合全般。
Stripe Webhook 署名検証は ComputeSignature で本物の署名を生成してユニットテストする
8日前
Stripe の Webhook 署名検証(webhook.ConstructEvent 系)は、検証ロジックをモックせずに本物の署名を生成してテストできる。stripe-go は署名計算関数 webhook.ComputeSignature(timestamp, payload, secret) を公開しており、Stripe が送る Stripe-Signature ヘッダー形式(t=<unix秒>,v1=<hex(HMAC-SHA256)>)を自前で組み立てられる。
最低限入れるべきテストケース:
- 正しい署名 → イベントが返る
- 別のシークレットで署名 → 拒否
- 署名後にペイロードを改ざん → 拒否
- 古いタイムスタンプ(デフォルト許容は5分)→ 拒否
- ヘッダー形式が不正 → 拒否
理由: Webhook 署名検証は決済の入口であり、モックで素通しにすると「検証を外しても全テストが通る」状態になる。実署名でテストすれば、API バージョン互換オプション(IgnoreAPIVersionMismatch 等)の挙動変更や、シークレットの取り違えをテストで検出できる。ネットワーク不要・外部依存なしで実行できるため CI コストもかからない。
応用: HMAC 署名方式の Webhook(GitHub、Slack 等)全般に同じパターンが使える。署名関数が公開されていない場合も、仕様どおり HMAC を自前計算すれば同様にテストできる。
Goで生成モックがテスト対象パッケージをimportする場合は外部テストパッケージにする
8日前
mockery 等で生成したモックを集約パッケージ(test/mocks など)に置く構成では、モック対象インターフェースの入出力型がどのパッケージに定義されているかによって、テストが import cycle で書けなくなることがある。
発生条件: インターフェースの Input/Output 構造体を interactor 等の実装パッケージ自身に定義していると、生成モックがそのパッケージを import する。すると同パッケージ内テスト(package foo)からモック集約パッケージを import した時点で import cycle not allowed in test になる。DTO を別パッケージ(usecase/dto/... 等)に分離しているインターフェースでは起きない。
対処の選択肢:
- 外部テストパッケージ(
package foo_test)にする(最小変更): 外部テストパッケージは「テスト対象を import するパッケージ」を import してもサイクルにならない。エクスポートされた API 経由のテストになるため、振る舞い駆動テストとしてはむしろ健全。 - 入出力型を独立した dto パッケージへ移す(設計としては根治だが既存コードの変更が必要)。
補足: unexported な純粋関数もテストしたい場合は、同パッケージ内テスト(モック不要のもの)と外部パッケージテスト(モック使用)の2ファイルに分けられる。Go は foo と foo_test の2パッケージのテストファイル共存を許している。
気づき方: テスト追加時に import cycle not allowed in test が出たら、まず生成モックの import 文を見て、どの実装パッケージに依存しているかを確認する。
gin の SSE ストリーミングハンドラーは httptest 標準レコーダーではテストできない
8日前
gin の c.Stream() / SSE を使うハンドラーを httptest.NewRecorder() でテストすると、interface conversion: *httptest.ResponseRecorder is not http.CloseNotifier で panic する。gin の Stream 実装がクライアント切断検知に http.CloseNotifier を要求するが、標準のレコーダーは実装していないため。
対処: レコーダーをラップして CloseNotify を生やすだけでよい。
これを router.ServeHTTP(&closeNotifyRecorder{httptest.NewRecorder()}, req) のように渡す。チャネルを閉じなければ「切断されないクライアント」として振る舞う。切断時の挙動をテストしたい場合は、閉じるチャネルを返す版を作る。
併せてハマりやすい点:
- Content-Type の検証は完全一致にしない。
c.SSEvent経由だとtext/event-stream;charset=utf-8のように charset が付くため、Containsで判定する。 - ストリーム源がチャネルの場合、バッファ付きチャネルに値を入れて close してから渡せば、ハンドラーは同期的に全イベントを書き切って返るので、goroutine 同期なしで本文を検証できる。
適用条件: gin で SSE / chunked ストリーミングを返すエンドポイントのハンドラー単体テスト全般。
Usecaseがテスト済みのHTTP handlerはAPI境界だけを薄くテストする
8日前
HTTP handler の下にある usecase / service が既に十分テストされている場合、handler テストで同じ業務ロジックを再検証すると重複が増え、変更に弱くなる。handler 側では API 境界の責務に絞ってテストすると、少ないケースで回帰検知の価値を出せる。
handlerで優先して固定するもの:
- path / query / body の parse と必須チェック
- デフォルト値、上限丸め、型変換など HTTP 入力固有の処理
- usecase へ渡す主要引数(ユーザーID、リソースID、limit など)
- domain/usecase エラーから HTTP status への変換
- レスポンス DTO の最低限の形と主要フィールド
避けるもの:
- usecase 内の分岐を handler テストで再現すること
- repository や外部 API の詳細まで handler テストに持ち込むこと
- 全レスポンスフィールドの過剰なスナップショット化
判断基準: handler のカバレッジが低くても、既に usecase が厚くテストされているなら、追加すべきは「HTTP 境界でしか壊れない分岐」。例えば query 必須、limit の上限丸め、ID parse エラー、validation error、NotFound/BadRequest/InternalServerError の出し分けは handler テスト向き。ビジネスルールや保存順序は usecase テストに残す。
外部リダイレクト型フローの E2E は外部ページではなく遷移開始までを検証する
8日前
外部決済・OAuth・カスタマーポータルのように、アプリ操作後に外部サービスへリダイレクトするフローは、E2E で外部ページそのものを追うと不安定になりやすく、モック検証の価値も薄くなる。
判断基準:
- 自アプリの責務は、正しい画面状態から正しい API を呼ぶこと、必要な request body を送ること、API が返した URL へ遷移を開始することまで。
- 外部サービス画面の UI、カード入力、認証、決済完了 webhook などは外部システムまたは統合テストの責務に分ける。
- E2E では外部ドメインへ実アクセスせず、ローカルのモック URL を API レスポンスとして返し、その URL へ遷移したことを確認する。
window.location.hrefのような副作用は小さな関数へ切り出し、単体テストで「API 成功時に返却 URL が渡る」ことを固定すると、E2E 側は画面導線と API 境界に集中できる。
検証観点:
- 遷移前の画面状態が期待どおり表示される。
- click で発生した API request の method/body が期待どおり。
- API が返すローカル URL へ実際に navigation する。
- 外部サービスの本物の画面にはアクセスしないため、CI でネットワーク・認証・外部 UI 変更に影響されない。
適用範囲: 決済 checkout、顧客管理 portal、OAuth consent、外部 SSO など、アプリ境界を越えるリダイレクト型フロー全般。
ドラッグ並び替えの E2E は pointer 操作に固執せず決定的な操作経路で検証する
8日前
dnd-kit などのドラッグ並び替え UI は、E2E で pointer drag を直接再現すると座標、scroll、animation、collision 判定、CI の描画タイミングに左右されて flaky になりやすい。
判断基準:
- 検証したい責務が「並び替え後に UI 順序が変わる」「正しい sort payload が API に送られる」「再取得後も順序が保たれる」なら、pointer drag そのものを E2E の主対象にしない。
- UI にキーボードでの上下移動や move up/down ボタンなどの deterministic な操作経路を用意し、その操作で同じ並び替え処理を呼ぶ。
- pointer drag はライブラリ統合・手動確認・必要なら薄い smoke test に留め、E2E では request body と永続化後の表示順を強く検証する。
実装上の注意:
- ドラッグハンドルには accessible name を付け、E2E でも role/name または安定した test id で対象を選べるようにする。
- モック API は並び替え request を無視せず、受け取った順序を保存して次回 GET に反映する。これにより「画面上だけ一瞬並び替わる」偽陽性を防げる。
- キーボード経路はアクセシビリティ改善にもなるため、テスト専用の隠し API より優先する。
適用範囲: ドラッグで並び替えるリスト、ボード、優先順位設定など。特に CI 上で drag の座標指定が不安定な UI に有効。
Go coverage は build tag テストの有無で package 対象を分ける
8日前
Go の coverage を unit test と integration test で分けたい場合、単に通常実行から -tags=integration を外すだけでは不十分なことがある。//go:build integration のテストファイルは実行されないが、その package の通常ビルド対象コードは coverage の分母に入るため、integration test でしか通らない repository / DB 境界などの実装が unit coverage を押し下げる。
判断基準:
- integration test 専用の package があり、通常 coverage の数字を unit test の指標として使いたいなら、通常 coverage の package リストから integration test ファイルを持つ package を除外する。
- integration coverage は逆に、integration test ファイルを持つ package だけを
-tags=integration付きで実行する。これで「unit coverage」と「integration 対象 package の coverage」を別の指標として読める。 go listのIgnoredGoFilesには build tag で通常ビルドから除外されたファイルが入るため、*_integration_test.goを含む package の検出に使える。
注意点: package 単位で除外するため、同じ package に unit test と integration test を混在させている場合、その package の unit test も通常 coverage から外れる。厳密に分けたいなら、integration test を repository など境界ごとの package に寄せるか、coverage 対象の設計を明示する。
検証方法: coverage ターゲットを dry-run して、通常 coverage に integration test を持つ package が含まれていないこと、integration coverage が該当 package だけを対象にしていることを確認する。その後、両方の coverage を実行し、分母の違いにより total が分かれて出ることを確認する。
固定IDのseedデータはDB sequenceを進めないとテスト挿入で衝突する
7日前
PostgreSQL などで seed / migration が主キー ID を明示して初期データを投入する場合、テーブルの sequence がその最大 ID まで進んでいるとは限らない。統合テストやアプリ処理で通常の auto increment insert を行うと、sequence が古い値を返して既存 seed の主キーと衝突し、duplicate key value violates unique constraint のような失敗になる。
起きやすい条件:
- migration や seed が
id = 1など固定 ID を明示して insert している。 - その後の insert は ID を省略して sequence / serial / identity に任せている。
- テスト DB は migration + seed を毎回 replay するため、本番より早く顕在化する。
対処の選択肢:
- seed 後に
setval等で sequence を既存最大 ID 以上へ進める。通常 insert と固定 ID seed を共存させるなら根本対策になる。 - テストで作るデータは既存 seed と衝突しない高い ID を明示する。ただし局所回避なので、アプリ本体でも同じテーブルに通常 insert するなら sequence 修正を優先する。
- 可能なら seed でも ID を省略し、自然キーや名前で参照する。固定 ID 前提の外部キーや設定が多い場合は管理が難しくなる。
検証方法: migration / seed 適用直後に対象テーブルへ ID 省略 insert を 1 件実行する。主キー衝突が起きるなら sequence が seed と同期していない。SELECT nextval(...) と SELECT max(id) を比較しても確認できる。
テスト coverage は分母を判断対象コードに絞ってから指標化する
7日前
Go など package 単位で coverage を測るプロジェクトでは、go test ./... -coverprofile をそのまま使うと、生成 mock、test helper、interface だけの package、DTO/response 変換だけの薄い package、bootstrap や migration 補助などが分母に入り、総 coverage が実際の品質判断とずれやすい。
判断基準:
- coverage を「テスト拡充の判断材料」にしたいなら、分母は handler、usecase、domain model、middleware、外部 adapter など、挙動リスクを判断したい本番コードに寄せる。
- 生成コード、テスト補助、mock、interface-only package、単純 DTO は除外候補にする。これらは coverage が 0% でも挙動リスクを直接示しにくく、逆に本当に薄い実行経路を埋もれさせる。
- DB repository など Docker や実 DB に依存する層は、unit coverage と integration coverage を分ける。通常 coverage に混ぜると実行コストや環境依存が増え、日常的な指標として使いづらくなる。
- 除外対象はコマンドに埋め込むだけでなく、対象 package を表示する確認ターゲットを用意すると、分母の妥当性をレビューしやすい。
落とし穴: Makefile の $(shell go list ...) は、環境によって go list が失敗しても空文字として展開され、意図しない対象で coverage が進むことがある。coverage 用 package の解決は recipe 内で行い、解決結果が空なら明示的に失敗させるとよい。
検証方法: 対象 package 一覧を出力し、生成物・test helper・mock が含まれていないことを確認する。そのうえで coverage 総値を見る。総値が大きく変わった場合は「テストが増えた」のではなく「分母が変わった」ため、変更前後の対象範囲を併記して扱う。
相対時刻フィクスチャは日付境界で別レコードと衝突しテストをフレーキーにする
7日前
テストで now - 1h のような現在時刻基準の相対時刻フィクスチャを使い、別のフィクスチャを now - 24h のように丸1日前に置くと、検索条件が「カレンダー日付」単位のとき(例: WHERE date(created_at) = '2026-06-13')に両者が同じ日へ重なり、特定の実行時刻でだけテストが落ちる。
発生条件: now - 1h は、現在時刻が日付が変わった直後の1時間以内(00:00〜00:59)だと前日に回り込む。一方 now - 24h も前日。両方が同じカレンダー日になるため、「1件ヒットするはず」のアサーションが2件ヒットで失敗する。深夜帯に走る CI で非決定的に再現するのが厄介。
原因の型: 「相対時刻の差(時間単位)」と「検索の粒度(日単位)」が噛み合っていない。時間単位ではフィクスチャを区別できているつもりでも、日付へ丸めた瞬間に区別が消える。
対策: 日付単位で照合するテストでは、フィクスチャの時刻差を検索粒度より十分大きく取り、実行時刻に関わらず別の日に落ちるよう隔離する。例えば「直近データ」を now - 1h、「対象日データ」を now - 48h(丸2日前)にすれば、-1h と -48h は時刻に関係なく必ず別カレンダー日になる。より堅牢にするなら現在時刻に依存しない固定日付を使う。
気づき方・検証: 「日付で絞り込むテストが特定時間帯だけ落ちる/件数が1多い」場合にこのパターンを疑う。フィクスチャの時刻を 00:30 など境界付近に固定して回すと再現でき、修正後も同条件で1件に収まることを確認する。
適用範囲: カレンダー日・週・月など、連続時刻を粗い粒度へ丸めて照合するテスト全般(DB の日付検索、集計のバケット境界、週またぎ判定など)。
testify suite はサブテスト間で mock 状態を共有する — エラー系は分離する
7日前
stretchr/testify の suite.Suite で、1 つの Test メソッド内に複数の suite.Run(...) サブテストを並べ、mock セットを SetupTest で 1 度だけ生成して使い回す構成では、サブテスト間で mock の登録状態(expectation)が共有される点が落とし穴になる。SetupTest はメソッド単位で呼ばれ、同一メソッド内の各 suite.Run の前後では走らないため、先行サブテストで登録した expectation が後続サブテストに残る。
何が壊れるか: 回数指定のない成功 mock(.Once() を付けず常時マッチする On(...).Return(成功))を正常系サブテストで登録すると、同じメソッド内の後続の異常系サブテストでもそのマッチが効いてしまい、本来エラーを返すはずの分岐が成功で素通りする。結果「エラーになるはず」のテストが偽陰性(err が nil)になり、未到達分岐を埋めたつもりが実は通っていない、という事故が起きる。トランザクション内の成功 mock やヘルパー(成功 Tx をセットする系)は特に常時マッチになりやすく汚染源になる。
対処の判断基準:
- 同一メソッド内で正常系と、その成功 mock と衝突する異常系を併置しない。衝突するエラー系は 1 ケース 1 トップレベル Test メソッドに分離する(メソッドが変われば
SetupTestで mock が作り直され、状態が混ざらない)。 - 1 メソッド内に複数ケースを置くなら、各 expectation に
.Once()(または回数指定)を付け、ケースをまたいで使い回されないようにする。 TearDownTestでAssertExpectationsを呼ぶ運用なら、各ケースで「実際に呼ばれる mock だけを過不足なく登録」する規律と合わせると、余剰 expectation の残留にも気づきやすい。
気づき方: 異常系サブテストで err が nil になる/アサーションが意図せず通る場合、まず同一メソッド内の先行サブテストが常時マッチの成功 mock を残していないかを疑う。
適用条件: xUnit 系の suite/fixture で setup が「ケース単位」ではなく「メソッド単位」で走り、mock 状態を共有する構成全般(testify suite に限らず、setup 粒度とサブテスト粒度がずれるテストフレームワークで再現しうる)。
リポジトリのDBエラー分岐はキャンセル済みcontextで網羅する
7日前
リポジトリ/データアクセス層の書き込み・読み取りメソッドにある if err != nil { return ... } のエラー分岐は、正常系の統合テストでは到達せず、関数カバレッジが頭打ち(典型的には70%前後)になる。実 DB を相手にすると意図的にエラーを起こしにくいのが原因。
手法: あらかじめキャンセルした context を渡すと、ドライバが context のキャンセルを尊重してクエリ実行時にエラーを返すため、SQL 実行を伴う各メソッドのエラー分岐を低コストで通せる。
設計のコツ:
- パッケージごとに「キャンセル済み context テスト」を1本用意し、SQL を実行する全メソッド(Find/List/Store/Update/Upsert/Delete など)を列挙してそれぞれ error 返却をアサートする。1メソッドだけ通して満足しないこと。
- 渡す引数は FK 整合などを満たす必要はない。キャンセルはクエリ実行前後で効くため、存在しない ID やダミー値でよい(コンパイルが通る最小限の構造体で十分)。
- 空スライスの一括 INSERT など、入力が空だと SQL を発行せず早期 return する実装は、context のキャンセルより前に抜けてエラーにならない。非空の入力を渡す。
限界と補完: この手法が検証するのは「エラーを握りつぶさず伝播するか」だけで、エラー時の個別ハンドリング(ログ整形、リトライ、特定エラーの変換)の正しさまでは見ない。分岐網羅の底上げとして使い、意味のある異常系は別途書く。
適用条件: context を第一引数で受け取り、その context をクエリ実行へ渡すデータアクセス層全般(Go の database/sql 系 ORM など)。
カバレッジで埋まらない防御分岐は「到達不能」と「壊れるから書けない」を区別する
7日前
テストで埋まらない条件分岐(特に if x == nil やエラーハンドリングなどの防御コード)にぶつかったとき、「カバレッジが伸びない」という表面だけで放置せず、なぜ到達できないのかを必ず分類するとバグを拾える。
3 つに分ける:
- 真に到達不能な防御コード(
crypto/rand失敗、上位が既に同等の検査をしていて冷長な二重ガードなど)→ 無理に埋めず理由を添えて残す。テストのために本番コードを歪めない。 - 実は到達可能だがテストがないだけ→ 普通にテストを追加する。
- 「到達すると壊れる」から書けない→ これが実バグ。分岐内で panic・誤った返却値・無限ループ等が起きるため「到達させるテストが書けない」状態になっている。修正してからテストを追加する。
具体例(よくある型): 「見つからないとき nil を返す」find 関数の直後で if x == nil { logger.Error(msg, nil); return err } のようなコードは、(a) ログラッパーが err を無条件に err.Error() していると nil 渡しで panic、(b) return err が err==nil なので「not-found なのに成功を返す」論理バグ、の 2 重に壊れていることがある。この分岐が「テストで埋まらなかった」正体。
予防策:
- エラーを受け取るログラッパー関数は nil でガードする(
err != nilのときだけerr.Error()を呼ぶ)。一度入れると、同種の nil 渡しが他にあっても panic クラスを一括で潰せる。 - not-found は nil(成功) ではなく明示的な not-found エラーを返す。
- 全コールサイトを横断で grep(例:
logger.Error(... , nil))して同種パターンを洗い出す。
検証方法: 修正後は分岐が到達可能になるので、そのケース(not-found・該当エラー)のテストを追加して回帰を固定する。ログラッパーの nil ガードは NotPanics で振る舞いを固定できる。
適用条件: カバレッジを上げる作業全般。「未到達だからテストを足す」という作業の中で、未到達の理由を詰めると品質バグ発見に転じる。