Featured image of post コードメカニック【Dependency Injection】ローカルでは動くのにテストが一行も書けない〜内部で組み込んだ部品を外から受け取る形へ〜

コードメカニック【Dependency Injection】ローカルでは動くのにテストが一行も書けない〜内部で組み込んだ部品を外から受け取る形へ〜

配送料の見積りにテストを書こうにも、関数の奥で本物のDBと運送業者APIを直接呼んでいて一行も書けない。依存を内部で作るのをやめ外から受け取り、mainで組み立てて、本物に繋がず試験台で回せるようにする整備記録。

動くのに、テストが一行も書けない

平日の十九時すぎ。オフィスはもう人がまばらで、空調の音がやけに大きく聞こえる。わたしのエディタには、さっき作ったばかりの delivery_test.go が開いている。

1
2
3
func TestEstimateDelivery(t *testing.T) {

}

func TestEstimateDelivery(t *testing.T) { まで打って、その先が一文字も書けていない。カーソルだけが点滅している。

先週、配送料が¥0になるバグを直した。離島宛ての注文で、運送業者が「この宛先は配送できません」と返したとき、コードがその返事を見ずに、料金0円をそのまま通していた。無料配送の注文が、何件か成立してしまっていた。ホットフィックスはもう当ててある。配送できない宛先なら、エラーで止める。それだけのことだ。

だからリードの言葉も、もっともだと思った。「同じ取りこぼしが、他の地域にもあるかもしれない。直したことをテストで証明してから、次のリリースに乗せて」。明日の昼に出すはずの修正が、その一言で止まっている。

テストを書けばいい。書こうとした。なのに、書けない。EstimateDelivery を開くと、理由が分かる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package delivery

import (
	"database/sql"
	"errors"

	"ec/carrier" // 運送業者のクライアント(外部サービス)
)

var ErrOutOfArea = errors.New("delivery: out of service area")

// 注文IDから配送料を見積もる
func EstimateDelivery(orderID int) (int, error) {
	// ← DB接続を、この関数の中で作っている
	db, err := sql.Open("postgres", "host=prod-db user=app dbname=ec sslmode=require")
	if err != nil {
		return 0, err
	}
	defer db.Close()

	var postal string
	var weight int
	err = db.QueryRow("SELECT postal_code, weight FROM orders WHERE id=$1", orderID).
		Scan(&postal, &weight)
	if err != nil {
		return 0, err
	}

	// ← 運送業者のクライアントも、この関数の中で作っている
	client := carrier.NewClient("CARRIER_API_KEY")
	fee, serviceable, err := client.Quote(postal, weight)
	if err != nil {
		return 0, err
	}

	// 先週のホットフィックス: 配送不可エリアはエラーにする
	if !serviceable {
		return 0, ErrOutOfArea
	}
	return fee, nil
}

中で、本物のDBに繋いでいる。本物の運送業者APIを叩いている。このテストを走らせたら、sql.Open で指定した本番データベースと、運送業者の本番APIに、問い合わせが飛ぶ。テスト用の注文をどこかに用意して、運送業者には離島の住所を投げて……考えるほど、手が止まった。動かすだけなら、ローカルでもちゃんと動く。なのに、確かめるための一行が書けない。

社内Wikiの片隅に、「詰まったときに呼ぶといい」とだけ書かれた連絡先のメモがあった。半信半疑で連絡すると、三十分ほどで、物静かな女性が来た。薄いノートPCを一台だけ提げている。「よろしくお願いします」とだけ言って、わたしの隣に座った。

わたしは、開いたままの EstimateDelivery を、少し不安な気持ちで見せた。「配送料の計算に、テストを書きたいんです。でも、この関数、中で本物のDBと運送業者APIを叩いていて……テストを走らせると、本物に繋がっちゃう。わたしの書き方が、下手なんでしょうか」

その人は画面を一秒ほど見て、sql.Open の行を指した。「下手じゃない。ここでDBを、作ってる。だから、外せない」

エラーログでも、テストの書き方でもなく、最初に指したのはその一行だった。迷いのない指先だった。半信半疑のまま、わたしはこの人を親方と呼んだ。

作ってる——? わたしは聞き返した。「sql.Open のことですか。でも、DBに繋ぐには、要りますよね」

テストを書きたいだけなのに、なぜ書けないのか。配送料は、本物のDBと運送APIに繋がないと計算できない。そう思い込んでいた。


とりあえず本物を試験台に載せる

親方は何も言わず、わたしのキーボードを借りて、テストを一本書いた。本物のDBの接続先を環境変数から読み、無ければスキップする。その先で EstimateDelivery をそのまま呼ぶ。

1
2
3
4
5
6
7
8
func TestEstimateDelivery_Integration(t *testing.T) {
	if os.Getenv("DATABASE_URL") == "" {
		t.Skip("本物のDBと運送業者サンドボックスが要る——配送不可ロジックを単体で検証できない")
	}
	if _, err := EstimateDelivery(1); err != nil {
		t.Fatalf("EstimateDelivery: %v", err)
	}
}

そして走らせた。

1
2
3
4
5
$ go test ./before/
=== RUN   TestEstimateDelivery_Integration
    estimate_test.go:13: 本物のDBと運送業者サンドボックスが要る——配送不可ロジックを単体で検証できない
--- SKIP: TestEstimateDelivery_Integration (0.00s)
ok  	code-mechanic/tight-coupling-di/before  0.31s

「書けた……と思ったら、スキップ?」わたしは画面を覗き込んだ。緑の ok は出ている。でも、SKIP と書いてある。

「いまは本物のDBが無いから、スキップされた」と親方は言った。「緑に見えるが、あなたのロジックは一行も確かめていない。本物を用意すれば、走るには走る。テスト用のDBを立てて、運送業者のサンドボックスのキーを入れれば」

「じゃあ、それをCIにも用意すれば、いいんじゃないですか」わたしは少し前のめりになった。

親方は首を横に振らなかったが、肯定もしなかった。「そのテストが確かめているのは、運送業者のAPIと、DBです。あなたが直した『配送不可エリア』の動きは、運送業者が、そのとき、たまたまどう答えるかに左右される。あなたのロジックを、単体では試していない」

「でも……離島の宛先を、サンドボックスに投げれば、配送不可の返事が来ますよね。それで確かめられる」食い下がってみた。

「運送業者のサンドボックスが、あなたの試したい全部の地域を、いつも同じように返してくれますか」

詰まった。「……それは、分からないです。向こうの都合だから」。海外、重量超過、一時的に配送停止——確かめたい状況を、こちらから運送業者に「こう答えてくれ」とは頼めない。

「それに、本物に繋ぐぶん遅くなる」と親方は続けた。「運送業者が落ちていたら、あなたのコードは何も壊れていないのに、このテストは赤くなる。試験台が、相手の都合で揺れる」

親方は EstimateDelivery を、もう一度、上から下まで指でなぞった。「これは応急です。試験台に、本物のエンジンを丸ごと載せて回しているだけだ。回るには回る。でも、部品ひとつの働きを試したいだけなのに、毎回エンジン全部と、本物の燃料が要る」

本物じゃなくて、こちらが用意した答えを返す「偽物」を渡せたら——。そこまで考えて、でもどうやって、と止まった。EstimateDelivery は、運送業者のクライアントを、自分の中で作ってしまっている。


部品が、関数の中で生まれている

親方は、EstimateDelivery の中の二行を指した。

1
2
3
	db, err := sql.Open("postgres", "host=prod-db ...")
	...
	client := carrier.NewClient("CARRIER_API_KEY")

「原因はここ。部品を、関数の中で作っている。DBも、運送業者のクライアントも、この関数が自分で組み立てている。だから、外から別のものに替えられない」

部品が、関数の中で生まれている。わたしは頭の中で言い換えた。エンジンに部品が、配線で直に繋ぎ込まれている——直結されている。エンジンを開けて、その配線を引き抜かない限り、別の部品には替えられない。

「これ、密結合っていうやつですか」聞いたことのある言葉が、口から出た。

「そうです。密結合——使う側が、依存する部品を自分の内側で直接作って、その部品に縛りつけられている状態だ。EstimateDelivery は、carrier.NewClient という具体的な部品の名前を、自分の中に握っている。握っているから、テストのときだけ別のものに、とはいかない」

わたしは、自分なりの直し方を思いついた。手は動くほうだ。「じゃあ、テストのときだけ偽物に差し替えればいいんですよね。パッケージにグローバル変数を一個置いて——var rateClient = carrier.NewClient(...) みたいに——テストの最初で、それを偽物に上書きすれば」

親方は、別のファイルに、そのグローバル変数版を黙って書いて見せた。それから、こう言った。

「これでも、差し替えはできる。でも、EstimateDelivery の見た目は、何も変わっていない。関数の形を見ても、それが何に依存しているのか、分からないままだ。依存を、グローバルという物置の奥に隠しただけだ。さっきの『関数の中で作る』のと、結局は同じ——握っている場所を、関数の中からパッケージの隅に動かしただけで、外から見えないのは変わらない」

言われて、書きかけのグローバル変数版を見た。確かに、EstimateDelivery(orderID int) という形からは、この関数がDBや運送業者に依存していることは、一文字も読み取れない。差し替えはできても、どこで何に繋がっているのかは、関数を開けて中を追わないと分からない。隠れただけだ。

「おまけに、そのグローバルを複数のテストで奪い合えば、結果も混ざる」と親方は付け加えた。

「差し替えの口を作るのに、グローバルも、テスト用の条件分岐も要らない」。親方はわたしの目を見た。「外から、渡してもらえばいい」

外から渡す——? でも、DBも運送APIも、この関数が使うものだ。使う本人が用意しないで、どうやって渡してもらうんだろう。


中で作るのをやめ、外から受け取る

「順番に組み直します」と親方は言った。

まず、使う側——delivery パッケージの中で、必要な操作だけを宣言した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package delivery

// delivery(使う側)が、必要な操作だけを宣言する。
// 実装が誰なのかは、ここでは知らない。
type OrderRepo interface {
	Find(orderID int) (postalCode string, weight int, err error)
}

type RateClient interface {
	Quote(postalCode string, weight int) (fee int, serviceable bool, err error)
}

「インターフェースを……使う側が書くんですか」わたしは戸惑った。「carrier パッケージの側じゃなくて」

「Goは、使う側が『これだけ繋がれば、中身は問わない』と宣言する。implements とは書かない。Quote というメソッドを、この形で持っている型なら、何でもこの口に挿さる。本物の運送業者クライアントでも、テスト用の偽物でも。メソッドの形が合っていれば、コンパイラが『満たしている』と判断する。形が合わなければ、コンパイルエラーで止まる」

口の規格を、使う側が決める。本物の部品も、テスト用の偽物も、その規格さえ満たせば挿さる——そういうことか。戻り値に feeserviceable と名前が付いているのは、何が返ってくるかを、口の宣言だけで読めるようにするためだった。引数ではなく、返り値の側だ。

次に、その口を、struct のフィールドとして持たせ、コンストラクタで受け取る形にした。コンストラクタといっても、Goに専用の構文があるわけではない。New〜 という名前で、部品を組み立てて返すだけの、ただの関数だ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package delivery

import "errors" // ← database/sql も carrier も、もう import しない

var ErrOutOfArea = errors.New("delivery: out of service area")

type Estimator struct {
	orders OrderRepo // 具体型ではなく、口(interface)を持つ
	rates  RateClient
}

// 依存を引数で受け取る。中で作らない。
func NewEstimator(orders OrderRepo, rates RateClient) *Estimator {
	return &Estimator{orders: orders, rates: rates}
}

func (e *Estimator) Estimate(orderID int) (int, error) {
	postal, weight, err := e.orders.Find(orderID)
	if err != nil {
		return 0, err
	}
	fee, serviceable, err := e.rates.Quote(postal, weight)
	if err != nil {
		return 0, err
	}
	// 先週のホットフィックスと同じ判定。今度は、これを単体で試せる
	if !serviceable {
		return 0, ErrOutOfArea
	}
	return fee, nil
}

Estimate は、もうDBも運送業者も、自分で作らない」と親方は言った。「コンストラクタで受け取った口を、使うだけだ」

ここで親方は、delivery パッケージの import 文を指した。さっきまで database/sqlec/carrier があった場所が、いまは errors の一行だけになっている。

わたしは、その import 文を二度見した。database/sql が、消えている。carrier も、消えている。さっきまで関数の中で握っていた具体的な部品の名前が、このパッケージから無くなっていた。試しに、ここで carrier.NewClient と書こうとしても、もう書けない——書けば、このパッケージに無い import が要る。delivery は、本物の部品の名前すら、知らなくなった。口しか知らない。

「依存の向きが、逆になった」と親方は言った。「前は deliverycarrier を握っていた。いまは carrier のほうが、delivery の決めた口に合わせる側だ」

ロジックは、一行も変えていない。if !serviceable の判定は、先週わたしが当てたホットフィックスのままだ。変わったのは、部品を「どこで作るか」だけだった。

では、本物のDBと運送業者は、どこで作るのか。親方は、入口のファイルを開いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Composition Root: 部品を組み立てる場所は、ここ一カ所だけ
package main

import (
	"database/sql"
	"log"
	"os"

	"ec/carrier" // 具体的な部品の名前を知るのは、ここ(と infra)だけ
	"ec/delivery"
	"ec/infra"
)

func main() {
	db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	repo := infra.NewSQLOrderRepo(db)                        // 本物のDB実装
	rates := carrier.NewClient(os.Getenv("CARRIER_API_KEY")) // 本物の運送業者
	est := delivery.NewEstimator(repo, rates)                // ← ここで本物を挿し込む

	// あとは est を、HTTPハンドラなどに渡して使う
	_ = est
}

「本物のDBと運送業者を作る処理は、無くならないんですね」とわたしは確かめた。「場所が、ここに移っただけ」

「そう。部品を作る場所を、入口の一カ所に集める。これを Composition Root——組み立ての根っこ、と呼ぶ。あちこちで部品を作らない。ここで組んで、あとは渡していくだけだ」

部品を作る場所と、部品を使う場所を、分けた。作るのは入口で一回。使う側は、渡された口を、使うだけ。わたしは自分の言葉に置き換えて、ようやく腑に落ちた。

main が呼んでいる infra.NewSQLOrderRepo の中身も、見せてもらった。さっき EstimateDelivery の中にあった db.QueryRow(...) は、消えたわけではない。OrderRepo を満たす本物の実装として、infra パッケージに移ってきただけだった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package infra

// delivery.OrderRepo を満たす、本物の実装。元の QueryRow はここが受け持つ
type SQLOrderRepo struct {
	db *sql.DB
}

func NewSQLOrderRepo(db *sql.DB) *SQLOrderRepo {
	return &SQLOrderRepo{db: db}
}

func (r *SQLOrderRepo) Find(orderID int) (postalCode string, weight int, err error) {
	err = r.db.QueryRow("SELECT postal_code, weight FROM orders WHERE id=$1", orderID).
		Scan(&postalCode, &weight)
	return postalCode, weight, err
}

database/sql を import しているのは、この infra と、組み立て役の main だけ。delivery は、もう知らない。DBを叩く処理そのものは消えていない。delivery の外へ、出ていっただけだ。

そして、テスト。親方は、口を満たす偽物を、その場で手書きした。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package delivery

// RateClient を満たす手書きの偽物。carrier も database/sql も import しない
type fakeRateClient struct {
	fee         int
	serviceable bool
	err         error
}

func (f *fakeRateClient) Quote(postalCode string, weight int) (int, bool, error) {
	return f.fee, f.serviceable, f.err
}

// fakeOrderRepo も同じ。Find を一本実装するだけ(postal/weight/err を返す)

「これ……carrier を import してない」わたしは気づいた。「偽物なのに、本物のクライアントを、一切知らないんですね」

「それが、疎結合だ」と親方は言った。「偽物は、口さえ満たせばいい。本物の部品を知らなくていい。だから、本物のDBも運送業者も要らずに、あなたのロジックだけを、試験台で回せる」

親方がやったこれには、名前があった。Dependency Injection(依存性注入)——使うものを自分の内側で作らず、外から渡してもらう組み付け方だ。これで、本物と、テスト用の偽物を、同じ口で差し替えられる。

親方は、作業台の壁に貼られた配線図を指差した。本物の重たい部品と、テスト用の透明なアクリル部品が、同じ『ソケット(口)』にカチリと差し替えられる構造が描かれていた。

Dependency Injection (DI) pattern structure: An Estimator controller connects to interchangeable sub-modules (SQLOrderRepo database and CarrierClient/fakeRateClient rates client) via standardized interface ports OrderRepo and RateClient

この構造に切り替えたことで、何が変わって、なぜテストが書けるようになったのか。整理すると、こうだ。依存を作る場所が、関数の内側から main へ移った。delivery パッケージから database/sqlcarrier の import が消え、具体的な部品を握らなくなった。依存が NewEstimator の引数に現れて、何に依存しているか、関数の形を見るだけで分かるようになった。そしてテストでは、その口に偽物を挿せる。本物の部品を作る場所と、それを使う場所を切り離したから、使う側は口だけを知っていればよくなった。


試験台で、地域ぜんぶを回す

わたしは、自分の手で、先週のバグの回帰テストを書いた。偽物に「配送不可」を仕込む。serviceablefalse のとき、ちゃんとエラーで止まるか。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 配送不可エリアはエラー(ホットフィックス前は ¥0 を返していた挙動を、ここで固定する)
func TestEstimate_OutOfArea_ReturnsError(t *testing.T) {
	est := NewEstimator(
		&fakeOrderRepo{postal: "907-0000", weight: 800},
		&fakeRateClient{fee: 0, serviceable: false}, // 配送不可を仕込む
	)
	if _, err := est.Estimate(2); !errors.Is(err, ErrOutOfArea) {
		t.Fatalf("配送不可エリアは ErrOutOfArea になるはず: got %v", err)
	}
}

ここで使った errors.Is は、返ってきたエラーが ErrOutOfArea と同じ目印かを確かめる関数だ。== での比較に近いが、エラーが途中で別のエラーに包まれていても、元を辿って見つけてくれる。ErrOutOfArea のほうは、最初に errors.New でパッケージの変数として一度だけ用意した、配送不可の目印だった。

走らせた。

1
2
3
4
5
6
7
8
$ go test ./after/delivery/
=== RUN   TestEstimate_Serviceable_ReturnsFee
--- PASS: TestEstimate_Serviceable_ReturnsFee (0.00s)
=== RUN   TestEstimate_OutOfArea_ReturnsError
--- PASS: TestEstimate_OutOfArea_ReturnsError (0.00s)
=== RUN   TestEstimate_RepoError_Propagates
--- PASS: TestEstimate_RepoError_Propagates (0.00s)
ok  	code-mechanic/tight-coupling-di/after/delivery  0.25s

本物の運送業者は、もう要らなかった。離島でも、海外でも、配送不可でも——偽物に「こう答えろ」と仕込めば、そのとおりに返ってくる。試したい状況を、ぜんぶ自分の手で並べられる。さっきは本物が無いとスキップするしかなかったのに、いまは本物が無いからこそ、速く、毎回同じ結果で回る。

わたしは、止まらなかった。配送不可ならエラー。Find がDBエラーを返したら、それがそのまま伝わるか。重量超過のときは——。さっきまで一文字も書けなかったテストファイルが、ケースで埋まっていく。確かめたかったことが、一つずつ、テストになっていった。

親方は、わたしがテストを書き足していく手元を、黙って少しのあいだ見ていた。「もう本物は要らない。あとは、試したい答えを並べるだけだ」。そう言うと、わたしのキーを打つ音が止まないのを確かめるように一拍おいて、静かに立ち上がった。来たときと同じ、薄いノートPCを小脇に抱えただけの身軽さで。

「ありがとうございました」とわたしは言ったが、画面から目が離せなかった。書きたかったテストが、いま、いくらでも書ける。


整備記録簿

こんな異音・症状が出たら入れるべき整備(Dependency Injection)まだ様子見でいい
関数やメソッドの中で sql.Open や外部APIクライアントを直接 new していて、テストに本物のDB/外部サービスが要る
テストを書くのに、テスト用DBや外部サービスのサンドボックスを立てないと走らない/CIが相手の都合で揺れる
「テスト用フラグ」やグローバル変数で、無理やり差し替えている
依存が一つで、差し替える予定も、テストで偽物にしたい動機も無い安定した相手(標準ライブラリの encoding/json など)

整備手順

  1. 関数の中で「作っている」依存(sql.Open、外部クライアントの生成)を見つける。
  2. 使う側のパッケージで、必要な操作だけの小さな interface を宣言する(OrderRepoRateClient)。
  3. 依存を struct のフィールドに持たせ、NewXxx(...) コンストラクタで外から受け取る。関数の中の生成を消す。
  4. 本物の組み立ては main(Composition Root)に集める。そこで具体的な部品を作って、注入する。
  5. テストは、口を満たす偽物を手書きして渡す(database/sql や外部パッケージを import しない)。go test が、DB・ネットワーク無しで走る。

親方より

使うものを、関数の中で作るな。外から受け取れ。作る場所は、入口の一カ所に集めろ。そうすれば、本物でも偽物でも、同じ口で挿せる。試験台に本物のエンジンを丸ごと載せなくても、部品ひとつの働きを試せる。

ただし、何でも口を付けるな。差し替えたいもの、偽物にしたいものだけだ。動かないものを直すのが整備じゃない。動いているものを、開けて確かめられるようにしておくのが、整備だ。

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。