Posted at

Goで標準パッケージのみでtest coverage 100%を維持し続けるための開発ガイド


前提

この記事は、Goでテスト駆動で開発をしていく上で、標準パッケージのみで、ユニットテストのカバレッジを常に100%を維持しながら開発していくための基本ガイドとなります。

「関数をMockしてテストしていく方法」を主に紹介し、最後に「どうすればtestableなコードを書けるか」という話をしております。


なぜ標準パッケージだけでやるのか

Goの思想 に則ると、標準のTestingパッケージだけでテストすることが理想だからです。

GoのTestingパッケージは、他言語のメジャーなテストライブラリに比べるとかなり機能が少ないものの、それゆえにGoの原則さえ理解していれば誰でもすぐに読み書きできTestに合わせてコードを書いていかないといけないからこそ逆説的に適切なコードを書けるというメリットがあります。


テストカバレッジの基準とは

Goのテストカバレッジの分母は、基本的に C0または命令網羅率(statement coverage)と呼ばれる命令をどれくらい網羅しているかとなります。

ここでいう、命令とは、条件分岐を除いた、なんらかの処理を実行している部分となります。

詳細は、こちら。

Goのテストカバレッジの考え方と計測方法はこちら。

Try Golang! Go標準のカバレッジとはなにものか

また、テストのカバレッジを測定するコマンドはこちら。

テストカバレッジの測定 はじめてのGo言語


関数をMockする方法

Goの標準のTestingパッケージには、Mockingなどの他のライブラリによくある機能がないので、コードを書く時点からどうやってテストするかを考えながら書いていく必要があります。

つまり、どうやってMockしていくかを常に考えていく必要があります。

例えば、下記のような処理を見てみましょう。

下記のGetDataという関数をテストする場合、中で使用されているGetItemという関数をMockする必要があります。

なぜなら、コメントアウトにある通り、GetItemは、DBに実際にSQLを走らせる関数なので、ユニットテストでそのまま走らせるわけにはいかないからです。

また、このGetItemの直後にエラーハンドリングがあるので、エラーハンドリングの処理もテストで網羅するために、エラーが返却されるケースのMockも必要になります。

func (s *Server) GetData(query string) ([]string, error){

d, err := s.db.GetItem(query) // 内部で実際にDBに接続してSQLを走らせる関数
if err != nil {
return nil, err
}
return d, nil
}

それでは、どうすれば良いか実際に見ていきましょう。


1. 関数をinterfaceに定義する

まず。GoにおけるテストのMockingの一番基本的なやり方であるinterfaceを使った方法を紹介していきます。

Goにおけるinterfaceは、「実装したオブジェクトが使用できる関数(振る舞い)と入出力の型のみをまとめて定めたもの」といえます。また、関数の型だけを取り決めており、その関数の中身の挙動には一切関与しないということもいえます

そして、あるinterfaceを実装したオブジェクトは、interfaceに定義された関数を具体的な挙動とともに実装しています。

テストでは、このinterfaceの特性を活かし、本番用のtypeとは別に、テスト用のオブジェクトにinterfaceを実装させて、関数のダミーの挙動を定義することでMockしていきます。

そして、そのMockの関数を呼び出す側は、具体的なtypeではなく、interface(を実装したオブジェクト)をレシーバに関数を実行するようにすることで、そのinterfaceを実装したオブジェクトならなんでも受け付けるようにしておきます。

文章だけで説明してもぴんと来ないと思うので、以下で具体的な例を見ていきましょう。


a. interfaceをStructのフィールドとして持つ

先程のコードに戻ってみます。

関連するstructやinterfaceも追加しました。


type Server struct{
db datagetter
}

func (s *Server) GetData(query string) ([]string, error){
d, err := s.db.GetItem(query) // 内部で実際にDBに接続してSQLを走らせる関数
if err != nil {
return nil, err
}
return d, nil
}

type DB struct{}

type datagetter interface{
GetItem(string) ([]string, error)
}

