PROMARI JOURNAL

DIP――ブラウザの機能を、ポートの向こうへ置く

作成: / 公開: / 内容更新:

執筆:tamito0201 / 掲載・運営:プロマリ

丸い共有ボタンの裏側を掘る連載の第8回。SOLID の5つ目の原則 DIP を、「コピー」のボタン1つから確かめます。コピーを頼むコードはブラウザを知らず、約束(ポート)だけを知る。実行時の呼び出しは外へ、import は内へ。import の向きを数え、DI・DI コンテナ・ファクトリの役目を読み、わざと境界を破って何が止めるかまで、図16点で読み解…

PASS IT ON

ひとつの発見を、次の会話へ。

検証:わざと境界を破ってみる

ここまでで、コピーを頼むユースケースが、約束(ClipboardGateway)だけを知り、ブラウザの実装は組み立て役が選んで渡していることを見てきました。DIPにかなった形です。けれど、形がそうなっていることと、崩れたときに誰かが気づくことは、別の話です。このページでは、わざと境界を破り、何が止めてくれるのかを実測します。

検証は、連載2.2と同じ手順で進めました。プラグインのリポジトリを git worktree で一時作業用のコピーに写し、公開しているプラグインには何も入れていません。まず何も変えずにテストを流すと、Node のテストが63件、すべて通りました。型検査も、内側だけの検査と全体の検査の両方が通りました。そのうえで、ユースケースの1か所だけを書き換えて、もう一度流します。

実験で一か所だけ変えるのは、何が原因で見張りが反応したかを読みやすくするためです。ブラウザを直接呼ぶ変更と、実装をimportする変更を一度に入れたら、型の検査が何を見つけたのかが混ざります。まず一つを試し、元へ戻してから次を試すことで、二つの崩れ方を比べられます。

ここに残す63件という数は、この固定した版と当時の検証条件の記録です。現在版のテスト総数や、どの環境でも同じ失敗件数になるという意味ではありません。図に数があると一般的な法則に見えやすいので、今回の追加説明では「何が外れたか」の因果関係を中心に読んでいきます。

実験1:ユースケースが navigator を直接呼ぶ

1つ目の実験は、いちばんありがちな崩れ方です。「わざわざ約束を通さなくても、ここで直接ブラウザを呼べば早い」と、ユースケースの1行を書き換えます。

Diff実験1の git diff。- が元の行、+ が書き換えた行
--- 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 に直接つなぎました。ブラウザで動かせば、コピーは今までどおり動きます。この状態で、型検査とテストを流しました。

Shell実験1のあとに型検査とテストを流した結果(抜き出し・時間の表示は省略)
$ 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 が返りました。

図5.1-1 application が navigator を直接呼んだら
図5.1-1 application が navigator を直接呼んだら。実測(わざと破った実験)。DOM の型を持たない内側の型検査と、代役を渡すテストが気づきました。全体の型検査だけなら、黙って通っていました。図5.1-1 application が navigator を直接呼んだら。実測(わざと破った実験)。DOM の型を持たない内側の型検査と、代役を渡すテストが気づきました。全体の型検査だけなら、黙って通っていました。

ここで1つ、見落としやすいことがあります。「クリップボードが失敗したら fallback を出し、copied とは返さない」というテストは、この実験でも通りました。Node にはクリップボードが無いので、書き込みはいつも失敗し、たまたま期待どおりの結果になったのです。裏返せば、navigator を直接呼ぶ形では、テストで「コピーに成功した場合」を作ることができません。約束を通して代役を渡せば、グローバルな環境を変更せずに成功と失敗を選べます。これが、DIP がテストを書きやすくする、ということの具体的な中身です。

成功と拒否を、別々に選んで確かめられるか

失敗のテストが通ったことだけでは、望んだ経路を試せたとは限りません。次の切替スイッチでは、テストが成功と拒否をそれぞれ選べるかを見ます。スイッチは、実物に追加するUIではなく、代役の準備を表す模型です。

図5.2-1 成功と拒否を、別々に選んで試す
図5.2-1 成功と拒否を、別々に選んで試す。テストの条件を表す説明例。成功を準備したら成功へ、拒否を準備したら拒否へ。両方を確認すると、代役が使われない失敗を見つけやすくなります。図5.2-1 成功と拒否を、別々に選んで試す。テストの条件を表す説明例。成功を準備したら成功へ、拒否を準備したら拒否へ。両方を確認すると、代役が使われない失敗を見つけやすくなります。

