Testify を使った Go 言語で書かれたプログラムのテスト

ここ数か月ほど仕事で Go 言語を使ってプログラムを書いています。その中で、テストの記述が楽になる [testify](https://pkg.go.dev/github.com/stretchr/testify) というライブラリを見つけました。この記事では、その使い方をチュートリアル形式でご紹介します。

# テスト対象の HTTP サーバを作る

以下の仕様で HTTP サーバを実装する、というストーリーに沿って testify の機能を紹介します。

- `/probe` にアクセスすると `http://example.com` にアクセスして、200 が返ってくるかどうかをチェックする
- 上記に成功した場合、`/probe` のレスポンスは `probe_success 1` となり、失敗した場合は `probe_success 0` が返ってくる (Prometheus の exporter と同じ形式)

## テストコード

まず testify なしで、標準ライブラリだけでテストを書きます。`handleProbe` がテスト対象の関数です。`http.HandleFunc` に渡せるようにシグネチャを合わせています。Go 言語には `net/http` パッケージのテストを書くためのヘルパとして `net/http/httptest` というパッケージが用意されているので、これを使いました。

```go:main_test.go
package main

import (
	"net/http"
	"net/http/httptest"
	"testing"
)

func Test_handleProbe(t *testing.T) {
	rec := httptest.NewRecorder()
	req := httptest.NewRequest("GET", "/probe", nil)

	handleProbe(rec, req)

	expectedCode := http.StatusOK
	if rec.Code != expectedCode {
		t.Errorf("unexpected status code, expected %d but got %d", expectedCode, rec.Code)
	}

	expectedBody := "probe_success 1\n"
	if rec.Body.String() != expectedBody {
		t.Errorf("unexpected body, expected %s but got %s", expectedBody, rec.Body.String())
	}
}
```

## プロダクションコード

テストが通るようにプロダクションコードを実装していきます。`handleProbe` は以下のように実装しました。

```go:main.go
func handleProbe(w http.ResponseWriter, req *http.Request) {
	var ret int
	if new(probe).Probe("http://example.com") {
		ret = 1
	}

	w.WriteHeader(http.StatusOK)
	w.Write([]byte(fmt.Sprintf("probe_success %d\n", ret)))
}
```

`Probe` メソッドで与えた URL にアクセスできるかどうかチェックします。

`Probe` は以下のように実装しました。

```go:main.go
type Prober interface {
	Probe(string) bool
}

type probe struct{}

func (p *probe) Probe(url string) bool {
	res, err := http.Get(url)
	if err != nil {
		return false
	}

	return res.StatusCode == http.StatusOK
}
```

単に GET でアクセスして `http.StatusOK` (200) かどうかチェックしているだけです。 `err` が返ってきた場合はプロトコル的なエラー (URL が間違ってる、サーバの HTTP レスポンスが不正等) が起きていたり、接続がタイムアウトしたりしている訳ですが、今回は簡単のためこれらも接続できなかったという扱いで `false` にしています。

ここまで書くと `go test` がパスするようになりました。

# assert を使ったテスト

## `assert.Equal`

標準ライブラリだけでテストを書くと、山ほど `if hoge { t.Errorf("fuga") }` のようなコードを書くことになるのですが、`assert` パッケージを使うとすっきり書けます。

```go:main_test.go
func Test_handleProbe(t *testing.T) {
	rec := httptest.NewRecorder()
	req := httptest.NewRequest("GET", "/probe", nil)

	handleProbe(rec, req)

	assert.Equal(t, http.StatusOK, rec.Code) // *testing.T, 期待する値、実際の値 を順に渡す
	assert.Equal(t, "probe_success 1\n", rec.Body.String())
}
```

すっきりして読みやすくなったと思います。

## テスト失敗時の出力

`assert` パッケージの良いところは、記述が簡潔になることだけではなく、失敗したアサーションが分かりやすく出力されるところにあります。試しに `handleProbe` を以下のように書き換えてテストしてみます。

```go:main.go
func handleProbe(w http.ResponseWriter, req *http.Request) {
	w.WriteHeader(http.StatusOK)
	w.Write([]byte("probe_success 0\n")) // 常に 0 を返す
}
```

```sh
$ go test ./...
--- FAIL: Test_handleProbe (0.00s)
    main_test.go:18:
		Error Trace:    server_test.go:18
		Error:	  Not equal:
				expected: "probe_success 1\n"
				actual  : "probe_success 0\n"

				Diff:
				--- Expected
				+++ Actual
				@@ -1,2 +1,2 @@
				-probe_success 1
				+probe_success 0

		Test:	   Test_handleProbe
FAIL
FAIL    qiita-testify-example 0.004s
FAIL
```

このように、期待する結果と実際の結果、そして値の diff が表示されます。アサーションライブラリを使わないと自分でエラーメッセージを丁寧に書く必要がありますが、その手間が省けて便利です。

## HTTP ハンドラ用のアサーション

`testify` には HTTP ハンドラに特化したアサーションメソッドもあるので、さらに短く書くこともできます。

```go:main_test.go
func Test_handleProbe(t *testing.T) {
	assert.HTTPStatusCode(t, handleProbe, "GET", "/probe", nil, http.StatusOK)
	assert.HTTPBodyContains(t,  handleProbe, "GET", "/probe", nil, "probe_success 1\n")
}
```

# mock を使ったテスト

ここまでに書いたテストは実際に http://example.com へとアクセスしています。そのため、インターネットに繋がらない環境でテストすると失敗します。また、 http://example.com が落ちるといった、開発者がコントロールできない要因でテストが失敗することもあります。こういった環境や外部システムへの依存をなくす手段がモックです。このプログラムにおいては、 `probe` 構造体がテスト時にモックで差し替えたいオブジェクトです。

## プロダクションコードの修正

始めに、テストから `probe` を差し替えられるよう、パッケージ変数を定義し、`handleProbe` からパッケージ変数を参照するように変更します。

```go:main.go
var defaultProbe Prober = new(probe)

func handleProbe(w http.ResponseWriter, req *http.Request) {
	var ret int
	if defaultProbe.Probe("http://example.com") {
		ret = 1
	}

	w.WriteHeader(http.StatusOK)
	w.Write([]byte(fmt.Sprintf("probe_success %d\n", ret)))
}
```

## モックを使ったテストの記述

モックに使う `mockProbe` 構造体を新たに定義し、 `mock.Mock` を埋め込みます。`mock.Mock` にはモックオブジェクトに必要な機能がいろいろとあるのですが、今回はモックオブジェクトのメソッドが期待された形式で呼び出されていることのチェックと、そのメソッドの戻り値を指定する機能を使います。

```go:main_test.go
import (
	// 省略
	"github.com/stretchr/testify/mock"
)

type mockProbe struct {
	mock.Mock
}

func (m *mockProbe) Probe(url string) bool {
	args := m.Called(url)
	return args.Bool(0)
}

func Test_handleProbe(t *testing.T) {
	m := &mockProbe{}
	m.On("Probe", "http://example.com").Return(true)
	defaultProbe = m

	assert.HTTPStatusCode(t, handleProbe, "GET", "/probe", nil, http.StatusOK)
	assert.HTTPBodyContains(t, handleProbe, "GET", "/probe", nil, "probe_success 1\n")
}
```

`On` メソッドに、テスト中に呼び出されるメソッド名とそのメソッドに渡される引数を与えます。今回の場合は `Probe` メソッドに `"http://example.com"` という文字列が渡されるはずなので、これらを指定しています。さらに、`Return` メソッドで `Probe` が返す戻り値を指定します。今回は戻り値が `true` 1つですが、複数ある場合は `Return(false, fmt.Errorf("foobar"))` のように複数の戻り値を指定できます。

モックを導入したことで `Probe` に失敗したケースもテストできるようになりました。テストケースを追加してみます。

```go:main_test.go
func Test_handleProbe_success(t *testing.T) {
	m := new(mockProbe)
	m.On("Probe", "http://example.com").Return(true)
	defaultProbe = m

	assert.HTTPStatusCode(t, handleProbe, "GET", "/probe", nil, http.StatusOK)
	assert.HTTPBodyContains(t, handleProbe, "GET", "/probe", nil, "probe_success 1\n")
}

func Test_handleProbe_fail(t *testing.T) {
	m := &mockProbe{}
	m.On("Probe", "http://example.com").Return(false)
	defaultProbe = m

	assert.HTTPStatusCode(t, handleProbe, "GET", "/probe", nil, http.StatusOK)
	assert.HTTPBodyContains(t, handleProbe, "GET", "/probe", nil, "probe_success 0\n")
}
```

# suite を使ったテスト

`suite` パッケージを使うとテストをグルーピングすることができます。これによって、パッケージよりも細かい単位 (テストスイート) でテストをまとめることができ、テストスイートごとに Setup, TearDown といったテストの前処理、後処理や、ヘルパ関数の定義ができるようになります。

## テストスイートの定義

まず `suite.Suite` を組み込んだ構造体を定義します。この構造体にはテストスイート内で共有したいフィールドを定義できます。今回はモックオブジェクトを `m` として定義しました。

```go:main_test.go
type ProbeSuite struct {
	suite.Suite
	m *mockProbe
}
```

## `SetupTest`

ProbeSuite 構造体に対して `SetupTest` を定義して前処理を記述します。`SetupTest` はテスト1つ1つの実行直前に実行されます (RSpec をご存知の方は `before(:each)` をイメージしてください)。今回はここでモックオブジェクトを初期化します。

```go:main_test.go
func (s *ProbeSuite) SetupTest() {
	s.m = new(mockProbe)
	defaultProbe = s.m
}
```

## テストスイートに対するテスト定義

個々のテストで初期化したモックオブジェクトを使ってテスト対象を実行します。テストも先述の `SetupTest` と同じように `ProbeSuite` のメソッドとして定義します。また、引数の `t *testing.T` は不要となり、代わりにレシーバの `s *ProbeSuite` を使います。

```go:main_test.go
func (s *ProbeSuite) TestSuccess() {
	s.m.On("Probe", "http://example.com").Return(true)

	s.HTTPStatusCode(handleProbe, "GET", "/probe", nil, http.StatusOK)
	s.HTTPBodyContains(handleProbe, "GET", "/probe", nil, "probe_success 1\n")
}

func (s *ProbeSuite) TestFail() {
	s.m.On("Probe", "http://example.com").Return(false)

	s.HTTPStatusCode(handleProbe, "GET", "/probe", nil, http.StatusOK)
	s.HTTPBodyContains(handleProbe, "GET", "/probe", nil, "probe_success 0\n")
}
```

## テストスイートの実行

最後に、テストスイートを実行するために **通常の** テストを書きます。これを忘れるとテストスイート全体が実行されなくなるので注意が必要です。

```go:main_test.go
func TestProbeSuite(t *testing.T) {
	suite.Run(t, new(ProbeSuite))
}
```

# まとめ

最終的に全ての機能を使うと以下のようなコードになりました。

プロダクションコード:

```go:main.go
package main

import (
	"fmt"
	"log"
	"net/http"
)

var defaultProbe Prober = new(probe)

type Prober interface {
	Probe(string) bool
}

type probe struct{}

func (p *probe) Probe(url string) bool {
	res, err := http.Get(url)
	if err != nil {
		return false
	}

	return res.StatusCode == http.StatusOK
}

func handleProbe(w http.ResponseWriter, req *http.Request) {
	var ret int
	if defaultProbe.Probe("http://example.com") {
		ret = 1
	}

	w.WriteHeader(http.StatusOK)
	w.Write([]byte(fmt.Sprintf("probe_success %d\n", ret)))
}

func main() {
	http.HandleFunc("/probe", handleProbe)
	log.Fatal(http.ListenAndServe(":8080", nil))
}
```

テストコード:

```go:main_test.go
package main

import (
	"net/http"
	"testing"

	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/suite"
)

type mockProbe struct {
	mock.Mock
}

func (m *mockProbe) Probe(url string) bool {
	args := m.Called(url)
	return args.Bool(0)
}

type ProbeSuite struct {
	suite.Suite
	m *mockProbe
}

func (s *ProbeSuite) SetupTest() {
	s.m = new(mockProbe)
	defaultProbe = s.m
}

func (s *ProbeSuite) TestSuccess() {
	s.m.On("Probe", "http://example.com").Return(true)

	s.HTTPStatusCode(handleProbe, "GET", "/probe", nil, http.StatusOK)
	s.HTTPBodyContains(handleProbe, "GET", "/probe", nil, "probe_success 1\n")
}

func (s *ProbeSuite) TestFail() {
	s.m.On("Probe", "http://example.com").Return(false)

	s.HTTPStatusCode(handleProbe, "GET", "/probe", nil, http.StatusOK)
	s.HTTPBodyContains(handleProbe, "GET", "/probe", nil, "probe_success 0\n")
}

func TestProbeSuite(t *testing.T) {
	suite.Run(t, new(ProbeSuite))
}
```

今回は基本的な機能を一通り紹介するために全パッケージを使いましたが、`assert` を導入するだけでも十分便利だと思います。

# 参考資料

- [testify のドキュメンテーション](https://pkg.go.dev/github.com/stretchr/testify@v1.6.1)
- [GoMock vs. Testify: Mocking frameworks for Go](https://blog.codecentric.de/2019/07/gomock-vs-testify/)