LSP――Copyに「開くURL」を要求すると何が壊れるか
作成: / 公開: / 内容更新:
執筆:tamito0201 / 掲載・運営:プロマリ
丸い共有ボタンの裏側を掘る連載の第6回。SOLID の3つ目の原則 LSP を、長方形と正方形の例と、共有ボタンの実物で確かめます。コピーのボタンに「開く URL」を求める書き換えを実際に作ると、型検査は通り、テストが5件落ちました。事前条件・事後条件・不変条件の3つの約束を、図15点で読み解きます。
代役を作るときの約束:事前条件を強めない・事後条件を弱めない
ここまでは、クリックの処理が「共有先」をどう扱うかを見てきました。このページでは、もう1つの取り替えを見ます。クリップボードや端末の共有のような、ブラウザの機能を使う部品の取り替えです。前のページの実験は、LSPが崩れると、エラーを出さずに静かに壊れることを示しました。同じことは、部品を取り替えるたびに起こりえます。
このプラグインでは、ブラウザの機能を、直接ではなくゲートウェイという約束を通して使っています。クリップボードの約束は ClipboardGateway です。
/** The ability to put text on the clipboard. Infrastructure implements it. */
export interface ClipboardGateway {
write(text: string): Promise<void>;
/** Optional clipboard fallback, such as a URL prompt. */
fallback?(text: string): void;
}1行目のコメントは「文字をクリップボードに置く能力」という意味です。約束は2つで、write は文字を書き込み、終わったら知らせる(Promise<void> を返す)メソッド、fallback は書き込めなかったときの代わりの方法を出す、あってもなくてもよいメソッドです。ブラウザでは BrowserClipboard という部品が、この約束をブラウザの Clipboard API で実現しています。テストでは、ブラウザが無くても動くように、同じ約束の形をした小さなオブジェクトを渡します。これを、この記事では代役と呼びます。
代役は、本物と同じ約束を守らなければ、テストの意味がなくなります。そこで、あえて約束を破る代役を2つ作り、本物に近い代役と並べて、クリックの処理(HandleShareClickUseCase)に渡してみました。
let board = ''; // クリップボードの中身の代わり
class RealLike implements ClipboardGateway { async write(text: string) { board = text; } }
// 事後条件を弱める代役:書いたふりをして、何も書かない
class Silent implements ClipboardGateway { async write(_text: string) {} }
// 事前条件を強める代役:https で始まる文字しか受け付けない
class HttpsOnly implements ClipboardGateway {
async write(text: string) { if (!text.startsWith('https://')) throw new Error('https only'); board = text; }
}1行目の board は、クリップボードの中身の代わりです。RealLike は、渡された文字を board に書く、本物に近い代役です。Silent は、書き込みが終わったと知らせるのに、何も書きません。「書き込んだら、その文字がクリップボードに残る」という事後条件を弱めています。HttpsOnly は、https:// で始まる文字しか受け付けません。本物の約束は「文字なら何でも受け付ける」なので、受け付ける範囲を狭めています。これは事前条件を強めた形です。
3つとも implements ClipboardGateway と宣言していて、型検査は通りました(終了コード0)。この3つを、https の記事と、手元で開発するときの http://localhost の記事の2つで試した結果が次です。
| 代役 | 記事の URL | 処理が返した結果 | クリップボードの中身 |
|---|---|---|---|
| RealLike | https://a.jp/post/ | copied | https://a.jp/post/ |
| RealLike | http://localhost:8080/post/ | copied | http://localhost:8080/post/ |
| Silent | https://a.jp/post/ | copied | (空) |
| Silent | http://localhost:8080/post/ | copied | (空) |
| HttpsOnly | https://a.jp/post/ | copied | https://a.jp/post/ |
| HttpsOnly | http://localhost:8080/post/ | copy-fallback | (空) |
Silent を渡すと、クリックの処理は copied(コピーできた)を返しました。けれど、クリップボードは空のままです。クリックの処理は、write が終わったと知らされたので、約束どおり「コピーできた」と判断しました。約束を破ったのは代役の側で、使う側に落ち度はありません。HttpsOnly は、https の記事ではうまく動きましたが、http://localhost の記事では書き込みを断り、クリックの処理は代わりの方法(copy-fallback)に切り替えました。開発中の手元の環境でだけ、コピーが動かないように見えるわけです。
どちらの代役も、使う側から見ると「同じ ClipboardGateway」です。名前も型も同じなので、型検査も、処理を組み立てる仕組みも、違いに気づきません。代役を作るときは、事前条件を強めず、事後条件を弱めない。本物より多くを受け付けるのはかまいませんし、本物より多くを約束するのもかまいません。けれど、本物より少なく受け付けたり、本物より少なく約束したりすると、代役を使ったテストが、本物では起きない結果を「正しい」と言い出します。
本物にも代役にも、同じ約束のテストを流す
約束を守っているかは、どう確かめればよいのでしょうか。1つの方法は、約束を1つのテストにまとめ、本物にも代役にも同じテストを流すことです。この形を契約テストと呼びます。クリップボードの約束を、次のようにテストにしました。
function clipboardContract(name: string, make: () => ClipboardGateway) {
describe(name, () => {
for (const url of ['https://a.jp/post/', 'http://localhost:8080/post/']) {
it(`${url} を書いたら、同じ文字が残る`, async () => {
board = '';
await make().write(url);
assert.equal(board, url);
});
}
});
}
clipboardContract('RealLike', () => new RealLike());
clipboardContract('Silent', () => new Silent());
clipboardContract('HttpsOnly', () => new HttpsOnly());clipboardContract は、代役の名前と、代役を作る関数を受け取り、同じ中身のテストを作ります。中身は「https と http の2つの URL について、書いたら同じ文字が残る」ことです。前者は事前条件(どちらの URL も受け付ける)を、後者は事後条件(書いた文字が残る)を確かめています。最後の3行で、3つの代役に同じテストを流しました。結果は、6件のうち3件が通り、3件が落ちました。RealLike は2件とも通り、Silent は2件とも落ち、HttpsOnly は http の1件だけが落ちました。約束を破った代役だけが、ちょうど破った分だけ落ちたのです。
このプラグインの今のテストは、代役を1つずつその場で作り、処理の結果(copied を返すか、書き込みを待つか、失敗したら fallback を出すか)を確かめています。4ページ目で5件のテストが違反を捕まえたのは、そのためです。一方で、上のような「本物と代役に同じテストを流す」形は、今のプラグインにはありません。本物の BrowserClipboard はブラウザの中でしか動かないので、Node のテストにそのまま流せないからです。この記事の契約テストは、検証のために一時作業コピーで書いたもので、プラグインの本体には入れていません。
いつも成り立つこと:名札カードの組み合わせ
最後に、3つ目の約束、不変条件を見ます。名札カードには、項目どうしの組み合わせについての決まりがあります。「endpoint があるなら、params に送る項目を書く」「endpoint が空なら、params も空にする」の2つです。この決まりは、名札カードを読む生成器(tools/config.py)が確かめていて、破った名札カードは、作り直しの時点で止まります。
では、action と endpoint の組み合わせはどうでしょうか。3ページ目で見たとおり、クリックの処理は「open なら endpoint がある」「copy と native なら endpoint は空」という組み合わせを前提にしています。組み合わせが崩れた名札カードを、一時作業コピーの生成器に読ませてみました。
# 開く動きなのに、開く先が無い(action = 'open'・endpoint = '')
$ python3 tools/config.py --config config/share_config.example.toml --generate
✅ 型スタブを生成しました(web/src/generated): config/share_config.example.toml
# コピーの動きなのに、開く先がある(action = 'copy'・endpoint あり・params あり)
$ python3 tools/config.py --config config/share_config.example.toml --generate
✅ 型スタブを生成しました(web/src/generated): config/share_config.example.toml
# 対照:開く先があるのに params が無い(action = 'open'・params なし)
$ python3 tools/config.py --config config/share_config.example.toml --generate
❌ シェア設定が不正です: noparams.toml: endpoint があるなら params に送る項目を書いてください# で始まる行は、どんな名札カードを読ませたかの説明です。3つ目の、endpoint と params の組み合わせを崩した名札カードは、生成器が止めました。けれど、1つ目と2つ目の、action と endpoint の組み合わせを崩した名札カードは、どちらも通りました。1つ目の名札カードで作られた共有先は、shareUrl が記事の URL を返し、decide は ‘popup’ を選びます。押すと、共有画面ではなく、読んでいる記事そのものを小窓で開くことになります。
今のプラグインでは、同梱の名札カード(copy.toml と native.toml は endpoint が空、ほかは endpoint あり)が、この組み合わせを守っています。ただ、それを確かめる検査はありません。もし「open なら endpoint を空にしない」「copy と native なら endpoint を空にする」という検査を生成器に足せば、崩れた名札カードも作り直しの時点で止まるはずです。この検査は、この記事のための提案で、プラグインには入れておらず、動かしてもいません。







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