テスト駆動は「欲しい機能を先に書く」。
スキーマ駆動は「欲しいインターフェースを先に書く」。
別物として語られがちですが、僕にはどうにも同じものに見えます。
テスト駆動は「欲しい機能」を先に書く
TDDの基本サイクルはRed→Green→Refactorです。
test("ユーザー作成時にIDが採番される", () => {
const user = createUser({ name: "n", email: "n@example.com" })
expect(user.id).toBeDefined()
})
まだ実装がない状態でこのテストを書きます。
「こう振る舞ってほしい」を先に宣言しているわけです。
実装は、このテストを通すために後から書かれます。
テストが仕様書で、実装はその仕様に追従する存在です。
スキーマ駆動は「欲しいインターフェース」を先に書く
スキーマ駆動開発も同じ流れです。
スキーマの記法にはvalibotを使います(zodでも話は同じです)。
import * as v from "valibot"
const CreateUserSchema = v.object({
name: v.string(),
email: v.string(),
})
これも実装より先に書きます。
「このAPIはこういう形であってほしい」という宣言です。
実装は、このスキーマを満たすように後から書かれます。
スキーマが契約で、実装はその契約に追従する存在です。
「先に書く」の対象が違うだけ
並べるとこうなります。
| 先に書くもの | 実装の役割 | |
|---|---|---|
| テスト駆動 | 期待する振る舞い(入力→出力) | テストを通す |
| スキーマ駆動 | 期待するインターフェース(型・契約) | スキーマを満たす |
どちらも「欲しい結果を先に固定して、実装を後追いさせる」という構造は同じです。
違うのは、固定する対象の解像度と検証タイミングだけだと思っています。
- テストは動かして確認する。実行時の振る舞いを保証する。
- スキーマは型を通して確認する。静的にインターフェースを保証する。
スキーマ駆動は下火なのか
正直なところ、「スキーマ駆動開発」という言葉自体はちょっと下火な気がしています。
OpenAPIのYAMLを別ファイルで書いて、そこからサーバーとクライアントのコードを生成する、というスタイルが面倒くさがられている印象があります。
ただ、下火なのはそのスタイルであって、スキーマ駆動という考え方そのものではないと思っています。
Honoのようにvalidatorを実装内に書いて、そこから型を推論する方式も、構造としては同じです。
export const userRoutes = new Hono().post(
"/users",
zValidator("json", createUserSchema),
(c) => c.json({ id: "user_123", ...c.req.valid("json") }, 201),
)
export type UserAppType = typeof userRoutes
クライアントが知っているのはUserAppTypeから推論された入出力の形だけで、Honoサーバーの実装は知りません。
これは「契約(スキーマ)が先にあり、実装はそれを満たす」という関係そのものです。
違うのは、スキーマを独立したIDLファイルとして持つか、実装(validator)に埋め込んで型推論に任せるかという置き場所だけです。
コード生成というワンクッションが消えただけで、「先に契約を固定する」というスキーマ駆動の芯は変わっていません。
だから正確には、下火なのは「スキーマ駆動 = spec-first + codegen」という一つの実装形態であって、スキーマ駆動という発想そのものは、コードに埋め込む形でむしろ生き残っている、というのが実態に近い気がしています。
「回帰を防ぐ」も「壊れてほしい所を壊す」も同じ話
TDDでテストを書く動機のひとつは回帰の防止です。
一度満たした仕様を、後から誰かが壊さないようにする。
スキーマ駆動でスキーマを先に書く動機も似ています。
インターフェースを変えたら、それに依存している箇所がコンパイル時に壊れてほしい。
これは以前書いた記事でも触れましたが、良い設計の本質は結局これです。
変更したら壊れてほしいところが壊れるようにする
テストは実行時にこれをやります。
スキーマ(型)は静的にこれをやります。
いつ壊れを検知するかが違うだけで、やりたいことは同じです。
違うのは重心が「振る舞い」か「使い方」か
とはいえ、完全に同一だと言うつもりはありません。
テスト駆動が強いのは振る舞いの記述です。
test("在庫が0のとき購入できない", () => {
const result = purchase({ stock: 0, quantity: 1 })
expect(result.ok).toBe(false)
})
型では表現しづらい「条件によって結果が変わる」ロジックは、テストの方が自然に書けます。
スキーマ駆動が強いのは使い方の記述です。
type CreateUserInput = v.InferOutput<typeof CreateUserSchema>
「このAPIをどう呼べばいいか」「どんな形が返ってくるか」は、テストを1つずつ書くよりも型で表現した方が圧倒的に効率がいいです。
そもそも呼び出し側のコードが書けなくなるので、テストを書くまでもなく壊れます。
つまり両者は競合するものではなく、担当領域が違うだけです。
- 「呼べるかどうか」「形が合っているかどうか」はスキーマに任せる
- 「その状況でどう振る舞うべきか」はテストに任せる
ライブラリはスキーマ駆動、アプリケーションはテスト駆動が向いている
この重心の違いは、書いているものが何かによっても変わってくると思っています。
ライブラリは、利用者から見ると「どう呼べば、何が返るか」がほぼ全てです。
内部でどう実装しているかは関心の外で、契約さえ守られていれば中身は差し替わってもいい。
しかも利用者は不特定多数で、どんな組み合わせで呼ばれるかを書いた本人が把握しきれません。
だから、想定していない使われ方をされた瞬間に型で弾けるようにしておく価値が大きいです。
declare function createUser(input: v.InferOutput<typeof CreateUserSchema>): User
この関数の価値のほとんどは「入力と出力の形」にあります。
振る舞いのテストも当然書きますが、契約が壊れていないかを守るのはスキーマの役目です。
一方でアプリケーションは、ビジネスルールの塊です。
「在庫が0なら買えない」「割引は初回のみ適用」「退会済みユーザーは投稿できない」
こういう条件分岐は型では表現しきれません。
stock: number という型が正しくても、それだけでは「0の時に弾く」ロジックが正しいかはわかりません。
しかもアプリケーションの呼び出し元は、多くの場合自分たちが書いた画面やバッチだけです。
不特定多数の利用者を想定した契約の厳格さよりも、「この業務ルール通りに動いているか」を確認する方が価値が高い。
なので、ライブラリを書くときはスキーマ駆動の比重を上げて、アプリケーションのビジネスロジックを書くときはテスト駆動の比重を上げる、というのが僕の肌感です。
どちらか一方だけでいいという話ではなく、書いているものの性質に合わせて重心を移す、というだけの話ですが。
仕様駆動開発は微妙
似た名前で「仕様駆動開発(Spec-Driven Development)」というのも最近見かけます。
自然言語で仕様書をがっつり書いて、それをLLMに渡して実装させる、というものです。
「先に欲しいものを書く」という表面上の構造だけ見るとTDDやスキーマ駆動の仲間に見えます。
でも、僕はこれをむしろ対極だと思っています。
TDDのテストも、スキーマ駆動のスキーマも、機械的に検証可能という一点で成立しています。
テストは実行すれば通るか落ちるかがわかる。
スキーマは型チェッカーが判定してくれる。どちらも決定論的で、Yes/Noがはっきりします。
仕様駆動開発は、この一番大事な軸が丸ごと反転しています。
検証者がLLM自身、あるいは人間の目視になった瞬間、判定は非決定論的になります。
仕様書の解釈にも揺れが出るし、同じ仕様書でも実行するたびに違う結果になり得ます。
つまり「先に固定する」という形は似ていても、「固定したものをどう検証するか」という中身は真逆です。
TDDとスキーマ駆動が決定論の側に立つ手法なら、仕様駆動開発は非決定論の側に立つ手法。
名前の並びに騙されて同じカテゴリに入れるべきではないと思っています。
自然言語の仕様書を書くなとは言いません。
でも、それをテストやスキーマの代わりにするのは違うと思っています。
仕様書は人間同士の合意形成のために書き、検証はテストとスキーマに任せる。この役割分担が大事です。
まとめ
テスト駆動もスキーマ駆動も、根っこは同じです。
欲しいものを先に書いて、実装をそれに追従させる。変更したら壊れてほしいところを壊す。
違いは、それをテストという実行時の検証で担保するか、スキーマという静的な検証で担保するかだけです。
- インターフェースの形はスキーマ(できればvalibot)で縛る
- 条件付きの振る舞いはテストで縛る
- 自然言語の仕様書に検証まで任せない
両方使えば、静的にも動的にも「壊れてほしいところがちゃんと壊れる」システムになります。