func GetItem(q string) ([]string, error){
// DBからDataを取得して返却 下記のようなイメージ
// 1.queryを整形
// 2.整形したqueryとでレシーバのstructが持っているconnectionを利用して、DBからデータを取得
// 3.取得したデータを整形して返却
}

ここで注目すべきは、interfaceであるdatagetterを、structであるServerが中のフィールドの型として使用している点です。

つまり、ここのServerの中のdbというフィールドには、datagetterを実装したオブジェクトならなんでも入るということになります。

こうすることで、テストの際には、db というフィールドに datagetterを実装したダミーのオブジェクトを入れることで、datagetterが宣言しているGetItem という関数の挙動をテスト用のものに変更することができます。

そして、次にこのGetDataのMock用のstructと関数を作ってみましょう。

// DBに接続して実行するメソッドをmockするためのstruct

type MockDB struct{}

// テスト用にMockした関数
func (*MockDB) GetItem(string) ([]string, error) {
// returns dummy response
return []string{"test"}, nil
}


func TestGetData(t *testing.T) {
cases := []struct {
input string
expected []string
}{
{"test", []string{"test"}},
}

// mock
// MockDBをServerの項目として持たせる
s := &Server{
db: &MockDB{},
}

for _, tc := range cases {
// execute
d, err := s.GetData(tc.input)

// assert
if !reflect.DeepEqual(d, tc.expected) {
t.Errorf("failed handling valid cases, expected: '%#v', actual: '%#v'", tc.expected, d)
}
if err != nil {
t.Errorf("failed handling valid cases, '%d'", err)
}
}
}

The Go Playground

上記と同様にこのinterfaceを活用することで、エラーケースのテストも実施することができます。

// DBに接続して実行するメソッドをmockするためのstruct

type MockDBError struct{}

// テスト用にMockした関数
func (*MockDBError) GetItem(string) ([]string, error) {
// returns dummy response
return nil, fmt.Errorf("error in GetItem")
}


func TestGetDataError(t *testing.T) {
// set up
cases := []struct {
input string
expected []string
}{
{"test", nil},
}

// mock
// MockDBをServerの項目として持たせる
s := &Server{
db: &MockDB{},
}

for _, tc := range cases {
// execute
d, err := s.GetData(tc.input)

// assert
if !reflect.DeepEqual(d, tc.expected) {
t.Errorf("failed handling valid cases, expected: '%#v', actual: '%#v'", tc.expected, d)
}
if err == nil {
t.Errorf("failed handling invalid cases, error was not found when expected")
}
}
}

The Go Playground

このように、Mockしたい関数をinterfaceに定義して、そのinterfaceをstructのフィールドとして埋め込むことで、関数のテストができるようになります。


b. Mockしたinterfaceを関数の引数に取る

上で紹介したのは、interfaceをStructのfieldの一つとして持たせることで、そのStructを引数かレシーバに取る関数が内部で使用している関数をMockするというやり方でした。

ただし、このやり方では、レシーバを取らない関数をMockできません。

例えば、structの初期化の関数などはレシーバは取ろうにも取れないことが多いと思います。

そこで、そのような場合は、interfaceを関数の引数に持つ ことで関数を必要に応じてMockできます。


type Server struct {
db DB
}

type ConnectionMaker interface {
MakeDBConnection() (DB, error)
}

// 引数にInterfaceを取る
func InitializeServer(cm ConnectionMaker) (*Server, error) {
db, err := cm.MakeDBConnection() // Mockしたい関数
if err != nil {
return nil, err
}

s := &Server{
db: db,
}
return s, nil
}

上記のInitializeServerは、引数にConnectionMakerというinterfaceを取っているため、このinterfaceを実装したオブジェクトならなんでも引数として受け付ける ということになります。

つまり、前項と同様に、今度は引数にMock用のオブジェクトを入れることでテストができるということになります。


2. type funcを利用する

ここまででinterfaceを使ってMockする方法を紹介しました。基本的に、interfaceでMockできるところはそうするべきですが、interfaceを使うと冗長になって、可読性が下がる場合があります。