約束を通して代役を渡す側では、成功するwriteを渡せばcopied、拒否するwriteを渡せばcopy-fallbackという経路を調べられます。同じクリック処理を保ったまま、外の結果だけを変えています。図の二つのランプは、両方を一度に点灯するのではなく、条件を分けた二回の確認を表しています。

直接ブラウザを呼ぶように書き換えた実験では、渡した代役のスイッチを動かしても、コードがその口を使いません。Node側にクリップボードが無いという事情で、いつも失敗側へ進みました。テストが用意した条件と、実際に呼ばれた道具がずれていたのです。赤い切れ目が、この取りこぼしを表しています。

だから、「失敗時のテストが緑だった」という一件だけでは、差し替えが効いている証拠になりません。成功を準備したら成功へ、拒否を準備したら拒否へ進むか、両方を確かめます。さらに、代役へ渡したURLも観察すれば、単に結果の文字列を返しただけの実装も見つけやすくなります。テストの条件を操作できることと、その条件をコードが実際に使うことを、セットで見ます。

切替の図を見ながら、テストの失敗を予想してみましょう。成功を返す代役を渡したのに結果がcopy-fallbackなら、代役の入口が使われていないか、別の箇所で失敗しているかを調べます。拒否する代役を渡したのにcopiedなら、失敗を待たずに結果を返した可能性があります。二つの条件があると、原因を絞る手がかりが増えます。

さらに、代役へ渡されたURLを記録して、押されたボタンのURLと比べます。別の記事のURLをコピーしても、Promise自体は成功できます。結果の名前がcopiedであることに加え、何をコピーするよう頼んだかも見る必要があります。成功ランプだけでなく、流した値にも窓を開けるわけです。

この三つを一緒に確かめれば、境界を通って呼んだこと、意図した入力を渡したこと、結果に応じて判断したことがつながります。ブラウザでの保存の確認は別に残りますが、方針の側が引き受ける仕事は、代役を使って具体的に観察できます。テストしやすいという言葉を、その三つの確認として説明できます。

これは、すべての依存をテスト用に好きな結果へ変えてよいという意味でもありません。代役は、公開された契約の範囲にある成功や失敗を再現します。契約に無い戻り値を作っても、現実の振る舞いの確認にはなりません。前々回のLSPが、スイッチで選んでよい結果の範囲を支えています。

実験2:ユースケースがブラウザの実装を import する

2つ目の実験は、もう少し行儀のよさそうな崩れ方です。navigator を直接書く代わりに、ブラウザの実装(BrowserClipboard)を import して、ユースケースの中で new します。いわば、2ページ目の「ボタンが保存先を自分で作る形」を、実物でやってみるわけです。

Diff実験2の git diff。+ が足した行、- が消した行
--- 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か所で止まりました。

図5.3-1 application がブラウザの実装を import したら
図5.3-1 application がブラウザの実装を import したら。実測(わざと破った実験)。禁止した向きの import は、依存の境界のテストがファイル名と行き先を名指しして止めました。図5.3-1 application がブラウザの実装を import したら。実測(わざと破った実験)。禁止した向きの import は、依存の境界のテストがファイル名と行き先を名指しして止めました。

この依存の境界のテスト(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を要求する例のように、型の持ち主や意味の設計は人がレビューする必要があります。機械が判定できる向きと、仕事に合う契約かという判断を組み合わせると、見張りに任せる範囲を正確に説明できます。

COMMENTS
コメント…

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

PASS IT ON

この気づきを、誰かにも。

この記事を書いた人

Takaomi Murasaki

Promari SNS Shareの開発を通して、Webの仕組みや設計の選び方を紹介しています。動く実装と、その判断に至るまでを記事に残しています。

公開記事 11 件

プロフィールを見る
THANK YOU FOR READING.すべての記事へ ↗

FROM INSIGHT TO IMPACT

「できたらいいな」を、
動く仕組みに。

記事で見つけたヒントを、あなたの事業へ。
新しいサービスも、手間のかかる業務も。
いまの課題から、つくるべきものを一緒に考えます。

開発・AI・研修の実績を見る

まだ、仕様書はいりません。

「何から始める?」から、ご一緒に。

課題がまとまっていなくても大丈夫。テーマを選ぶと相談文をご用意します。
連絡先の必須入力は、お名前とメールアドレスだけ。

まずは課題の整理から相談する

ご相談後の流れ

  1. 01 内容を確認
  2. 02 メールでご連絡
  3. 03 課題・進め方をご相談
送信だけで契約やお申込みが確定することはありません。