logoChibiham
cover
⚖️

構造と振る舞い:型とテストによるアジリティと品質の両立

導入:アジリティと品質は両立するか

「この修正、影響範囲どこまでですか?」——コードレビューでよく聞かれる質問だ。そして「ちょっと調べてみます」と答えて、関連するコードを追いかけ、テストを確認し、結局半日かかる。あるいは「たぶん大丈夫です」と答えて、本番で障害を起こす。

スピードを優先すれば品質が犠牲になり、品質を追求すれば開発が遅くなる。多くの開発現場でこの二項対立が前提として語られている。しかし、アジリティと品質は、適切な設計によって両立できる

ここでいうアジリティとは、単に「速く書く」ことではない。変更への対応速度だ。要件が変わったとき、バグが見つかったとき、新機能を追加するとき——そのフィードバックループをいかに高速に回せるか。これがアジリティの本質だと考えている。

そして、このFBループを高速化する鍵は、ドメインの認知構造がコードに直接反映されていることにある。ドメインの概念とコードの構造が一致していれば、「ここを変えたい」という意図がそのまま「このコードを変える」に直結する。概念とコードの間に翻訳コストがかからない。

本稿では、この「ドメインとコードの同型性」を実現するための戦略として、型による構造の表現テストによる振る舞いの検証という二層構造を提案する。

構造と振る舞い:コードベースの二つの側面

まず、コードベースを構成する二つの側面を明確に区分しておきたい。

構造とは何か

本稿で「構造」と呼ぶのは、概念間の結合・依存関係・境界から生まれる制約の総体だ。

  • モジュール間の依存関係と境界
  • 概念間の結合によって生まれる不変条件(例:「出荷済み注文には支払い日時が必ず存在する」)
  • 技術的制約(プロトコル、データフォーマット等)

ここで重要なのは、概念単体の内部的な制約(「金額は0以上」のような単純なバリデーション)ではなく、概念間の関係性から生まれる制約に焦点を当てているということだ。

振る舞いとは何か

一方、「振る舞い」とは、特定の入力に対する出力、あるいは操作による状態変化を指す。

  • あるAPIエンドポイントにリクエストを送ったときのレスポンス
  • ある関数に引数を渡したときの戻り値
  • ある操作を行った後のシステムの状態

両者の関係

構造と振る舞いは独立ではない。構造が振る舞いを制約する

例えば、「出荷済み注文には支払い日時が必ず存在する」という構造的制約があれば、「出荷済み注文の支払い日時を取得する」という振る舞いは常に成功する(nullチェックが不要になる)。逆に、この構造的制約がなければ、振る舞いの実装にnullチェックという分岐が必要になり、テストケースも増える。

良い構造は、振る舞いの実装を単純にし、検証すべきケースを減らす。

型による構造の表現

Make Illegal States Unrepresentable

型システムは、概念間の関係性と制約をコードレベルで表現する手段だ。Make Illegal States Unrepresentable(不正な状態を表現不可能にする)の原則に従えば、型定義そのものがドメインの構造を反映する。

typescript
// 構造が弱い — 不正な状態が表現可能
type Order = {
  status: string;
  paidAt: Date | null;
  shippedAt: Date | null;
};
// status が "shipped" なのに paidAt が null という不正状態を許してしまう

// 構造が強い — 不正な状態がコンパイル時に排除される
type Order =
  | { status: "draft" }
  | { status: "paid"; paidAt: Date }
  | { status: "shipped"; paidAt: Date; shippedAt: Date };
// 各状態で存在すべきフィールドが型レベルで強制される

後者の設計では、「出荷済みなのに支払い日時がない」という状態をテストで検証する必要がない。コンパイラが排除してくれる。

型定義がドメインモデルになる

この考え方を推し進めると、型定義自体がドメインモデルの表現になる。ドメインエキスパートと話すときに使う概念——注文、支払い、出荷——がそのまま型として定義され、それらの関係性が型の構造として表現される。

これが「ドメインの認知構造がコードに直接反映されている」状態だ。ドメインの言葉とコードの言葉が一致しているため、要件の変更がどのコードに影響するかが明確になる。FBループが速くなる理由がここにある。

テストによる振る舞いの検証

実装詳細 vs インターフェース

テストが担うべきはインターフェースに現れる振る舞いの検証だ。ここで重要な区分がある。

実装詳細に結合したテストは、リファクタリングで構造が変わると壊れる。しかしこれはテストという手法の限界ではなく、テストの書き方の問題だ。モジュールの内部実装ではなく、モジュールが外部に公開するインターフェースに対してテストを書いていれば、内部構造のリファクタリングでは壊れない。

つまり、テストの対象はモジュールのインターフェースに現れる振る舞いであるべきで、内部の個々の実装関数に対するテストは構造変化に対して脆い。

フラクタル的なインターフェーステスト

「インターフェースに対してテストする」という原則は、すべての粒度で適用できる。

フラクタル的なインターフェーステスト

ここで型は二つの役割を担っている。一つはインターフェースの形を定義すること——関数シグネチャ、リクエスト/レスポンスの型など、各境界で何を受け取り何を返すかを型で宣言する。もう一つは内部実装の構造的整合性を保証すること——先述のMake Illegal States Unrepresentableの原則だ。

テストは、型で定義されたインターフェースの上で振る舞いを検証する。「この入力に対してこの出力が返る」という動的な性質は、型だけでは表現できない。

この構造が入れ子状にすべてのスケールで繰り返される。

ただし、E2Eテストは性質がやや異なる点に注意が必要だ。モジュールテストや統合テストが単一のインターフェースに対する契約を検証するのに対し、E2Eテストは複数のモジュールを跨いだユーザージャーニー全体を検証する。「境界に対して振る舞いを検証する」という点では共通するが、目的や技術的なセットアップは異なる。