その際の方法として、type funcを利用する方法もあります。

つまり、Goの、関数をオブジェクトとして扱える性質を利用して、テスト対象の関数の引数またはレシーバに関数を持たせることで、外からmockした関数を投げてテストすることができるようにするということです。

実際の例を見てみましょう。


// 関数をオブジェクトとして扱うためにtypeとして定義する
type ConnectionFunc func() (DB, error)

func InitializeServer(connect ConnectionFunc) (*Server, error) {
// 引数として受け取った関数を実行する
db, err := connect()
if err != nil {
return nil, err
}

s := &Server{
db: db,
}
return s, nil
}

// 本番用の関数
func connect() (DB, error) {
// // DBとの接続を確立して返却 下記のようなイメージ
// // 1.環境変数などからポートなどのデータベース接続用の情報を読み込み
// // 2.読み込みんだ情報を利用して接続を試行(エラーなら返却)
// // 3.成功したらコネクションをオブジェクトとして返却
}

上記のInitializeServerという関数は、関数(をtypeとして定義したもの)を引数として取って中で実行しているので、引数として投げる関数次第でその挙動を変えることができるということになります。

そして、その引数の、ConnectionFunc というtypeは、func() DB, errorという形の関数ならなんでも良いので、Mockする関数もこの形を取れば良いということになります。

例えば、本番ではこのように使うとして、

func main() {

s := InitializeServer(connect)
}

テストでは、このようにmock用の関数を用意して、テストすることができます。

// 正常系のMock

func MockConnect() (DB, error) {
return "mocked db", nil

}

// 異常系のMock
func MockFailConnect() (DB, error) {
return "", fmt.Errorf("mocked failure")
}


func TestInitializeServerSuccess(t *testing.T) {
// execute(引数にMock関数を渡している)
s, err := InitializeServer(MockConnect)

// assert
if s == nil { // checking if Server is initialized(= not nil)
t.Errorf("failed handling with a valid case, return value was nil when expected")
}
if err != nil {
t.Errorf("failed handling valid cases, '%d'", err)
}
}

func TestInitializeServerFail(t *testing.T) {
// execute(引数にMock関数を渡している)
s, err := InitializeServer(MockFailConnect)

// assert
if s != nil { // checking if Server is not initialized
t.Errorf("failed handling with a invalid case, unexpected return value:'%#v'", s)
}
if err == nil {
t.Errorf("failed handling a invalid case, error was not found when expected")
}
}

The Go Playground


Mockを使ったテストのやり方

前項のまとめとして、あらためて実際にどのようにMockを作ってテストをするかということを考えていきましょう。


Step1. Mock作成用の関数をテストパッケージ用に公開する

前項の例では、便宜的にすべて同じパッケージ内の想定で説明していました。

それでも問題はないのですが、Goでは、テスト対象のパッケージとは異なるテスト用のパッケージからテストするのがベストプラクティスという考え方もあります。それは、パッケージのユーザの視点からテストするためです。

しかし、外部のパッケージからテストするとなると、パッケージ内のprivateな関数やstructのfieldはテストケースから直接参照することができないということになります。

例えば、最初に例にあげたコードだと、下記のdbは、Serverのprivate fieldなので外からは参照できません。

package datagetter

func (s *Server) GetData(query string) ([]string, error){
d, err := s.db.GetItem(query)
if err != nil {
return nil, err
}
return d, nil
}

なので、この場合は、テスト用にMockした関数を実装したdbを、あらかじめ作って外部向けに公開することになります。

具体的には、以下のようになります。

package datagetter

// MakeServerWithConnectionError initializes and return server with MockDBConnection
func MakeServerWithMockDBConnection() *Server {
return &Server{
db: &MockDB{},
}
}

// MakeServerWithConnectionError initializes and return server with MockDBConnection
func MakeServerWithConnectionError() *Server {
return &Server{
db: &MockDBError{},
}
}


Step2. Mockを呼び出してテストをする

