DIP――ブラウザの機能を、ポートの向こうへ置く
作成: / 公開: / 内容更新:
執筆:tamito0201 / 掲載・運営:プロマリ
丸い共有ボタンの裏側を掘る連載の第8回。SOLID の5つ目の原則 DIP を、「コピー」のボタン1つから確かめます。コピーを頼むコードはブラウザを知らず、約束(ポート)だけを知る。実行時の呼び出しは外へ、import は内へ。import の向きを数え、DI・DI コンテナ・ファクトリの役目を読み、わざと境界を破って何が止めるかまで、図16点で読み解…
検証:わざと境界を破ってみる
ここまでで、コピーを頼むユースケースが、約束(ClipboardGateway)だけを知り、ブラウザの実装は組み立て役が選んで渡していることを見てきました。DIPにかなった形です。けれど、形がそうなっていることと、崩れたときに誰かが気づくことは、別の話です。このページでは、わざと境界を破り、何が止めてくれるのかを実測します。
検証は、連載2.2と同じ手順で進めました。プラグインのリポジトリを git worktree で一時作業用のコピーに写し、公開しているプラグインには何も入れていません。まず何も変えずにテストを流すと、Node のテストが63件、すべて通りました。型検査も、内側だけの検査と全体の検査の両方が通りました。そのうえで、ユースケースの1か所だけを書き換えて、もう一度流します。
実験で一か所だけ変えるのは、何が原因で見張りが反応したかを読みやすくするためです。ブラウザを直接呼ぶ変更と、実装をimportする変更を一度に入れたら、型の検査が何を見つけたのかが混ざります。まず一つを試し、元へ戻してから次を試すことで、二つの崩れ方を比べられます。
ここに残す63件という数は、この固定した版と当時の検証条件の記録です。現在版のテスト総数や、どの環境でも同じ失敗件数になるという意味ではありません。図に数があると一般的な法則に見えやすいので、今回の追加説明では「何が外れたか」の因果関係を中心に読んでいきます。
実験1:ユースケースが navigator を直接呼ぶ
1つ目の実験は、いちばんありがちな崩れ方です。「わざわざ約束を通さなくても、ここで直接ブラウザを呼べば早い」と、ユースケースの1行を書き換えます。
--- a/web/src/application/HandleShareClickUseCase.ts
+++ b/web/src/application/HandleShareClickUseCase.ts
@@ -41,7 +41,7 @@ export class HandleShareClickUseCase {
case 'copy': {
preventDefault();
try {
- await this.#gateways.clipboard.write(button.url);
+ await navigator.clipboard.writeText(button.url);
return 'copied';
} catch {
this.#gateways.clipboard.fallback?.(button.url);変えたのは1行だけです。約束の write を通していたところを、ブラウザの navigator.clipboard.writeText に直接つなぎました。ブラウザで動かせば、コピーは今までどおり動きます。この状態で、型検査とテストを流しました。
$ npx tsc -p tsconfig.core.json
web/src/application/HandleShareClickUseCase.ts(44,17): error TS2304: Cannot find name 'navigator'.
$ npx tsc -p tsconfig.json
$ node --test 'web/test/**/*.test.ts'
✖ copy はクリップボードへ書いて copied を返し、既定動作を止める
✖ 書き込みの完了を待ってから結果を返す(完了前に成功を名乗らない)
✖ 内側の型検査はDOMもNodeの型もなく成功し、Elementを混ぜると失敗する
✖ クリックの通知は要素ごと・イベント名ごとに分かれる
ℹ tests 63
ℹ pass 59
ℹ fail 4上から読みます。1つ目の型検査は、domain と application だけを、ブラウザの型を持たない設定(tsconfig.core.json)で調べるものです。44行目の navigator について「Cannot find name 'navigator'(navigator という名前が見つからない)」と止めました。内側の層には、そもそもブラウザという言葉が存在しない設定になっているからです。2つ目の、ブラウザの型を含む全体の型検査は、何も出力せずに通りました。全体の型検査だけを見ていたら、この崩れには気づけませんでした。
テストは63件のうち4件が落ちました。「copy はクリップボードへ書いて copied を返す」テストは、代役を渡しているのに、ユースケースが代役を無視して本物のブラウザを呼ぶので、代役には何も記録されません。しかもテストを動かす Node では、navigator はあるのに navigator.clipboard がありません(手元の Node 24 で確かめると undefined でした)。そのため書き込みはいつも失敗し、copied ではなく copy-fallback が返りました。
ここで1つ、見落としやすいことがあります。「クリップボードが失敗したら fallback を出し、copied とは返さない」というテストは、この実験でも通りました。Node にはクリップボードが無いので、書き込みはいつも失敗し、たまたま期待どおりの結果になったのです。裏返せば、navigator を直接呼ぶ形では、テストで「コピーに成功した場合」を作ることができません。約束を通して代役を渡せば、グローバルな環境を変更せずに成功と失敗を選べます。これが、DIP がテストを書きやすくする、ということの具体的な中身です。
成功と拒否を、別々に選んで確かめられるか
失敗のテストが通ったことだけでは、望んだ経路を試せたとは限りません。次の切替スイッチでは、テストが成功と拒否をそれぞれ選べるかを見ます。スイッチは、実物に追加するUIではなく、代役の準備を表す模型です。
約束を通して代役を渡す側では、成功するwriteを渡せばcopied、拒否するwriteを渡せばcopy-fallbackという経路を調べられます。同じクリック処理を保ったまま、外の結果だけを変えています。図の二つのランプは、両方を一度に点灯するのではなく、条件を分けた二回の確認を表しています。
直接ブラウザを呼ぶように書き換えた実験では、渡した代役のスイッチを動かしても、コードがその口を使いません。Node側にクリップボードが無いという事情で、いつも失敗側へ進みました。テストが用意した条件と、実際に呼ばれた道具がずれていたのです。赤い切れ目が、この取りこぼしを表しています。
だから、「失敗時のテストが緑だった」という一件だけでは、差し替えが効いている証拠になりません。成功を準備したら成功へ、拒否を準備したら拒否へ進むか、両方を確かめます。さらに、代役へ渡したURLも観察すれば、単に結果の文字列を返しただけの実装も見つけやすくなります。テストの条件を操作できることと、その条件をコードが実際に使うことを、セットで見ます。
切替の図を見ながら、テストの失敗を予想してみましょう。成功を返す代役を渡したのに結果がcopy-fallbackなら、代役の入口が使われていないか、別の箇所で失敗しているかを調べます。拒否する代役を渡したのにcopiedなら、失敗を待たずに結果を返した可能性があります。二つの条件があると、原因を絞る手がかりが増えます。
さらに、代役へ渡されたURLを記録して、押されたボタンのURLと比べます。別の記事のURLをコピーしても、Promise自体は成功できます。結果の名前がcopiedであることに加え、何をコピーするよう頼んだかも見る必要があります。成功ランプだけでなく、流した値にも窓を開けるわけです。
この三つを一緒に確かめれば、境界を通って呼んだこと、意図した入力を渡したこと、結果に応じて判断したことがつながります。ブラウザでの保存の確認は別に残りますが、方針の側が引き受ける仕事は、代役を使って具体的に観察できます。テストしやすいという言葉を、その三つの確認として説明できます。
これは、すべての依存をテスト用に好きな結果へ変えてよいという意味でもありません。代役は、公開された契約の範囲にある成功や失敗を再現します。契約に無い戻り値を作っても、現実の振る舞いの確認にはなりません。前々回のLSPが、スイッチで選んでよい結果の範囲を支えています。
実験2:ユースケースがブラウザの実装を import する
2つ目の実験は、もう少し行儀のよさそうな崩れ方です。navigator を直接書く代わりに、ブラウザの実装(BrowserClipboard)を import して、ユースケースの中で new します。いわば、2ページ目の「ボタンが保存先を自分で作る形」を、実物でやってみるわけです。
--- a/web/src/application/HandleShareClickUseCase.ts
+++ b/web/src/application/HandleShareClickUseCase.ts
@@ -3,6 +3,7 @@
* domain perform side effects. The outcome is returned, and presentation decides how to show it.
*/
import type { ShareGateways } from '../domain/gateway/ShareGateways.ts';
+import { BrowserClipboard } from '../infrastructure/BrowserClipboard.ts';
import { ShareActionPolicy } from '../domain/service/ShareActionPolicy.ts';
import type { ShareButtonViewModel } from './BuildShareBarUseCase.ts';
@@ -41,7 +42,7 @@ export class HandleShareClickUseCase {
case 'copy': {
preventDefault();
try {
- await this.#gateways.clipboard.write(button.url);
+ await new BrowserClipboard().write(button.url);
return 'copied';結果は、依存の境界のテストが、崩した場所を名指しで止めました。「型import・再exportを含めて許可した方向だけに依存する」というテストが落ち、その理由として application/HandleShareClickUseCase.ts から application → ../infrastructure/BrowserClipboard.ts へ向かう依存が見つかった、と表示されます。テスト全体では63件のうち5件が落ちました。内側の型検査も、import した BrowserClipboard.ts の中にある navigator と window の3か所で止まりました。
この依存の境界のテスト(architecture.test.ts)は、フォルダの名前だけでなく、ファイルの中の import を1つずつ読み取って、許可した向きだけかを確かめています。許可している向きは、domain は domain だけ、application は application と domain、infrastructure は infrastructure と domain、presentation は presentation と application、そして composition は全部です。さらに、禁止した形の書き方(逆向きの import、動的な読み込みによる迂回、表示の層での navigator の直接呼び出しなど)をわざと書いた例を17通り用意し、そのすべてを検査が見つけることまで確かめています。見張りそのものが効いているかも、テストで見張っているのです。
二つ目の実験は、ブラウザAPIをユースケースから見えにくい場所へ移しただけでは不十分だと教えてくれます。BrowserClipboardという実装クラスの中へ呼び出しを隠しても、ユースケースがそのクラスを名指しで作っていたら、道具を選ぶ責任は内側に残っています。違反は、navigatorという文字列だけを探しても見つけきれません。
境界のテストは、その具体名へ向かうimportを見つけました。内側の型検査は、読み込まれた実装の中にあるブラウザの名前を見つけました。同じ変更で両方が止まっても、見ているものは違います。図の結果を読むときは、「何件落ちたか」から一歩進めて、「どの性質を検査して落ちたか」を対応させてみてください。
3つの見張りと、見張れないもの
2つの実験で働いた見張りを、まとめておきます。1つ目は、ブラウザの型を持たない内側の型検査です。方針のコードにブラウザの名前が1つでも入れば、動かす前に止まります。2つ目は、依存の境界のテストです。禁止した向きの import を、ファイル名と行き先を名指しして止めます。3つ目は、代役を渡すテストです。約束を通していないコードは、代役を渡しても思いどおりに動かないので、期待した結果と食い違って落ちます。
境界は、書いたときに守られていても、次の変更で崩れます。守り続けるのは見張りの仕事です。どの見張りも、1つだけでは足りませんでした。全体の型検査は実験1を見逃しましたし、grep はコメントとコードを区別できませんでした。
一方で、見張れないものもあります。3つの見張りが確かめているのは、どの名前を知っているかと、約束どおりに動くかです。約束の中身そのものがよいか、たとえば write が成功と失敗を正直に返しているか、ブラウザの実装が権限を断られたときに本当に失敗を返すか、までは保証しません。BrowserClipboard の中身は、ブラウザで動かして確かめる必要があります。約束の形をそろえても、本番の実装だけが黙って失敗するなら、差し替えられるとは言えないからです。
考えてみる:見張りのテストがあるなら、境界を破るコードは、そもそも書けないのでは?
書くことはできます。見張りは、書いたあとで止めるものです。実験でも、書き換えたコードもブラウザで動きうる形ですが、全体の型検査も通りました。見張りの価値は、崩れたことに、レビューの目ではなく機械が、しかも崩れた直後に気づくことにあります。人の目では「1行だけだから」と見過ごしやすい崩れほど、見張りが効きます。
見張りが置かれているだけでは、変更が常に防がれるわけではありません。その検査を変更後に実行し、失敗を見て修正する流れが必要です。実行しないテストは、図に描いた門が開きっぱなしの状態と同じです。どのコマンドがどの見張りを動かすかも、設計を運用するための知識になります。
見張り自身をわざと悪い例で確かめる点も、覚えておくと役立ちます。許されるコードが通るだけでは、禁止したコードを見つける能力は確認できません。通す例と止める例の両方を用意します。今回の二つの実験も、検査が緑であることを眺めるだけでなく、境界を崩したときに反応することを確かめるためのものです。
それでも、形式的な境界を守りながら、使う側に不要な手順を要求する口は作れてしまいます。先ほどのDatabaseRowを要求する例のように、型の持ち主や意味の設計は人がレビューする必要があります。機械が判定できる向きと、仕事に合う契約かという判断を組み合わせると、見張りに任せる範囲を正確に説明できます。









このページ(5ページ目)の感想・質問・設計へのコメント