型で表現しきれない制約

すべての構造的制約が型で表現できるわけではない。例えば「注文は在庫がある商品に対してのみ作成できる」という制約。これは概念間の結合から生まれる不変条件だが、実行時の状態に依存するため、型だけでは表現しきれない。

この種の制約に対しては、その制約が現れる境界のインターフェースでテストとして検証するのが妥当だ。

制約の種類保証手段
概念間の構造的制約(静的)型システム出荷済み注文には支払い日時が必須
実行時状態に依存する制約インターフェーステスト在庫がある商品のみ注文可能

両者は二者択一ではなく、補完的な関係にある。

設計プロセス:往復運動と順序

ここで一つの問いを検討したい。構造と振る舞い、設計からコードに落とすとき、どちらを先に考えるべきか。

思考は往復運動

実際の設計プロセスでは、構造と振る舞いの思考は往復する。

  • 「この振る舞いを実現したい」と考えて、初めて「この概念は分離すべきだ」と気づく
  • 逆に、「この構造にしよう」と決めると、「この振る舞いはこう制約される」と見えてくる

ユースケースを列挙することで必要なエンティティが見え、エンティティの関係を整理することで可能な操作が制約される。この往復は設計の本質的なプロセスだと思う。

コードに落とす順序は「構造→振る舞い」

しかし、コードに落とす段階では「構造を先に、振る舞いを後に」という順序が有効だと考えている。

理由は明確だ。型(構造)を先に定義すると、その型に沿った振る舞いしか書けなくなる。コンパイラがガードレールとして機能し、不正な振る舞いの実装を防いでくれる。

逆に、振る舞いから書き始めると、構造が不安定なまま進むことになりやすい。後から構造を直すとき、既に書いた振る舞いのコードも大幅に書き直す必要が出てくる。

型を書くことで設計のFBを得る

もう一つ重要な点がある。型定義を書いてみることで、抽象的な設計の妥当性を検証できる

「この概念はこういう構造だろう」と頭の中で考えていても、実際に型として書き下そうとすると矛盾や曖昧さに気づくことが多い。「型が書きにくい」は「設計がおかしい」のシグナルだ。

これは抽象的な設計から具象的なコードへのFBループであり、このループを高速に回すことで設計の質が上がる。

構造が良ければテストは肥大化しない

ここで、構造が良ければ、振る舞いのパターンも限定的で、テストも肥大化しないのではないか?という直感について検討したい。

偶有的複雑性の排除

Fred Brooksは「銀の弾丸」論文で、ソフトウェアの複雑性を本質的複雑性(ドメインに内在する複雑性)と偶有的複雑性(技術的選択や設計の不備から生じる複雑性)に分けた。

型によって不正な状態を表現不可能にすれば、テストで検証すべき状態空間が縮小する。先のOrder型の例で見たように、型が状態空間を制約するほど、テストのケース数は減る。これは偶有的複雑性から生まれるテストケースの削減だ。

本質的複雑性の局所化

一方、ドメインに内在する本質的複雑性は、構造の良し悪しに関わらず残る。例えば税率計算——商品カテゴリ、地域、顧客種別、キャンペーン適用の組み合わせで税率が変わるような場合、型で各概念を明確に分離しても、組み合わせのパターン自体は減らない。

しかし、構造が良ければ、この本質的複雑性は局所化される。税率計算モジュールの中に閉じ込められ、その境界の外には漏れ出さない。モジュール内ではテストが多くなるかもしれないが、プロダクト全体から見れば、テストは肥大化していない。

テストの肥大化は構造の問題のシグナル

ここで「構造の良し悪し」の基準を明確にしておきたい。良い構造とは、ドメインの概念構造がコードに直接マッピングされている状態だ。ドメインで「注文」「支払い」「出荷」と呼ばれる概念が、そのまま型として定義されている。概念間の関係性(「出荷には支払いが先行する」)が、型の構造として表現されている。

この基準に照らせば、「構造が良い」ということは、概念間の責務分離が明確で、各インターフェースが限定された関心事のみを扱っているということだ。限定された関心事は、限定された入出力パターンを生む。

逆に言えば、テストが肥大化するのは、一つのインターフェースが多すぎる関心事を引き受けている兆候だ。テストの肥大化は構造の問題のシグナルであって、テストの問題ではない。

結論:型とインターフェーステストの二層戦略

本稿の議論をまとめる。

第一層:型による構造の保証

  • Make Illegal States Unrepresentableの原則に従い、不正な状態を型レベルで排除する
  • モジュール間の依存関係と境界を型で表現する
  • コンパイラがガードレールとして機能する

第二層:インターフェーステストによる振る舞いの検証

  • 各モジュールの公開インターフェースに対して振る舞いを検証する
  • 実行時状態に依存する制約は、境界のインターフェースで統合テストとして検証する
  • 各層の境界でインターフェーステストを行う(E2Eは性質が異なる点に留意)

この二層構造が、アジリティと品質を両立させる。

  • がドメインの構造を表現することで、概念とコードのギャップが縮まり、変更の影響範囲が明確になる
  • インターフェーステストが振る舞いを検証することで、リファクタリングしても壊れない安全網ができる
  • 型を書くことで設計へのFBを得て、テストを書くことで実装へのFBを得る

「早く作る」と「正しく作る」はトレードオフではない。適切な構造——ドメインの認知構造がそのまま反映された型定義と、各境界でのインターフェーステスト——によって、FBループを高速に回しながら、品質を維持し続けることができる。