Golangにおけるinterfaceをつかったテスト技法

by Taichi Nakashima,

最近何度か聞かれたので自分がGolangでCLIツールやAPIサーバーを書くときに実践してるinterfaceを使ったテスト技法について簡単に書いておく.まずはinterfaceを使ったテストの基本について説明し次に自分が実践している簡単なテクニックをいくつか紹介する.

なおGolangのテストの基本については @suzuken さんによる「みんなのGo言語」 の6章が最高なので今すぐ買ってくれ!

前提

自分はテストフレームワークや外部ツールは全く使わない.標準のtestingパッケージのみを使う.https://golang.org/doc/faq#Packages_Testing にも書かれているようにテストのためのフレームワークを使うことは新たなMini language(DSL)を導入することと変わらない.最初にそれを書く人は楽になるかもしれないが新しくプロジェクトに参入してきたひとにはコストにしかならない(Golang以外も学ぶ必要がある).例えば自分があるプロジェクトにContributeしようとして見たこともないテストフレームワークが使われているとがっくりする.

とにかくGolangだけで書くのが気持ちがいい,に尽きる.

テストとinterface

テストという観点からみた場合のinterfaceの利点は何か? interfaceを使えば「実際の実装」を気にしないで「振る舞い」を渡すことができる.つまり実装の切り替えが可能になる.interfaceを使うことでいわゆるモックが実現できる.

どこをinterfaceにするのか?

interfaceはモックポイントと思えば良い.外界とやりとりを行う境界をinterfaceにする,が基本.外界との境界とは例えばDBとやりとりを行う部分や外部APIにリクエストを投げるClientである.他にも考えられるがとりあえずここをinterfaceにする.

実例

以下では簡単なAPIサーバーを書くとしてinterfaceによるテスト手法の説明を行う.このAPサーバーはDBとしてRedisを使うとする.なおコードは全てpseudoである.

まずはDBのinterfaceを定義する.

type DB interface {
    Get(key string) string
    Set(key, value string) error
}

次に実際のRedisを使った実装を書く.例えば以下のように書ける.

type Redis struct {
    // 接続情報など
}

func (r *Redis) Get(key string) string 
func (r *Redis) Set(key, value string) error

main関数から呼び出すときのことを考えてコンストラクタを実装すると良い(必要な接続情報などが与えられなかった時,もしくは必要な初期化処理に失敗した時にエラーを返せる).

func NewRedis() (DB, error)

ここで重要なのは実際の実装であるRedisを返すのではなくinterfaceのDBを返すこと.サーバー側ではこのinterfaceを使う.

サーバーの実装は以下のようにする.

type Server struct {
     DB DB
}

func (s *Server) Start() error

ServerはinterfaceのDBを持ち内部の実装(例えばhandlerなど)ではこのinterfaceを利用する.

main関数は以下のように書ける(main関数には他にもflagの処理や設定ファイルを読み込みそれを各コンストラクタに渡すという処理が考えられる).

func main() {
     redis, err := NewRedis()
     if err != nil {
         log.Fatal(err)
     }

     server := &Server{
         DB: redis,
     }

     if err := server.Start(); err != nil {
         log.Fatal(err)
     }
}

ではServerのテストはどうすれば良いか?例えば今であればDockerを使いRedisを準備することを考えるかもしれない.それが容易ではない場合,もしくは外部依存のないテストを書きたい場合はモックを考える.以下のようにDB interfaceを満たすモック実装を準備する.

type TestDB struct {
}

func (d *TestDB) Get(key string) string 
func (d *TestDB) Set(key, value string) error 

具体的な実装は書いてないが例えばフェイクの値やフェイクのerrorを返すようにする(これを考えることもより良い実装につながると思う).

これを利用すればServerのテストは以下のように書ける.

func TestServer(t *testing.T) {
     testDB := &TestDB{}

     server := &Server{
         DB: testDB,
     }    
}

Serverが必要なのはDB interface(振る舞い)であり実際の実装は何でも良い.これで実際にRedisを準備することなくServerの動きをテストすることができる.

以上がinterfaceを使ったテスト手法の基礎である.ここではDBを例にしたが他にも外部APIとやり取りを行うClientなどにも応用できる.また今回はinterfaceから実装したがすでに実際の実装がある場合もそれに対応したinterfaceを定義してしまえば同様のテストを書くことができる.

いくつかのテクニック

以下では自分が実践しているいくつかのテクニックを紹介する.

環境変数で切り替える

毎回全てのテストをモックにすれば良いわけではない.ちゃんと実際の実装によるテストも必要である.自分はこの切り替えに環境変数を使う.環境変数を使うのはCIとの相性が良いからである.

追記: いくつかこのやり方に誤解があったので.この環境変数の切り替えはテストコード(*_test.go)内に書く.本番用のコードとは別にコンストラクタを実装して切替を実現する(なので本番のコードで環境変数によってモックが使われてしまうという誤爆はない).

例えば以下のようなコンストラクタを準備する.


const (
     EnvTestDSN = "TEST_DSN"
)

func NewTestDB(t *testing.T) (DB, func()) {
     dsn := os.Getenv(EnvTestDSN)
     if len(den) == 0 {
           t.Log("Use TestDB")
           return &TestDB{}, func() {}
     }

    db, err := NewPostgreSQL(dsn)
     if err != nil {
         t.Fatal(NewPostgreSQL failed: ,err)
      }

     return db, func() {
         pSQL := db.(*PostgreSQL)
         pSQL.DB.DropTable(&SplunkInfo{})
     }
}

環境変数として接続情報(DSN)が与えられた場合は実際の実装であるPosgreSQLを返し実際のDBでテストし.与えられない場合はモックの実装でテストを行う.

ちなみに2つ目の返り値はDBをCleanupするための関数で呼び出し側ではdeferと一緒に使う(例えばDockerなどでEphemeralなデータストアを使う場合にデータを消してまっさらな状態に戻す).

外部パッケージの一部をinterfaceとして切り出す

例えば外部のAPIを利用する場合は公式などが提供しているClientパッケージを使ってしまうのが手っ取り早い.特にAPIの場合はリクエストに制限があったり安易に呼び出せないものあるのでモックが大切になる.この場合は自分は必要な部分のみをinterfaceとして切り出すという方法をとる(他にもレスポンスを保存するSymmetric API Testingという方法もある).

例えばGitHubのAPIを使いたい場合は https://github.com/google/go-github を使うが,以下のように必要なものを切り出してしまう.そしてメインロジックではこのinterfaceを利用するようにする.

type GitHub interface {
    CreateRelease(ctx context.Context, opt Option) (string, error)
    GetRelease(ctx context.Context, tag string) (string, error)
    DeleteRelease(ctx context.Context, releaseID int) error
}

Testing as Public API

“Advanced Testing with Go” の発表を見てなるほどなあと思い実践始めたのはtest用の関数をAPIとして公開する方法.

すべてをmainパッケージに実装している間は良いがある程度の規模になるとパッケージの切り出しが大切になる.*_test.goに記述した関数は別のパッケージからは参照できないためモックの実装をどこに書くかが問題になる(呼び出し側で何度も実装するのは非効率).この方法はtesting.gotesting_*.goといったファイルを準備し外部から参照可能なテスト用のヘルパー関数を提供する,つまりテスト関数もAPIとして提供してしまう方法である.

例えば以下のような関数を準備する.

func TestDB(t *testing.T) DB
func TestConfig(t *testing.T) *Config

これで呼び出し側でのテスト用のモックなどの再実装を防ぐことができる.

まとめ

ただしやりすぎると可読性が下がるのでやりすぎには注意する必要がある.