そして、テストする側は、publicに提供されたツールを利用することで外部パッケージからでもMockされた関数が利用できることになります。

package datagetter_test

import (
"datagetter"
)

func TestGetData(t *testing.T) {
cases := []struct {
input string
expected []string
}{
{"test", []string{"test"}},
}

// mock
// 提供されたメソッドを利用してテスト用のオブジェクトを取得する
s := datagetter.MakeServerWithMockDBConnection()

for _, tc := range cases {
// execute
d, err := s.GetData(tc.input)

// assert
if !reflect.DeepEqual(d, tc.expected) {
t.Errorf("failed handling valid cases, expected: '%#v', actual: '%#v'", tc.expected, d)
}
if err != nil {
t.Errorf("failed handling valid cases, '%d'", err)
}
}
}


テストできる(=Mockできる)コードを書く方法

最後に、これまでのテストのMockの方法を踏まえて、どうやってテストできるコードを書いていくべきかという話をします。

Goでtest coverage 100%を維持し続けるためには、そもそもテストを書きながら(少なくともテストすることを考えながら)コードを書いていくテスト駆動の考え方が必要になります。

そして、そのテストできるコード(関数)とは、下記のいずれかを満たしているということになります。

1. そもそもMockしないでそのまま走らせて良い関数

2. interfaceを実装していてMockできる関数

3. 引数にinterfaceを実装したオブジェクトを渡すことでMockできる関数

4. レシーバか引数でtype funcを渡すことでMockできる関数

例えば、最初に例として出したGetDataの関数も、下記のような作りだったら、上の1-4のいずれも満たしておらず、テストが難しくなります。

import (

"db" // DB操作パッケージ
)
func (s *Server) GetData(query string) ([]string, error){
d, err := db.GetItem(query) // dbは外部からimportしたパッケージのレシーバを取らない関数
if err != nil {
return nil, err
}
return d, nil
}

まず、GetDataの中のGetItemは、DBを操作する処理なのでテストでそのまま走らせていい関数ではありません。また、後続にエラーハンドリングがあるので、テストによってはエラーが返ってくるようにする必要もあります。

そしてGetItemという関数は、このdbという外部パッケージのレシーバを持たない関数として持っています。

その上で、引数にはふつうのstringを取っているため、外部から関数の挙動を制御する方法がありません。(この引数のstringに特定の値("test"とか)の場合だけ特殊な処理をするみたいなこともできますが、それは可読性や安全性などの観点から好ましくないのは明らかでしょう。)

このような場合、このコードの書き方がTestableじゃないコードということになるので、GetItemを定義したinterfaceを用意するか、引数を変更するなどの対応が必要になります


補足

ここまで説明したのが、「標準パッケージのみでテストのカバレッジを満たしながら開発していく」ための方法となります。この記事で書いた方法で、基本的に開発は進められると思います。

ただし、この記事では、テストのカバレッジを満たしていくことに重きを置いているため、可読性などの視点でより良いコードを書いていく方法は最低限しか触れていません。

実際には、「原則に則ってカバレッジを100%満たしたコードを書いているけど、コードの行数が膨大かつ冗長で読みづらい」みたいな状況にはしばしば陥りがちだと思います。

それらに対処する方法は大小いくつかあると思いますが、例えばsub testがその一つだと思います。

なので、実際にそれなりの規模のものを作る上では、そのあたりの方法も別途頭に入れる必要があるとは思います(そのうちそういう記事も書こうかなと思います)。


まとめ


Goの標準パッケージのみでテストする際に、関数をMockする方法

1. 関数をinterfaceに定義する

2. type funcを関数の引数かレシーバに持つ


test coverage100%を達成する上で、関数が満たしていないといけないこと(いずれか一つ)

1. そもそもMockしないでそのまま走らせて良い関数

2. interfaceを実装していてMockできる関数

3. 引数にinterfaceを実装したオブジェクトを渡すことでMockできる関数

4. レシーバか引数でtype funcを渡すことでMockできる関数


参考