ここ数か月ほど仕事で Go 言語を使ってプログラムを書いています。その中で、テストの記述が楽になる 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
というパッケージが用意されているので、これを使いました。
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
は以下のように実装しました。
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
は以下のように実装しました。
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
パッケージを使うとすっきり書けます。
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
を以下のように書き換えてテストしてみます。
func handleProbe(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("probe_success 0\n")) // 常に 0 を返す
}
$ 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 ハンドラに特化したアサーションメソッドもあるので、さらに短く書くこともできます。
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
からパッケージ変数を参照するように変更します。
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
にはモックオブジェクトに必要な機能がいろいろとあるのですが、今回はモックオブジェクトのメソッドが期待された形式で呼び出されていることのチェックと、そのメソッドの戻り値を指定する機能を使います。
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
に失敗したケースもテストできるようになりました。テストケースを追加してみます。
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
として定義しました。
type ProbeSuite struct {
suite.Suite
m *mockProbe
}
SetupTest
ProbeSuite 構造体に対して SetupTest
を定義して前処理を記述します。SetupTest
はテスト1つ1つの実行直前に実行されます (RSpec をご存知の方は before(:each)
をイメージしてください)。今回はここでモックオブジェクトを初期化します。
func (s *ProbeSuite) SetupTest() {
s.m = new(mockProbe)
defaultProbe = s.m
}
テストスイートに対するテスト定義
個々のテストで初期化したモックオブジェクトを使ってテスト対象を実行します。テストも先述の SetupTest
と同じように ProbeSuite
のメソッドとして定義します。また、引数の t *testing.T
は不要となり、代わりにレシーバの s *ProbeSuite
を使います。
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))
}
まとめ
最終的に全ての機能を使うと以下のようなコードになりました。
プロダクションコード:
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))
}
テストコード:
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
を導入するだけでも十分便利だと思います。