設計とユニットテストでデグレはどこまで防げるか ― 層ごとにテストの意味を一つにする
Published on 2026年9月23日
設計はじめに
発端は、ユニットテストを書き進めている途中で気づいた違和感だった。
層ごとにテストを書き、層ごとに依存をモックしていた。それぞれのテストは独立していて、速く、緑だった。ところが実装の変更が進むにつれ、別の層に入った変更がモックに反映されず、モックが古い契約のまま固定値を返し続ける 状態が起きた。テストは通る。壊れているのは本番だけ。デグレを検知するために書いたテストが、モックの独立性ゆえにデグレを見逃していた。
層ごとに独立したモックは、他の層の変更から自分を切り離す。それはテストを安定させると同時に、検知範囲を狭める。
ここから問いが立った。純粋関数で組んだ核と、それらを組み上げて処理を作る集約点を、テストでどう担保するか。モックを増やす方向ではなく、実物を呼べる場所は実物を呼び、モックが必要な場所は型で契約に縛る方向で考え直した。その過程で、純粋関数、モック、型、E2E の位置づけが一本に繋がった。
テスト戦略:I/O契約を守り、内部実装を自由にする ではテストピラミッドとモック方針を書いたが、あの記事には「その方針が成り立つ前提は何か」と「E2E はどこに置くか」が曖昧なまま残っていた。今回はそこを埋める。
結論を先に書く。インフラ以外のデグレは、層ごとの設計とテストで担保できる。E2E から仕様検証の責務は完全に剥がせる。 ただしそれが成り立つには前提と条件があり、この記事はその前提と条件を言語化したものだ。
題材は自分のリポジトリ(Hono + Next.js のモノレポ、DDD + クリーンアーキテクチャ構成)の GET /artists/:handle 周辺で、設計の全体像は 設計違反が型エラーになるバックエンド に書いた。
結論:規則は一つ
モックするのは不純な殻だけ。アサートするのは自分の層の責務だけ。
層ごとに「どのテストスタイルを当てはめるか」を選ぶのではなく、この一つの規則から各層のテストの役割が自動的に決まる。この規則が守られていれば、E2E には「繋がっているか」の確認だけが残り、仕様検証は持たなくなる。
以下、この規則が成り立つための前提を三つ、条件を五つ、順に書く。
本文で使う語は次の意味で固定する。どの語も「そうであるか」を型か機械で判定できる形で定めていて、レビューでの解釈に頼らない。読み飛ばして戻ってきてもよい。
用語の定義:核と殻 / 差し替えるもの / テストの種類 / 失敗の名前
核と殻
| 用語 | 定義 | 題材のリポジトリでの実体 |
|---|---|---|
| 純粋 | DB・ネットワーク・時刻・環境変数のいずれにも触れず、同じ入力に同じ出力を返す関数・モジュール。ID 採番の crypto.randomUUID() だけは I/O ではないので純粋に含める |
domain/(VO / Entity / Policy / Domain Service / Factory)、usecases/ |
| 核 | 純粋なモジュールの集合。テストでは 差し替えず実物を呼ぶ | 同上 |
| 殻 | 外界に触れるモジュール。テストでは 差し替える | Repository、外部 API クライアント(Auth / Storage / api-server client)、時刻取得、環境変数、next/navigation |
| 契約 | 核と殻を繋ぐ唯一の接点であるインターフェース型。殻の実装は契約の裏にあり、核は契約しか知らない | I*Reader / I*Writer、CapabilityDeps、Hono の InferResponseType |
差し替えるもの
| 用語 | 定義 | 書き方 |
|---|---|---|
| テストダブル | 殻の代わりにテストで置くもの全般。スタブ・スパイ・ダミーの総称 | — |
| スタブ | 決めた値を返すだけのダブル | vi.fn<I["m"]>().mockResolvedValue(v)、または async () => null の素の関数 |
| スパイ | 呼ばれ方を記録するダブル。検証してよいのは 書き込み境界(save / publish / upsert 等)への到達と引数 だけで、内部の呼び出し回数や順序は検証しない | vi.fn<I["m"]>() + toHaveBeenCalledExactlyOnceWith / not.toHaveBeenCalled |
| ダミー | 呼ばれない前提で置き、呼ばれたら throw するダブル | 認可テストの unusedInAuthorizationTests |
| 型付きダブル | vi.fn<I["m"]>() で宣言し satisfies I で縛ったダブル。契約の変更(メソッドの追加・リネーム・戻り値型)をコンパイルエラーにする。素の vi.fn() は 型なしダブル |
条件B |
| 台本 | スタブが返すと決めた値の列。書いた人が信じている殻の振る舞いであり、実インフラとの一致は契約テストでしか保証されない | mockResolvedValue(...) / mockResolvedValueOnce(...) の引数 |
| フィクスチャ | 台本やテスト入力に使う集約・値。手書きのオブジェクトリテラルではなく、factory(reconstruct* / create*)か上流の型から導く |
reconstructStoredProfile({...})、upstreamJsonResponse(body) |
| モック | 本文では上記の 総称としてだけ 使う。vitest の vi.mock / vi.fn はスタブにもスパイにもなる道具なので、役割を言うときはスタブ・スパイ・ダミーのいずれかで言う |
— |
テストの種類
| 用語 | 定義 | 一般的な分類との対応 | 題材のリポジトリでの実体 |
|---|---|---|---|
| 核のテスト | ダブルを一切置かず、純粋なモジュールの入出力を検証するテスト | Solitary / Sociable Unit | domain/**/index.test.ts |
| 合成点 | 殻のダブルを与えて 実物の核を通す テスト。核と殻が出会う場所。本文の「組み上げ点」も同じ | Sociable Unit | api-server の route / usecase、フロントの BFF route / ClientAdapter hook |
| 契約テスト | 殻を 実インフラ(実 DB 等)に対して流し、契約どおり振る舞うかを検証するテスト。台本と実インフラのずれを検知する唯一の手段 | Integration | *.integration.test.ts |
| スモーク | デプロイ後に導線の到達だけを確かめるテスト。ダブルなし・仕様検証なし・導線ごとに一本。本文で「E2E」と書いているのはこれ | E2E | デプロイ後 |
失敗の名前
| 用語 | 定義 |
|---|---|
| 偽パス | 実装が壊れているのにテストが通る状態。発生源は四つ。(1) 核をダブルで隠す (2) 型なしダブル (3) 手書きフィクスチャ (4) 台本と実インフラのずれ |
| 責務漏れ | 上層のテストが下層の規則(VO の形式、エラーマップの status 等)を再検証している状態。偽パスではないが、下層の正当な変更で上層が落ちてテストの書き換えを誘発する |
前提0:すべては、アプリケーション全体が層ごとに閉じていることの上に成り立つ
先に断っておく。ここから書く規則も条件も、テストの書き方の工夫ではない。アプリケーション全体の設計が、層ごとに責務と契約を持って完結していることの帰結 だ。
具体的には、次の状態を指している。
- 層の境界がインターフェースとして明示され、依存の向きが一方向に固定されている
- 純粋な核と不純な殻が分離され、殻は薄く、核は殻を知らない
- 層をまたぐ値は factory と値オブジェクトを通ってしか作られない
- 層の境界の契約が型として表現され、破ると型エラーになる
この状態がなければ、以降の条件は一つも成立しない。
| 条件 | 設計側の前提がないと |
|---|---|
| A:純粋関数をモックしない | 純粋関数がなければ、モックしないという選択肢がない |
| B:モックは型を参照する | インターフェースがなければ、モックを縛る型がない |
| C:フィクスチャは実装から導出する | factory がなければ、導出する元がない |
| D:自分の層の責務だけをアサートする | 責務が層に割れていなければ、「自分の層の責務」が定義できない |
| E:殻は契約テストで守る | 殻が分離されていなければ、契約テストの対象が切り出せない |
そして層が閉じていないとき、層をまたいで穴を塞ぐ道具として E2E が必要になる。
だからこの記事は、テスト戦略の記事であると同時に、設計がここまで来ていれば E2E から仕様検証を剥がせる、という設計の到達点の記事 でもある。逆に言えば、E2E で仕様を守らざるをえない状態は、テストの問題ではなく、設計がまだ閉じていない合図だ。
前提1:テストは「認知された仕様」を実行可能な形で固定したもの
テストは書いた人が想定したケースしか守らない。これはユニットテストの弱点ではなく、テストという手法全般の性質だ。だからテストとは 「現時点で認知されている仕様」を実行可能な形で固定したもの であり、デグレとは 「認知された仕様からの逸脱」 と定義できる。
この定義を置くと、二つのことが決まる。
- 認知された仕様の範囲についてはテストが検知する。ここは論理的に閉じる
- 認知の外にある挙動(誰も仕様と思っていなかった順序や丸めや文言)はテストでは守れない
後者は設計やテストの問題ではなく仕様認知の問題なので、以降の議論は前者、つまり「認知された仕様をどの層のテストが担保するか」に絞る。
前提2:純粋な核と不純な殻に分ける
副作用のない純粋関数で核を組み上げ、I/O・時刻・乱数・DB・外部 API との接点を薄い殻に押し込める。これが以降すべての条件の土台になる。
純粋関数は決定的で速く、状態を共有しないので、テストが安定する。それ以上に重要なのは、純粋関数を実物で呼ぶコストがゼロ だという点だ。依存を隔離する動機は「遅い」「非決定的」「状態を共有する」のどれかで、純粋関数はそのどれにも該当しない。隔離しても速度も安定性も上がらず、失うものは検知範囲だけになる。
この構造にすると、デグレの発生地点は殻と合成点に集約される。「残りは担保済み」ではなく 「残りのリスクの所在が明確になった」 という状態で、その集約された場所に型と契約テストを当てるのがここからの話だ。
層ごとのテストの役割
規則から導かれる各層の役割を表にする。
| 層 | 純粋か | モック | 実物で呼ぶ | 検証するもの |
|---|---|---|---|---|
| Domain(VO / factory / behavior / policy) | 純粋 | なし | 全部 | 入力 → 出力の仕様そのもの。仕様の原本 |
| Usecase | 純粋(合成) | リポジトリのインターフェース(型に縛る) | domain の関数、factory で作ったフィクスチャ | Result への写像、正規化、参照しない条件 |
| Route(HTTP) | 不純な殻(上) | 殻の生成点(DI コンテナ) | usecase 以下すべて | ステータス、JSON の形、レスポンス契約 |
| Repository(DB) | 不純な殻(下) | クエリビルダ(CI では実 DB) | factory、toPersistence | 発行される SQL、行 → 集約の復元。契約テスト |
| UI コンポーネント | 純粋(props → 描画) | なし(モックデータは仕様) | 全部 | 描画と操作(stories + play 関数) |
| Fetcher / BFF | 不純な殻 | 上流クライアント(型に縛る) | 変換ロジック | 上流レスポンス → 画面モデルの写像、エラー分類 |
| デプロイ後スモーク | 環境 | なし | 本物すべて | 導線が繋がっていることだけ |
下の層の変更は、上の層が実物を呼ぶことで自然に衝突して検知される。これは目的というより、実物を呼ぶことで自然に得られる性質だ。
条件A:純粋関数をモックしない
呼び出し側のテストで純粋関数をモックすると、純粋関数の中身が壊れてもモックは固定値を返し続け、テストは通る。壊れた実装を呼んでいるのは本番だけ、という状態になる。モックは「この関数の挙動はこのテストの関心外」と宣言する道具なので、その関数の変化からテストを切り離す方向に働く。
実物を呼ぶと、呼び出し側のテストが自然と結合テストを兼ねて、純粋関数の意味変更(型は同じで丸めや順序や単位が変わる)が呼び出し側の期待と衝突した時点で落ちる。これが契約のズレを最も安く拾う方法だ。
「それは単体テストではないのでは」という違和感は正しく、これは Fowler の分類で言う sociable unit test にあたる。ただ unit の元の意味は「他のテストから独立して実行できる単位」であって「コード上の一つの関数」ではない。純粋な核の中では、「単体」を関数ではなく 振る舞いの単位 で取るほうが合理的だ。
条件B:モックは依存先の型を参照する
殻をモックするときは、モックがインターフェースの型に縛られている必要がある。型が変わる壊れ方(返り値の構造、null 許容、sum type の分岐追加、引数の追加)は、これで型検査に乗る。
usecase テストではすでにこうなっていた。
const createCaps = () =>
({
artistProfiles: {
load: vi.fn<IArtistProfileReader["load"]>(async () => ({
kind: "noProfile",
artistId: "artist-1",
})),
findPublishedByHandle: vi.fn<IArtistProfileReader["findPublishedByHandle"]>(
async () => null,
),
listPublishedSummaries: vi.fn<IArtistProfileReader["listPublishedSummaries"]>(
async () => [],
),
},
}) satisfies Pick<PublicReadCapabilities, "artistProfiles">;
一方 route テストでは vi.mock のファクトリ経由で型の鎖から外れていた。
// before: IArtistProfileReader に対して型検査されていない
const mockArtistProfiles = {
load: vi.fn(),
findPublishedByHandle: vi.fn(),
listPublishedSummaries: vi.fn(),
};
// after: 契約に縛る。メソッド追加や返り値の変更がコンパイルで落ちる
const mockArtistProfiles = {
load: vi.fn<IArtistProfileReader["load"]>(),
findPublishedByHandle: vi.fn<IArtistProfileReader["findPublishedByHandle"]>(),
listPublishedSummaries: vi.fn<IArtistProfileReader["listPublishedSummaries"]>(),
} satisfies IArtistProfileReader;
この修正をすると mockResolvedValue の引数が PublishedProfile | null に対して検査されるので、factory の戻り値(draft も含む StoredProfile)をそのまま渡せなくなり、kind === "published" で絞る必要が出る。型が「フィクスチャが本当に published か」を強制するようになる という意味で、望ましい方向だ。
型をすり抜けるのは「型は同じで意味だけ変わる」ケースで、これへの対処は意味を型に載せる(branded type、SortedList<T>)か、実物を呼ぶ(条件A)かの二方向になる。
フロント側では hc<ApiServerAppType> と hc<BeatfolioBffAppType> が同じ役割を果たしていて、UI → BFF → api-server の二段の契約が Hono のアプリ型から型検査されている。
条件C:フィクスチャは実装から導出する
テストで使う集約や値オブジェクトを手書きせず、factory(reconstructStoredProfile など)で生成する。factory の意味が変われば(章の順序ルールなど)、それを使っている上位層のテストが落ちる。手書きのフィクスチャは古い意味のまま残るので、条件Cは条件Aを型のレベルで補完するものだ。
条件D:各層は自分の層の責務だけをアサートする
実物を呼ぶ設計では、上の層のテストが下の層の規則まで検証し始めると、テストが層をまたいで重複し、下層の変更で上層のテストを書き換える必要が出る。
- route テストが chapters の順序を
toStrictEqualで見ているのは、順序ルールを検証しているのではなく「usecase の出力が JSON にそのまま出る」ことを見ている toHaveBeenCalledWithは観測可能な副作用(trim された handle で DB に問い合わせる、書式不正なら DB に到達しない)にだけ使う。「どう呼んだか」ではなく 「何が起きたか」 のアサートに留める
この線を守れば、下層が壊れたときに上層のテストが連鎖して落ちても、最も具体的な失敗は下層のテストに出るので原因の特定に困らない。逆に言えば 「各層に自分の責務のテストがある」ことが、実物を呼ぶ設計を成立させる前提条件 だ。
条件E:殻は契約テストで守り、CI で実インフラに対して流す
組み上げ点のテストは殻をモックで置き換えるので、殻の正しさは別に担保する必要がある。
| 殻 | 担保の仕方 |
|---|---|
| Repository | クエリビルダをモックして SQL 文を検証する(現状) |
| Repository(CI) | Postgres を立て、マイグレーションを空の DB に流し、本物の db で叩く。テストごとにトランザクションで巻き戻す |
| Storage | ローカルエミュレータに対して一本 |
これで「インフラに対するコードが間違っている」種類のバグは CI で閉じる。閉じないのは「その環境のインフラが期待と違う」種類(本番の接続文字列、権限、別々にデプロイされたアプリ間の版ずれ)で、これはテストではなく次のスモークの領域になる。
E2E の位置づけ
責務を四つに分解する
E2E が担っていた責務を分解すると四つになる。
| 責務 | 担う場所 |
|---|---|
| 仕様が正しいか | domain と UI の核 |
| 境界の型が合っているか | 型(satisfies、hc<AppType>) |
| 殻が正しいか | 契約テスト + CI インテグレーション |
| 繋がっているか | これだけが E2E に残る |
残った「繋がっているか」はテストというよりデプロイ後の疎通確認なので、その位置づけを保つための縛りを三つ置く。
三つの縛り
- 何もモックしない。 本物の Auth、DB、ストレージ、デプロイ済みの両アプリ。モックした瞬間に「実環境の検証」でなくなって、下の層のテストの劣化版になる
- アサートは「到達できた」だけにする。 ステータスと主要要素の表示まで。内容や順序や文言は見ない。それは stories と domain テストの責務で、ここで見ると E2E が仕様に張りついて壊れやすくなる
- 本数は導線の数で決める。 閲覧、オンボーディング、公開、くらいの主要導線を一本ずつ。条件分岐は増やさない。分岐は下の層で網羅済みだからだ
こうすると E2E が落ちたときの意味が一つに決まる。「どこかの配線が切れた」だけ だ。仕様のズレなら下の層が先に落ちているはずなので、E2E だけが落ちたら環境を疑えばいい。
「テストがないから E2E で覆う」は根本対処ではない
E2E で全体を覆って現状を固定する方法が固定しているのは「仕様」ではなく 「観測された振る舞い」 で、バグも偶然の挙動も含まれる。
単体テストは「この入力にこの出力であるべき」を人が判断して書くので、書いた時点で仕様が言語化される。E2E で全体を覆う場合は「今こう動いているからこれを正とする」で、仕様の認知は一切進まないまま現状が凍結される。デグレは検知できても、それが「仕様からの逸脱」なのか「バグの修正」なのかを判定する根拠がテストの中にないので、落ちるたびに人が考え直すことになる。
さらに、原因を局所化できない。層ごとに責務が閉じていないから E2E で覆っているわけで、E2E は「層が閉じていない」という問題をそのまま温存する。むしろ E2E があることで安心してしまい、層を閉じる動機が消える。そして E2E は振る舞いを全部固定するので、リファクタリングで内部を変えただけでも壊れる。根本対処である「設計を直す」こと自体が E2E に邪魔される、という逆転が起きる。
反証を一つ出しておく。Feathers の言う characterization test のように、設計の悪いコードをこれから直すための足場として一時的に E2E で覆うのは正当だ。設計がない状態でいきなり単体テストは書けないので、まず現状を固定して、層を切り出しながら単体テストに置き換え、置き換わった分だけ E2E を捨てる。この使い方なら E2E は根本対処への経路であって代替ではない。
E2E で覆うことが終点なら根本対処ではなく、始点なら根本対処の一部。
以前の記事からの更新
テスト戦略 では「E2E は現フェーズでは導入しない」と書いた。あれは「仕様検証のための E2E」を指していて、その判断は変わらない。今回追加したのは、仕様検証を一切持たない デプロイ後の疎通確認 という、別の役割の E2E だ。名前は同じでも、モックの有無・アサートの範囲・本数の決め方がすべて違う。
デグレをどう読むか
「デグレが起きた箇所は単体テストが不適切だった合図」という読み方は、事後分析の出発点としては正しい。ただし狭めすぎると他の要因を単体テストに押しつけることになる。
- 発生箇所と原因箇所は一致しない。症状の層のテストは正しく動いていることが多い
- 環境変数、依存更新、データ状態、同時実行、性能は単体テストが「不適切」ではなく「無関係」
- 認知の外の挙動への依存(Hyrum の法則)はテストの責任ではなく仕様認知の問題
- 意図的な仕様変更でテストを書き換えたなら、失敗したのはレビューの判断
- 一件のデグレは弱い証拠。毎回テストを足すと実装に張りついた壊れやすいテストが増える
- 正しい対処が型や設計であることがある(単位の取り違え → branded type)
合図として受け取るなら、問いは「どのテストが足りなかったか」ではなく 「これは何の層の問題だったか」 のほうが安全だ。
残るもの
この構成でも残るリスクは三つある。
| リスク | 内容 |
|---|---|
| 認知の外 | 誰も仕様と思っていなかった挙動への依存 |
| 仕様変更時の判断 | テストが仕様なら、テストの書き換えは仕様変更。「意図した変更」と「実装に合わせて壊した」はコードから区別できず、レビューでしか判定できない |
| 非機能 | 性能劣化やリソースの回帰は正しさのテストでは出ない |
技術で閉じる範囲と運用で閉じる範囲の境目がここにある。
判断の構成:流派は How として読む
ここまでの規則と条件は、テストの流派を選んで当てはめたものではない。順序は逆で、担保したいものを一つ置き、既存の流派をそれぞれ How として読み、採否を決めた結果 としてこの構造が出てきた。判断の全体を一枚にすると、こうなる。
入力は一つだけだ。認知された仕様が、変更後も壊れていないこと。 テストが通ることに意味がある、偽パスがない、と言い換えてもいい。
判定基準も一つで、各流派に同じ問いを当てる。
その流派が解こうとした問題は、このコードベースに存在するか。存在しないなら、弱点だけが残る。
| 流派 | 解いた問題 | このコードベースに存在するか | 判断 |
|---|---|---|---|
| ロンドン派(全協力者をモック、相互作用検証) | 副作用だらけの OOP で速さと設計の発見を得る | 核は純粋で、層と依存の向きは先に決まっている。存在しない | 採らない。残るのは偽パスとリファクタ耐性の低さ |
| 古典派(実物を呼ぶ、状態検証) | 振る舞いの固定と回帰の検知 | 担保したいものそのもの。存在する | 採る。隔離基準「使いにくいもの」を「不純なもの」に置換 |
| 核と殻(Bernhardt, 2012) | 副作用の所在を一箇所に集める | 殻に DB / HTTP / 時刻が集まっている。存在する | 採る。殻の境界がそのままモックの境界になり、古典派の隔離基準を供給する |
| 関数型 DDD(Wlaschin, 2018) | 状態遷移を隠れた状態と指示ではなく、戻り値で表す | Draft | Published、Result で表している。存在する |
採る。遷移が値で返ることが、戻り値で検証できる根拠になる |
| Khorikov の基準(管理外のプロセス外依存だけモック) | モック対象を人の判断から境界の性質に移す | 方向は同じ。存在する | 採る。「プロセス外・管理外」を「純粋か」でさらに機械化 |
| E2E(境界から境界まで全部実物) | 組み上がった全体が動くことの確認 | 仕様・型・殻は下で担保済み。配線の確認だけ残る | 責務を縮小して残す。モックなし、到達のみ、導線の数 |
採った How を担保したいものに接続した結果が、「モックするのは不純な殻だけ、アサートするのは自分の層の責務だけ」という規則と、条件A〜Eだ。
「古典派」という名前は、この構造に あとから付いたラベル であって、入力ではない。正確に言えば、隔離基準を純粋性に置換した古典派の変種になる。名前から入ると、その流派が解いた問題が自分のコードベースにあるかを問わずに道具だけを持ち込むことになり、弱点だけが残る。
そしてこの図の全体が、前提0の上に立っている。純粋な核がなければ条件Aは成立せず、インターフェースがなければ条件Bは成立せず、factory がなければ条件Cは成立しない。閉じていないなら、先に設計を閉じる。
次に新しい流派や道具が出てきたときも、やることは変わらない。同じ問い「それが解いた問題はここにあるか」を当てるだけでよい。
まとめ
E2E の必要性はテスト戦略の問題ではなく、設計の成熟度の関数だ。層ごとに責務と契約が閉じていれば、E2E が塞ぐ穴がなくなる。守るべきは E2E ではなく、規則のほうだ。
規則は一つ。モックするのは不純な殻だけ、アサートするのは自分の層の責務だけ。その規則が成り立つ前提が「アプリケーション全体が層ごとに閉じている」「テストは認知された仕様の固定」「純粋な核と不純な殻」の三つで、条件がA〜Eの五つだった。
新しいルートや殻を足すときのチェックリストにしておく。
- 純粋関数をモックしていないか
- 殻のモックはインターフェースの型に
satisfiesで縛られているか - フィクスチャは factory で生成しているか
- このテストは自分の層の責務だけをアサートしているか
-
toHaveBeenCalledWithは観測可能な副作用にだけ使っているか - 新しい殻には契約テストがあるか。CI で実インフラに対して流れるか
- E2E に仕様のアサートを書いていないか
層ごとにテストの意味が一つずつになる。Domain が落ちたら仕様、Route が落ちたら契約、契約テストが落ちたら殻、E2E が落ちたら環境。どこが落ちたかで、何を疑えばいいかが決まる。この構成の一番きれいなところは、そこだと思っている。