LSP――Copyに「開くURL」を要求すると何が壊れるか
作成: / 公開: / 内容更新:
執筆:tamito0201 / 掲載・運営:プロマリ
丸い共有ボタンの裏側を掘る連載の第6回。SOLID の3つ目の原則 LSP を、長方形と正方形の例と、共有ボタンの実物で確かめます。コピーのボタンに「開く URL」を求める書き換えを実際に作ると、型検査は通り、テストが5件落ちました。事前条件・事後条件・不変条件の3つの約束を、図15点で読み解きます。
検証:action を見ずに開くと、何が壊れるか
3ページ目では、このプラグインが action を見て実行方法を選んでいることを確かめました。では、もし action を見ずに、「どの共有先も共有 URL を開けば共有できる」と思い込んだコードを書いたら、何が壊れるのでしょうか。LSPの違反を、実際に作って確かめてみます。
検証は、次の順で進めました。まず、プラグインのリポジトリをgit worktreeで作業用のコピーに写しました。公開しているプラグインには何も入れていません。次に、何も変えていない状態で、型検査と Node のテストを流しました。型検査は通り、テストは63件すべて通りました。そのうえで、クリックを処理する HandleShareClickUseCase の #perform を、action を見ない形に書き換え、もう一度流します。
書き換えた差分は次のとおりです。行頭の – が消した行、+ が足した行です。
@@ -5,3 +5,2 @@
import type { ShareGateways } from '../domain/gateway/ShareGateways.ts';
-import { ShareActionPolicy } from '../domain/service/ShareActionPolicy.ts';
import type { ShareButtonViewModel } from './BuildShareBarUseCase.ts';
@@ -38,30 +37,5 @@ export class HandleShareClickUseCase {
async #perform({ button, preventDefault }: ShareClickCommand): Promise<ShareClickResult> {
- const decision = ShareActionPolicy.decide(button);
- switch (decision) {
(ここに消した25行が続く。copy・native・popup・follow・default の分岐で、3ページ目で読んだ部分なので省略)
- }
+ // 実験:action を見ず、「どの共有先も href を開けば共有できる」と思い込む。
+ if (button.popup && this.#gateways.popup.open(button.href, button.popup)) { preventDefault(); return 'popup'; }
+ return 'follow';
}書き換えたあとの処理は、2行だけです。小窓の設定があれば、共有 URL(button.href)を小窓で開いて ‘popup’ を返します。無ければ ‘follow’ を返し、ブラウザにリンクをたどらせます。X や Facebook のボタンなら、これで今までどおり動きます。差分の行数は、足した行が3、消した行が29でした(git diff –stat の数え方です。省略した行も含みます)。
型検査は通り、テストが5件落ちた
書き換えたあとに、型検査とテストを流した結果が次です。
$ pnpm run typecheck > /dev/null; echo $?
0
$ node --test 'web/test/**/*.test.ts'
✖ copy はクリップボードへ書いて copied を返し、既定動作を止める
'follow' !== 'copied'
✖ 書き込みの完了を待ってから結果を返す(完了前に成功を名乗らない)
TypeError: finish is not a function
✖ clipboard が失敗したら fallback を出し、copied とは返さない
+ 'follow'
- 'copy-fallback'
✖ 端末共有の成功と拒否で、従来どおり操作イベントを一度だけ通知する
'follow' !== 'shared'
✖ クリックの通知は要素ごと・イベント名ごとに分かれる
'follow' !== 'copied'
ℹ tests 63
ℹ pass 58
ℹ fail 5最初の2行が型検査です。終了コードは0で、型検査はまた何も言いませんでした。書き換えた処理も、受け取る値と返す値の型は元と同じだからです。2ページ目の正方形と同じで、形の合い方だけを見る仕組みは、この取り違えを見つけません。
下の部分が Node のテストです。63件のうち58件が通り、5件が落ちました。✖ の行が落ちたテストの名前で、その下が落ちた理由です。’follow’ !== ‘copied’ は「返ってきたのは follow で、期待していた copied と違う」という意味で、+ と – の2行も同じことを表しています。2件目の TypeError は、クリップボードに書く部品が一度も呼ばれなかったので、書き込みの終わりを知らせる関数(finish)が用意されないまま使われた、というエラーです。落ちた5件は、どれもコピーか端末の共有のテストでした。
5件を捕まえたのは、テストに事後条件が書いてあったからです。「コピーを押したら、クリップボードに書いて copied を返す」「書き込みが終わる前に成功を名乗らない」「失敗したら copied とは返さない」。どれも、コピーの処理が守るべき結果の約束です。約束をテストに書いておいたので、約束を破った形が、実行する前に見つかりました。一方で、X や Facebook のテストは落ちませんでした。開く共有先にとっては、書き換えたあとの処理も、約束どおりだったからです。
コピーのボタンが、コピーしなかった
テストが落ちたことは分かりました。では、実際の画面では何が起きるのでしょうか。書き換えた処理と元の処理に、同じボタンを渡して比べる小さなスクリプトを書き、それぞれが何を呼んだかを記録しました。ブラウザの部品の代わりに、呼ばれたことを記録するだけの部品を渡しています(見やすくするため、URL に付く計測用の値は外しました)。
| ボタン | 処理 | 返した結果 | リンクを止めたか | 呼ばれた部品 |
|---|---|---|---|---|
| X | 元の処理 | popup | 止めた | 小窓で投稿画面を開く・計測の知らせ |
| X | 書き換えた処理 | popup | 止めた | 小窓で投稿画面を開く・計測の知らせ |
| コピー | 元の処理 | copied | 止めた | クリップボードへ記事の URL を書く・計測の知らせ |
| コピー | 書き換えた処理 | follow | 止めない | 計測の知らせだけ |
| 端末の共有 | 元の処理 | shared | 止めた | 端末の共有メニューを開く・計測の知らせ |
| 端末の共有 | 書き換えた処理 | follow | 止めない | 計測の知らせだけ |
X のボタンは、元の処理でも書き換えた処理でも、まったく同じ動きでした。違ったのは、コピーと端末の共有です。書き換えた処理では、クリップボードにも端末の共有メニューにも、何も頼んでいません。呼ばれたのは、計測の知らせ(promari-sns-share という名前のイベント)だけでした。
ここから先は、表示の部品のコードから読める動きです(ブラウザで押した様子は、今回は測っていません)。この記事の上にある丸い共有欄では、コピーのボタンはリンクではなく、ただのボタンとして描かれています。書き換えた処理は follow を返してリンクに任せますが、たどるリンクがありません。つまり、押しても何も起きず、「コピーしました」の知らせも出ません。丸くない標準の共有欄では、コピーのボタンは記事の URL へのリンクとして描かれているので、同じ記事をもう一度開き直すことになります。
そして、どちらの場合も、計測の知らせだけは出ています。アクセス解析がこの知らせを数えていれば、「コピーのボタンが押された」と記録されます。利用者の手元には何もコピーされていないのに、です。コピーの約束(事後条件)が弱くなっても、エラーは1つも出ません。静かに壊れるので、テストで約束を書いておかないと、気づくのが遅れます。
考えてみる:X のテストは1件も落ちなかったのに、なぜこの書き換えはだめなの?
書き換えた処理は、開く共有先だけを前提にしています。X や Facebook は開く共有先なので、前提どおりに動きました。けれど、同じ並びにはコピーや端末の共有も入っています。使う側(クリックの処理)が、並びの中の一部の共有先にしか通用しない約束に頼ったので、残りの共有先に取り替えたときに壊れたのです。LSP の違反は、「たいていの場合は動く」という形で現れることが多く、それが見つけにくさの理由です。









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