12
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

日立グループ OSSAdvent Calendar 2020

Day 9

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

Last updated at Posted at 2020-12-08

ここ数か月ほど仕事で 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 というパッケージが用意されているので、これを使いました。

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 は以下のように実装しました。

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 は以下のように実装しました。

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 パッケージを使うとすっきり書けます。

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 を以下のように書き換えてテストしてみます。

main.go
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 ハンドラに特化したアサーションメソッドもあるので、さらに短く書くこともできます。

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 からパッケージ変数を参照するように変更します。

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 にはモックオブジェクトに必要な機能がいろいろとあるのですが、今回はモックオブジェクトのメソッドが期待された形式で呼び出されていることのチェックと、そのメソッドの戻り値を指定する機能を使います。

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 に失敗したケースもテストできるようになりました。テストケースを追加してみます。

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 として定義しました。

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

SetupTest

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

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

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

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

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")
}

テストスイートの実行

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

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

まとめ

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

プロダクションコード:

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))
}

テストコード:

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 を導入するだけでも十分便利だと思います。

参考資料

12
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?