ISP――コピーの代役に、いいねの保存まで求めない
作成: / 公開: / 内容更新:
執筆:tamito0201 / 掲載・運営:プロマリ
丸い共有ボタンの裏側を掘る連載の第7回。SOLID の4つ目の原則 ISP を、飛べないペンギンの例から、プラグインの5つの小さな口(ポート)とテストの代役まで読み進めます。全部入りのインターフェースにすると、コピーの代役に何が要るのか。作業コピーでの実験と図15点で確かめます。
コピーの代役は、write が1つあれば足りる
前のページで、プラグインの外の能力が5つの小さなポートに分かれ、クリックの処理はそのうち4つを ShareGateways という束で受け取ることを見ました。このページでは、その分け方がテストでどう効いているかを確かめます。
テストでは、本物のブラウザを使わずに、口へ代役を差し込んで動かします。クリックの処理のテスト(web/test/application.test.ts)では、代役を次のように書いています。
const gateways = (overrides: Partial<ShareGateways> = {}) => {
const calls: string[] = [];
const tracked: ShareActivity[] = [];
const base: ShareGateways = {
clipboard: { write: async (t) => { calls.push(`copy:${t}`); }, fallback: (t) => calls.push(`fallback:${t}`) },
nativeShare: { available: true, share: async (d) => { calls.push(`share:${d.url}`); } },
popup: { open: (href) => { calls.push(`popup:${href}`); return true; } },
activity: { publish: (d) => tracked.push(d) },
};
return { calls, tracked, gateways: { ...base, ...overrides } };
};
// …(中略)…
it('clipboard が失敗したら fallback を出し、copied とは返さない', async () => {
const { calls, gateways: g } = gateways({ clipboard: { write: async () => { throw new Error('denied'); }, fallback: (t) => calls.push(`fallback:${t}`) } });最初の gateways は、代役の束を作る関数です。その中の base で、4つの口の代役を1行ずつ書いています。どれもプレーンなオブジェクトで、たとえばコピーの代役は、write が呼ばれたら「copy:(URL)」と記録するだけです。return の行で、引数の overrides に渡したものだけを差し替えます。後半の2行はテストの1つで、「クリップボードへの書き込みが失敗したら fallback を出し、copied(コピーできた)とは返さない」ことを確かめるものです。差し替えているのは clipboard の1項目だけで、write は失敗(例外)を投げ、fallback は呼ばれたことを記録します。
口が小さいので、代役も小さくて済みます。コピーの代役に書くのは write と、必要なら fallback だけです。ほかの口の代役も、どれも1行です。このテストを含む Node のテストを流すと、クリックの処理の5件を含む63件が、すべて通りました(node –test ‘web/test/**/*.test.ts’)。
コピーの代役がwriteだけで足りるというのは、ClipboardGatewayを満たすための話です。クリック処理全体を作るときには、ShareGatewaysの残りの口も必要です。引用したgateways関数は、それらの標準の代役を先に用意し、試したい口だけを差し替えられるようにしています。「テスト全体に一つの関数しか無い」と読むと、束の役割を見失ってしまいます。
失敗のテストで差し替えたのは、clipboardの中身だけです。知らせを記録する仕組みや、小窓を開く代役まで毎回書き直す必要はありません。図では、引き抜く差し込み口が一つに絞られています。この小ささは行数の節約だけでなく、何を変えたためにテストの結果が変わったかを、読む人へ伝える助けにもなります。
もし全部入りの1つだったら――作業コピーで試す
では、口を分けずに、全部入りの1つにしていたらどうなるでしょうか。プラグインの本体には何も入れず、git worktreeで作った作業用のコピーの中で試しました。5つの口の能力をすべて1つのインターフェースに集め、さらに、共有欄にあるいいねボタンの「保存」も同じ場所に入れた、全部入りのインターフェースを作ります。
import type { SharedPage } from '../model/SharedPage.ts';
import type { ShareActivity } from './ShareActivityPublisher.ts';
/** 実験:5つのポートの能力に、いいねの保存を足して1つにまとめた全部入りのインターフェース。 */
export interface ShareEverything {
write(text: string): Promise<void>;
fallback?(text: string): void;
readonly available: boolean;
share(data: { title: string; url: string }): Promise<void>;
open(href: string, size: { width: number; height: number }): boolean;
publish(activity: ShareActivity): void;
read(): SharedPage;
saveLike(liked: boolean): Promise<{ liked: boolean; count: number }>;
}interface ShareEverything の中を上から見ると、write と fallback はコピー、available と share は端末の共有、open は共有の窓、publish は知らせ、read はページの読み取りの能力です。最後の saveLike が、実験のために足した「いいねの保存」です。このプラグインに、そういう口はありません。全部入りの形を作るための仮のものです。
この全部入りのインターフェースで、コピーの代役を、分けた形と同じ1行で書いてみます。比べるために、分けた形の代役も同じファイルに並べました。
import type { ClipboardGateway } from '../src/domain/gateway/ClipboardGateway.ts';
import type { ShareEverything } from '../src/domain/gateway/ShareEverything.ts';
// 分けた形:コピーの代役は write だけで足りる。
export const copyDouble: ClipboardGateway = { write: async () => {} };
// 全部入りの形:同じ代役を、全部入りのインターフェースとして書く。
export const copyDoubleFat: ShareEverything = { write: async () => {} };$ npx tsc -p tsconfig.json
web/test/isp-experiment.ts(8,14): error TS2740: Type '{ write: () => Promise<void>; }' is missing the following properties from type 'ShareEverything': available, share, open, publish, and 2 more.分けた形(5行目)は何も言われずに通り、全部入りの形(8行目)だけが型チェックで止まりました。エラーは「available、share、open、publish、ほか2つが足りない」という意味です。ほかの2つは read と saveLike で、どれもコピーとは関係のないものばかりです。
型チェックを通すには、使わない6つの項目にも中身を書くことになります。実際に、available には false を、publish には何もしない関数を、share・open・read・saveLike には throw new Error('この試験では使わない') を書いて7つの項目をそろえると、型チェックは通りました。上の図の名札が、その代役の形です。けれど、代役の7つの項目のうち、コピーのテストで意味があるのは write だけです。残りの6つは、「この試験では使わない」と例外を投げるか、何もしないかです。1ページ目のペンギンの fly と同じ形が、今度はテストの代役に現れました。
代役の荷物を、必要な道具と余分な道具に分けて見る
全部入りの代役を、次のかばんで見てみます。大きなかばんにたくさんの道具が入っていること自体が悪いのではありません。ここで頼まれた仕事はコピーだけなのに、型を満たすため、別の仕事の道具まで持参しているのが問題です。
小さいかばんにはwriteが一つあります。大きいかばんには、それに加えて六つの項目が入っています。使わない道具には、呼ばれたら例外にする札や、何もしない札を付けていました。かばんの重さは性能の測定値ではありません。代役を作る人と読む人が、余計に説明しなければならない項目を表しています。
「共通のヘルパーで全部埋めれば、毎回書かずに済む」と考えることもできます。確かに、同じ記述を繰り返す手間は減ります。けれど、全部入りの契約が変われば、そのヘルパーは直す必要があります。記述を一か所へ集める工夫と、必要のない契約へ依存しない工夫は、別々に効きます。前者だけで、後者の問題が消えるわけではありません。
ここに載せたwriteが空の一行の例は、型を満たすために何が必要かを比べる実験です。コピーの動作が正しいことを、その空の実装で確かめたわけではありません。LSPの回で見たとおり、書き込んだ結果を調べるなら、内容を記録する代役が要ります。ISPの比較で形を省略したコードを、そのまま動作検証の正解にしないようにします。
かばんの図を使った練習では、まずコピーのテストの目的を一文にします。「URLがwriteへ渡ることを確認する」なら、保存先の件数を返すsaveLikeは登場しません。目的の文に現れない項目が多いほど、テストの準備が別の仕事に引きずられていないかを調べる余地があります。
次に、余分な項目の中身を一つ変えたら、このテストの期待は変わるかを考えます。いいねの保存結果を変えても、コピーの期待するURLは変わらないはずです。それでも準備の型が合わなくなるなら、仕事の関係ではなく、広い口を共有したためにつながっていると分かります。
最後に、必要な項目まで削っていないかを確認します。コピーの後に知らせを出すことを含めて試すなら、activityの観測は残します。「コピー」という短い名前だけを理由に、実際の利用側が行う仕事まで切り捨ててはいけません。どこまでを一つの利用側として試すかが、必要な荷物の範囲を決めます。
実際のレビューなら、余分な六項目を数えたあとに、それぞれがどの利用側のためのものかを書いてみます。どれにも持ち主がいるなら、それらの仕事が悪いのではありません。コピーのテストへ一緒に運ぶ必要があるか、という置き場所の問題です。道具を捨てるのではなく、必要な人が必要な荷物を受け取れる形へ戻して考えます。
関係のない変更で、コピーの代役が止まる
全部入りの形には、もう1つ困ることがあります。いいねの機能を育てて、「保存した件数を読み込む」loadLikes を、全部入りのインターフェースに1行足してみました。すると型チェックは、コピーの代役の行で error TS2741: Property 'loadLikes' is missing in type … but required in type 'ShareEverything'(loadLikes が無い)と言って止まりました。
コピーの代役は1文字も変えていないのに、いいねの都合で入った変更に、コピーのテストが巻き込まれたのです。Martin の言う「ほかの利用側の都合で変わった部分に、自分まで巻き込まれる」が、そのまま起きています。分けた形なら、いいねの能力は別の口に置くので、そちらにメソッドが増えても、ClipboardGateway の代役には何も起きません。なお、図の「いいね用の口」は、分けた形の側を説明するための設計例で、今のプラグインにはありません。
変更が届く図では、loadLikesを追加した場所から、赤い線を逆向きにたどってみてください。コピーの入力も出力も変わっていません。それでも同じ全部入りの型を名乗るため、コピーの代役へ新しい項目が必要になりました。ここで数えているのはコピー機能の実行時の故障ではなく、無関係な契約変更による型の検査の失敗です。
小さな口に分けた後も、共有のテスト設定や共通のヘルパーを不用意に結びつければ、別の経路で影響が届く可能性はあります。図の線を切ったから、リポジトリ全体の影響が永久にゼロになるわけではありません。少なくとも、いいね用の口へ項目を足すだけではClipboardGatewayの実装義務が増えない、という範囲で効果を読みます。











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