はじめに
Goでクリーンアーキテクチャやレイヤードアーキテクチャを実装しようとすると、DI(Dependency Injection)
やDIP(Dependency Inversion Principle)
などの概念が出てきて思うようにアーキテクチャに対する理解が進みませんでした。
また、GoでDIについて解説された記事は既にいくつかあるのですが、いまいち腑に落ちていなかったので自身の知識を整理するためにこの記事を書きました。
この記事ではシンプルな実装でDIに触れ、DIのメリットを体験できることを目的とします。
DIについてはこちらの記事にわかりやすくまとまっていますので、そもそもDIって何?という方はぜひ参考にしてみてください。
https://qiita.com/hshimo/items/1136087e1c6e5c5b0d9f
DIのメリット
DIについて細かく説明するのはこの記事の本題ではないのですが、DIを導入するメリットについては軽く触れておきたいと思います。
DIのメリットは以下の2つです。
- オブジェクト間の結合度を下げる
- 依存関係のあるオブジェクトをモック化でき、ユニットテストがしやすくなる
2つと書きましたが、オブジェクト間の結合度を下げることにより結果としてユニットテストがしやすくなると言えます。
特に外部ライブラリや外部サービスを使用する際に、依存関係のある箇所をモック化することによりテストが容易になります。
DBへの接続などをモック化したり、コントローラからDB接続を行っている層(リポジトリー層など)をモック化するケースが多いかと思います。
試してみる
本題の実装に入っていきたいと思います。
GoでDIを実現する方法はinterfaceを満たす
ことで実現できます。
まずはDIを活用してないコードを実装し、リファクタリングしていく形でDIを活用したコードを実装していきます。
サンプルコードは部屋の面積を算出する簡単な実装になります。
DIを活用していない実装
部屋情報の実装
type Room struct {
RoomNumber int
Height float64
Width float64
}
func (r *Room) CalcArea() string {
area := r.Height * r.Width
return fmt.Sprintf("room %d's area is %.f", r.RoomNumber, area)
}
area := r.Height * r.Width
の部分は面積を計算する処理なのでCalcArea()
から切り出します。
type IGeometry interface {
Area(height, width float64) float64
}
type geometry struct{}
func NewGeometry() *geometry {
return &geometry{}
}
func (g *geometry) Area(height, width float64) float64 {
return height * width
}
NewGeometry
でコンストラクタを定義し、geometry
構造体を返しています。
上記に伴い、CalcArea()
メソッドを修正します。
func (r *Room) CalcArea() string {
ig := NewGeometry()
area := ig.Area(r.Height, r.Width)
return fmt.Sprintf("room %d's area is %.f", r.RoomNumber, area)
}
面積を計算する部分はCalcArea()
メソッドから切り出すことができましたが、メソッド内でNewGeometry
コンストラクタの初期化を行っているためgeometry
のと結合度が高くなっています。
次にCalcArea()
メソッドに対するテストを追加していきます。
func TestCalcArea(t *testing.T) {
testCases := []struct {
room Room
expected string
}{
{Room{RoomNumber: 101, Height: 5.0, Width: 7.0}, "room 101's area is 35"},
{Room{RoomNumber: 201, Height: 4.0, Width: 7.5}, "room 201's area is 30"},
}
for _, tc := range testCases {
actual := tc.room.CalcArea()
if actual != tc.expected {
t.Errorf("expected %v, but got %v", tc.expected, actual)
}
}
}
上記のテストコードはテスト対象がシンプルなため問題なさそうですが、CalcArea()
メソッド内のig.Area(r.Height, r.Width)
の内部処理を把握しておかないといけません。
また、関数やメソッド内でDBへの接続などを行っている場合は、DBを立ち上げておく必要があるなど、今回テストしたいCalcArea()
のテストに必要ない箇所も考慮しなければいけません。
DIを活用した実装
さきほど実装したコードをリファクタリングしていきたいと思います。
まずはCalcArea()
内のig := NewGeometry()
をどうするか考えます。
今回はメソッドの引数にIGeometry
を受け取れるようにすることで、DIを実現していきます。
func (r *Room) CalcArea(ig IGeometry) string {
area := ig.Area(r.Height, r.Width)
return fmt.Sprintf("room %d's area is %.f", r.RoomNumber, area)
}
これだけでIGeometry
をモック化して引数に渡す(DIでいうところの注入する)ことができます。
テストを修正していきます。
func TestCalcArea(t *testing.T) {
testCases := []struct {
roomNumber int
area float64
expected string
}{
{101, 35.0, "room 101's area is 35"},
{201, 30.0, "room 201's area is 30"},
}
for _, tc := range testCases {
room := Room{
RoomNumber: tc.roomNumber,
}
actual := room.CalcArea()
if actual != tc.expected {
t.Errorf("expected %v, but got %v", tc.expected, actual)
}
}
}
変更点としてはRoom
を定義した際にHeight
とWidth
が期待する面積の値になるように指定していましたが、面積を算出する処理のことを気にせず、テスト実行時にはarea
で指定した面積の値がig.Area(r.Height, r.Width)
の結果として返ってくるもの、としてテストを変更しています。
このままではテストが落ちてしまうので、IGeometry
の部分をモック化し、CalcArea()
の引数に渡していきます。
まずはモックを作成します。
// モックの定義
type FakeIGeometry struct {
IGeometry // IGeometryを埋め込むことでAreaを呼び出せるように
FakeCalcArea func(height, width float64) float64
}
// IGeometryインターフェースを満たすためにはAreaメソッドを定義する必要がある
func (g FakeIGeometry) Area(height, width float64) float64 {
// FakeIGeometryのAreaメソッドは内部的にはFakeCalcAreaを呼び出しているだけ
return g.FakeCalcArea(height, width)
}
モック化に関しては以下の記事を参考にさせていただきました。
次にモックを利用してテストを修正します。
func TestCalcArea(t *testing.T) {
testCases := []struct {
roomNumber int
area float64
expected string
}{
{101, 35.0, "room 101's area is 35"},
{201, 30.0, "room 201's area is 30"},
}
for _, tc := range testCases {
// モックの初期化
mockIGeometry := FakeIGeometry{
FakeCalcArea: func(height, width float64) float64 {
// testCasesで指定したareaの値を返すだけ
return tc.area
},
}
room := Room{
RoomNumber: tc.roomNumber,
}
actual := room.CalcArea(mockIGeometry)
if actual != tc.expected {
t.Errorf("expected %v, but got %v", tc.expected, actual)
}
}
}
これでモックを利用しテストが通るようになりました。
コード中のコメントにも記載しましたが、面積を算出する処理をmockIGeometry
によりtestCases
で指定したarea
の値を返すようにしているため、仮に面積を算出する処理にバグがある、もしくは未実装の場合でもCalcArea()
メソッドのテストを行うことができるようになりました。
このようにすることでユニットテストが書きやすくなり、それぞれの責務でテストを行うことができます。
おわりに
今回はGoでDIを活用する方法を解説しました。
わかりにくい部分や間違っている部分があれば指摘していただけると嬉しいです。