動くのに、テストが一行も書けない
平日の十九時すぎ。オフィスはもう人がまばらで、空調の音がやけに大きく聞こえる。わたしのエディタには、さっき作ったばかりの delivery_test.go が開いている。
| |
func TestEstimateDelivery(t *testing.T) { まで打って、その先が一文字も書けていない。カーソルだけが点滅している。
先週、配送料が¥0になるバグを直した。離島宛ての注文で、運送業者が「この宛先は配送できません」と返したとき、コードがその返事を見ずに、料金0円をそのまま通していた。無料配送の注文が、何件か成立してしまっていた。ホットフィックスはもう当ててある。配送できない宛先なら、エラーで止める。それだけのことだ。
だからリードの言葉も、もっともだと思った。「同じ取りこぼしが、他の地域にもあるかもしれない。直したことをテストで証明してから、次のリリースに乗せて」。明日の昼に出すはずの修正が、その一言で止まっている。
テストを書けばいい。書こうとした。なのに、書けない。EstimateDelivery を開くと、理由が分かる。
| |
中で、本物のDBに繋いでいる。本物の運送業者APIを叩いている。このテストを走らせたら、sql.Open で指定した本番データベースと、運送業者の本番APIに、問い合わせが飛ぶ。テスト用の注文をどこかに用意して、運送業者には離島の住所を投げて……考えるほど、手が止まった。動かすだけなら、ローカルでもちゃんと動く。なのに、確かめるための一行が書けない。
社内Wikiの片隅に、「詰まったときに呼ぶといい」とだけ書かれた連絡先のメモがあった。半信半疑で連絡すると、三十分ほどで、物静かな女性が来た。薄いノートPCを一台だけ提げている。「よろしくお願いします」とだけ言って、わたしの隣に座った。
わたしは、開いたままの EstimateDelivery を、少し不安な気持ちで見せた。「配送料の計算に、テストを書きたいんです。でも、この関数、中で本物のDBと運送業者APIを叩いていて……テストを走らせると、本物に繋がっちゃう。わたしの書き方が、下手なんでしょうか」
その人は画面を一秒ほど見て、sql.Open の行を指した。「下手じゃない。ここでDBを、作ってる。だから、外せない」
エラーログでも、テストの書き方でもなく、最初に指したのはその一行だった。迷いのない指先だった。半信半疑のまま、わたしはこの人を親方と呼んだ。
作ってる——? わたしは聞き返した。「sql.Open のことですか。でも、DBに繋ぐには、要りますよね」
テストを書きたいだけなのに、なぜ書けないのか。配送料は、本物のDBと運送APIに繋がないと計算できない。そう思い込んでいた。
とりあえず本物を試験台に載せる
親方は何も言わず、わたしのキーボードを借りて、テストを一本書いた。本物のDBの接続先を環境変数から読み、無ければスキップする。その先で EstimateDelivery をそのまま呼ぶ。
| |
そして走らせた。
| |
「書けた……と思ったら、スキップ?」わたしは画面を覗き込んだ。緑の ok は出ている。でも、SKIP と書いてある。
「いまは本物のDBが無いから、スキップされた」と親方は言った。「緑に見えるが、あなたのロジックは一行も確かめていない。本物を用意すれば、走るには走る。テスト用のDBを立てて、運送業者のサンドボックスのキーを入れれば」
「じゃあ、それをCIにも用意すれば、いいんじゃないですか」わたしは少し前のめりになった。
親方は首を横に振らなかったが、肯定もしなかった。「そのテストが確かめているのは、運送業者のAPIと、DBです。あなたが直した『配送不可エリア』の動きは、運送業者が、そのとき、たまたまどう答えるかに左右される。あなたのロジックを、単体では試していない」
「でも……離島の宛先を、サンドボックスに投げれば、配送不可の返事が来ますよね。それで確かめられる」食い下がってみた。
「運送業者のサンドボックスが、あなたの試したい全部の地域を、いつも同じように返してくれますか」
詰まった。「……それは、分からないです。向こうの都合だから」。海外、重量超過、一時的に配送停止——確かめたい状況を、こちらから運送業者に「こう答えてくれ」とは頼めない。
「それに、本物に繋ぐぶん遅くなる」と親方は続けた。「運送業者が落ちていたら、あなたのコードは何も壊れていないのに、このテストは赤くなる。試験台が、相手の都合で揺れる」
親方は EstimateDelivery を、もう一度、上から下まで指でなぞった。「これは応急です。試験台に、本物のエンジンを丸ごと載せて回しているだけだ。回るには回る。でも、部品ひとつの働きを試したいだけなのに、毎回エンジン全部と、本物の燃料が要る」
本物じゃなくて、こちらが用意した答えを返す「偽物」を渡せたら——。そこまで考えて、でもどうやって、と止まった。EstimateDelivery は、運送業者のクライアントを、自分の中で作ってしまっている。
部品が、関数の中で生まれている
親方は、EstimateDelivery の中の二行を指した。
| |
「原因はここ。部品を、関数の中で作っている。DBも、運送業者のクライアントも、この関数が自分で組み立てている。だから、外から別のものに替えられない」
部品が、関数の中で生まれている。わたしは頭の中で言い換えた。エンジンに部品が、配線で直に繋ぎ込まれている——直結されている。エンジンを開けて、その配線を引き抜かない限り、別の部品には替えられない。
「これ、密結合っていうやつですか」聞いたことのある言葉が、口から出た。
「そうです。密結合——使う側が、依存する部品を自分の内側で直接作って、その部品に縛りつけられている状態だ。EstimateDelivery は、carrier.NewClient という具体的な部品の名前を、自分の中に握っている。握っているから、テストのときだけ別のものに、とはいかない」
わたしは、自分なりの直し方を思いついた。手は動くほうだ。「じゃあ、テストのときだけ偽物に差し替えればいいんですよね。パッケージにグローバル変数を一個置いて——var rateClient = carrier.NewClient(...) みたいに——テストの最初で、それを偽物に上書きすれば」
親方は、別のファイルに、そのグローバル変数版を黙って書いて見せた。それから、こう言った。
「これでも、差し替えはできる。でも、EstimateDelivery の見た目は、何も変わっていない。関数の形を見ても、それが何に依存しているのか、分からないままだ。依存を、グローバルという物置の奥に隠しただけだ。さっきの『関数の中で作る』のと、結局は同じ——握っている場所を、関数の中からパッケージの隅に動かしただけで、外から見えないのは変わらない」
言われて、書きかけのグローバル変数版を見た。確かに、EstimateDelivery(orderID int) という形からは、この関数がDBや運送業者に依存していることは、一文字も読み取れない。差し替えはできても、どこで何に繋がっているのかは、関数を開けて中を追わないと分からない。隠れただけだ。
「おまけに、そのグローバルを複数のテストで奪い合えば、結果も混ざる」と親方は付け加えた。
「差し替えの口を作るのに、グローバルも、テスト用の条件分岐も要らない」。親方はわたしの目を見た。「外から、渡してもらえばいい」
外から渡す——? でも、DBも運送APIも、この関数が使うものだ。使う本人が用意しないで、どうやって渡してもらうんだろう。
中で作るのをやめ、外から受け取る
「順番に組み直します」と親方は言った。
まず、使う側——delivery パッケージの中で、必要な操作だけを宣言した。
| |
「インターフェースを……使う側が書くんですか」わたしは戸惑った。「carrier パッケージの側じゃなくて」
「Goは、使う側が『これだけ繋がれば、中身は問わない』と宣言する。implements とは書かない。Quote というメソッドを、この形で持っている型なら、何でもこの口に挿さる。本物の運送業者クライアントでも、テスト用の偽物でも。メソッドの形が合っていれば、コンパイラが『満たしている』と判断する。形が合わなければ、コンパイルエラーで止まる」
口の規格を、使う側が決める。本物の部品も、テスト用の偽物も、その規格さえ満たせば挿さる——そういうことか。戻り値に fee や serviceable と名前が付いているのは、何が返ってくるかを、口の宣言だけで読めるようにするためだった。引数ではなく、返り値の側だ。
次に、その口を、struct のフィールドとして持たせ、コンストラクタで受け取る形にした。コンストラクタといっても、Goに専用の構文があるわけではない。New〜 という名前で、部品を組み立てて返すだけの、ただの関数だ。
| |
「Estimate は、もうDBも運送業者も、自分で作らない」と親方は言った。「コンストラクタで受け取った口を、使うだけだ」
ここで親方は、delivery パッケージの import 文を指した。さっきまで database/sql と ec/carrier があった場所が、いまは errors の一行だけになっている。
わたしは、その import 文を二度見した。database/sql が、消えている。carrier も、消えている。さっきまで関数の中で握っていた具体的な部品の名前が、このパッケージから無くなっていた。試しに、ここで carrier.NewClient と書こうとしても、もう書けない——書けば、このパッケージに無い import が要る。delivery は、本物の部品の名前すら、知らなくなった。口しか知らない。
「依存の向きが、逆になった」と親方は言った。「前は delivery が carrier を握っていた。いまは carrier のほうが、delivery の決めた口に合わせる側だ」
ロジックは、一行も変えていない。if !serviceable の判定は、先週わたしが当てたホットフィックスのままだ。変わったのは、部品を「どこで作るか」だけだった。
では、本物のDBと運送業者は、どこで作るのか。親方は、入口のファイルを開いた。
| |
「本物のDBと運送業者を作る処理は、無くならないんですね」とわたしは確かめた。「場所が、ここに移っただけ」
「そう。部品を作る場所を、入口の一カ所に集める。これを Composition Root——組み立ての根っこ、と呼ぶ。あちこちで部品を作らない。ここで組んで、あとは渡していくだけだ」
部品を作る場所と、部品を使う場所を、分けた。作るのは入口で一回。使う側は、渡された口を、使うだけ。わたしは自分の言葉に置き換えて、ようやく腑に落ちた。
main が呼んでいる infra.NewSQLOrderRepo の中身も、見せてもらった。さっき EstimateDelivery の中にあった db.QueryRow(...) は、消えたわけではない。OrderRepo を満たす本物の実装として、infra パッケージに移ってきただけだった。
| |
database/sql を import しているのは、この infra と、組み立て役の main だけ。delivery は、もう知らない。DBを叩く処理そのものは消えていない。delivery の外へ、出ていっただけだ。
そして、テスト。親方は、口を満たす偽物を、その場で手書きした。
| |
「これ……carrier を import してない」わたしは気づいた。「偽物なのに、本物のクライアントを、一切知らないんですね」
「それが、疎結合だ」と親方は言った。「偽物は、口さえ満たせばいい。本物の部品を知らなくていい。だから、本物のDBも運送業者も要らずに、あなたのロジックだけを、試験台で回せる」
親方がやったこれには、名前があった。Dependency Injection(依存性注入)——使うものを自分の内側で作らず、外から渡してもらう組み付け方だ。これで、本物と、テスト用の偽物を、同じ口で差し替えられる。
親方は、作業台の壁に貼られた配線図を指差した。本物の重たい部品と、テスト用の透明なアクリル部品が、同じ『ソケット(口)』にカチリと差し替えられる構造が描かれていた。

この構造に切り替えたことで、何が変わって、なぜテストが書けるようになったのか。整理すると、こうだ。依存を作る場所が、関数の内側から main へ移った。delivery パッケージから database/sql と carrier の import が消え、具体的な部品を握らなくなった。依存が NewEstimator の引数に現れて、何に依存しているか、関数の形を見るだけで分かるようになった。そしてテストでは、その口に偽物を挿せる。本物の部品を作る場所と、それを使う場所を切り離したから、使う側は口だけを知っていればよくなった。
試験台で、地域ぜんぶを回す
わたしは、自分の手で、先週のバグの回帰テストを書いた。偽物に「配送不可」を仕込む。serviceable が false のとき、ちゃんとエラーで止まるか。
| |
ここで使った errors.Is は、返ってきたエラーが ErrOutOfArea と同じ目印かを確かめる関数だ。== での比較に近いが、エラーが途中で別のエラーに包まれていても、元を辿って見つけてくれる。ErrOutOfArea のほうは、最初に errors.New でパッケージの変数として一度だけ用意した、配送不可の目印だった。
走らせた。
| |
本物の運送業者は、もう要らなかった。離島でも、海外でも、配送不可でも——偽物に「こう答えろ」と仕込めば、そのとおりに返ってくる。試したい状況を、ぜんぶ自分の手で並べられる。さっきは本物が無いとスキップするしかなかったのに、いまは本物が無いからこそ、速く、毎回同じ結果で回る。
わたしは、止まらなかった。配送不可ならエラー。Find がDBエラーを返したら、それがそのまま伝わるか。重量超過のときは——。さっきまで一文字も書けなかったテストファイルが、ケースで埋まっていく。確かめたかったことが、一つずつ、テストになっていった。
親方は、わたしがテストを書き足していく手元を、黙って少しのあいだ見ていた。「もう本物は要らない。あとは、試したい答えを並べるだけだ」。そう言うと、わたしのキーを打つ音が止まないのを確かめるように一拍おいて、静かに立ち上がった。来たときと同じ、薄いノートPCを小脇に抱えただけの身軽さで。
「ありがとうございました」とわたしは言ったが、画面から目が離せなかった。書きたかったテストが、いま、いくらでも書ける。
整備記録簿
| こんな異音・症状が出たら | 入れるべき整備(Dependency Injection) | まだ様子見でいい |
|---|---|---|
関数やメソッドの中で sql.Open や外部APIクライアントを直接 new していて、テストに本物のDB/外部サービスが要る | ✓ | |
| テストを書くのに、テスト用DBや外部サービスのサンドボックスを立てないと走らない/CIが相手の都合で揺れる | ✓ | |
| 「テスト用フラグ」やグローバル変数で、無理やり差し替えている | ✓ | |
依存が一つで、差し替える予定も、テストで偽物にしたい動機も無い安定した相手(標準ライブラリの encoding/json など) | ✓ |
整備手順
- 関数の中で「作っている」依存(
sql.Open、外部クライアントの生成)を見つける。 - 使う側のパッケージで、必要な操作だけの小さな interface を宣言する(
OrderRepo、RateClient)。 - 依存を
structのフィールドに持たせ、NewXxx(...)コンストラクタで外から受け取る。関数の中の生成を消す。 - 本物の組み立ては
main(Composition Root)に集める。そこで具体的な部品を作って、注入する。 - テストは、口を満たす偽物を手書きして渡す(
database/sqlや外部パッケージを import しない)。go testが、DB・ネットワーク無しで走る。
親方より
使うものを、関数の中で作るな。外から受け取れ。作る場所は、入口の一カ所に集めろ。そうすれば、本物でも偽物でも、同じ口で挿せる。試験台に本物のエンジンを丸ごと載せなくても、部品ひとつの働きを試せる。
ただし、何でも口を付けるな。差し替えたいもの、偽物にしたいものだけだ。動かないものを直すのが整備じゃない。動いているものを、開けて確かめられるようにしておくのが、整備だ。
