Thou Shalt Write Tests

Testify

前回、いくつか機能を実装しました。(前回はこちら)

前回のコードを使って、今回はテストを実装します。

Go 言語のテスト

Go 言語でのテストコードの記載方法の基本的なところは以下となります。

  • テスト対象コードがあるパッケージと同じパッケージにファイルを作成する
  • テスト対象のコード名 + _test.go というファイル名でファイルを作成する
  • testing パッケージを import する
  • テストコードの関数名は、Test から始める
  • テストコードの関数の引数には、 *testing.T を用意する

main パッケージの main.go にある GetName 関数をテストする場合は、 ファイル名は main_test.go となりファイルの内容は以下のようになります。

package main

import (
  "testing"
)

func TestGetName(t *testing.T) {
  // テスト内容
}

これを踏まえ Web API のテストコードについて確認します。

Web API のテスト

実装に Gin Web Framework を使用しているので、 Testing を確認していきます。

以下のコードが Testing のページに記載されています。

package main

import (
	"net/http"
	"net/http/httptest"
	"testing"
	"github.com/stretchr/testify/assert"
)

func TestPingRoute(t *testing.T) {
	router := setupRouter()

	w := httptest.NewRecorder()
	req, _ := http.NewRequest("GET", "/ping", nil)
	router.ServeHTTP(w, req)

	assert.Equal(t, 200, w.Code)
	assert.Equal(t, "pong", w.Body.String())
}

このコードをまず確認します。

router は、 gin.Engine インスタンスのことなので、前回の gin.Default() で作成しルーティングの設定をしたものとなります。

r := gin.Default()

r.GET("/ping", func(c *gin.Context) {

前回のコードで言うと上記の rrouter と同じものとなります。 この r に設定していった処理をテスト対象にできます。

次に w についてです。

w := httptest.NewRecorder()

whttptest.ResponseRecorder のインスタンスになります。

これは、 http のレスポンスを記録するものとなり、ここに記録された値を確認することでテストの結果を確認できるようになります。

次に、 req の行です。

req, _ := http.NewRequest("GET", "/ping", nil)

ここでは、テストしたい URL, Parameter などを指定します。 上の例の場合、 GET/ping にリクエストしてテストするということになります。

次の行で、 req と対応する処理を実行しています。

router.ServeHTTP(w, req)

その次の行からは、結果の確認をしています。 結果の確認は、 Testify を使用して行っています。

assert.Equal(t, 200, w.Code)
assert.Equal(t, "pong", w.Body.String())

上の行でレスポンスコードを確認し、下の行でレスポンスの Body を確認しています。

テストの実装準備

次にテストを実装していきたいのですが、前回書いたコードのままだとテストが実装しづらいためテストしやすいコードに変更していきます。

先程のテストコードの場合、 gin.Engine インスタンスを取得してそれを使用してテストを行っていました。 前回書いたコードのままだと gin.Engine がテストコードから取得できないので取得できるように変更します。

gin.Engine に必要な処理を追加する部分までをまとめて setupRouter 関数に切り出します。

func setupRouter() *gin.Engine {
	r := gin.Default()

	r.GET("/ping", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{
			"message": "pong",
		})
	})

	r.GET("/tasks", func(c *gin.Context) {
	// 〜 以下省略

	return r
}

main 関数は以下のようになります。

func main() {
	r := setupRouter()
	r.Run()
}

これで準備ができました。

テストの実装

実際にテストを実装していきます。

テストコードを記載するファイル main_test.go を作成します。(テスト対象コードが main.go のため)

まず先程のコードを貼り付けます。

package main

import (
	"net/http"
	"net/http/httptest"
	"testing"
	"github.com/stretchr/testify/assert"
)

func TestPingRoute(t *testing.T) {
	router := setupRouter()

	w := httptest.NewRecorder()
	req, _ := http.NewRequest("GET", "/ping", nil)
	router.ServeHTTP(w, req)

	assert.Equal(t, 200, w.Code)
	assert.Equal(t, "pong", w.Body.String())
}

前回実装したコードにも /ping があるので、そのままテストを実行します。

以下のコマンドを main_test.go があるディレクトリで実行します。

go test

テストは失敗しました。

GIN] 2021/**/** - **:**:** | 200 |      38.874µs |                 | GET      "/ping"
--- FAIL: TestPingRoute (0.00s)
    main_test.go:19:
                Error Trace:    main_test.go:19
                Error:          Not equal:
                                expected: "pong"
                                actual  : "{\"message\":\"pong\"}"

                                Diff:
                                --- Expected
                                +++ Actual
                                @@ -1 +1 @@
                                -pong
                                +{"message":"pong"}
                Test:           TestPingRoute
FAIL
exit status 1
FAIL    go-web  0.018s

前回実装した /ping のレスポンスがテストコードで指定している期待値と違う値になっているからです。 テストコードのレスポンス Body を確認している箇所を修正します。

assert.Equal(t, "{\"message\":\"pong\"}", w.Body.String())

再度テストを実行すると成功します。

[GIN] 2021/**/** - **:**:** | 200 |       40.01µs |                 | GET      "/ping"
PASS
ok      go-web  0.018s

これで /ping の正常系のテストが実装できました。 次から各機能のテストを実装していきます。

各機能のテストを実装

タスク情報を登録する API からテストを実装していきます。

タスク情報を登録する API は POST /task で呼び出すので先程の /ping のテストコードのテスト対象を変更した形でまず書いてみます。

func TestPostTaskRoute(t *testing.T) { // 関数名を変更
	router := setupRouter()

	w := httptest.NewRecorder()
	req, _ := http.NewRequest("POST", "/task", nil) // リクエスト先を変更
	router.ServeHTTP(w, req)

	assert.Equal(t, 200, w.Code)
	assert.Equal(t, "{\"message\":\"pong\"}", w.Body.String())
}

テストを実行します。

[GIN] 2021/**/** - **:**:** | 400 |       9.437µs |                 | POST     "/task"
--- FAIL: TestPostTaskRoute (0.00s)
    main_test.go:29:
                Error Trace:    main_test.go:29
                Error:          Not equal:
                                expected: 200
                                actual  : 400
                Test:           TestPostTaskRoute
    main_test.go:30:
                Error Trace:    main_test.go:30
                Error:          Not equal:
                                expected: "{\"message\":\"pong\"}"
                                actual  : "{\"error\":\"missing form body\"}"

                                Diff:
                                --- Expected
                                +++ Actual
                                @@ -1 +1 @@
                                -{"message":"pong"}
                                +{"error":"missing form body"}
                Test:           TestPostTaskRoute
FAIL
exit status 1
FAIL    go-web  0.017s

失敗します。

テストコードに記載してある assert の箇所 2 つとも失敗しています。

1 つ目の失敗はレスポンスコードが違うというエラーです。

                Error Trace:    main_test.go:29
                Error:          Not equal:
                                expected: 200
                                actual  : 400
                Test:           TestPostTaskRoute

2 つ目の失敗はレスポンス Body が違うというエラーです。

                Error Trace:    main_test.go:30
                Error:          Not equal:
                                expected: "{\"message\":\"pong\"}"
                                actual  : "{\"error\":\"missing form body\"}"

                                Diff:
                                --- Expected
                                +++ Actual
                                @@ -1 +1 @@
                                -{"message":"pong"}
                                +{"error":"missing form body"}
                Test:           TestPostTaskRoute

タスク情報を登録する API は、リクエスト Body に登録するタスク情報を指定する必要があります。

先程書いたテストコードでは、リクエスト Body には何も指定していないためエラーとなりました。 API の動きとしては問題ないので、リクエスト Body に何も指定されていない場合のテストケースとしてテストコードを修正します。

結果の確認部分のみ修正します。

assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Equal(t, "{\"error\":\"missing form body\"}", w.Body.String())

タスク情報を登録する API は、必要な情報がリクエスト Body に無い場合 http.StatusBadRequest をレスポンスコードとして返すようになっているため上記のように修正しました。 レスポンス内容については前回実装したコードからは何が指定されるかわからないのですが、ここではとりあえず上記のようにしておきます。

テストを再実行すると成功します。

これで、タスク情報を登録する API のリクエスト Body が指定されていない場合のテストケースが実装できました。

次に登録が成功するケースを実装します。

登録を成功させるには、リクエスト Body を指定する必要があります。 この API は、JSON 形式でリクエスト Body を指定する必要があるので以下の形で指定します。

jsonBody := []byte(`{"content":"hoge","done":false}`)
req, _ := http.NewRequest("POST", "/task", bytes.NewBuffer(jsonBody))
req.Header.Add("content-type", "application/json")

リクエスト Body と Header を指定しました。(http.NewRequest の第三引数に Body が指定できます)

リクエスト Body を指定したので、処理が成功することを期待しレスポンスコードの確認を変更します。

assert.Equal(t, http.StatusOK, w.Code)

レスポンス Body には登録したデータが入るので、レスポンス body の確認を以下のように変更します。

var registered Task
err := json.Unmarshal(w.Body.Bytes(), &registered)
if assert.NoError(t, err) { // Unmarshalがエラーとなる場合はjson形式でない場合なので、エラーでないことを確認している
  assert.Equal(t, "hoge", registered.Content)
  assert.Equal(t, false, registered.Done)
  assert.NotEmpty(t, registered.ID)
}

レスポンス Body からは JSON で登録したデータが取得できるため、JSON をタスク情報の struct にマッピングし各値を確認するようにしました。

ID については乱数が設定されるため値自体の確認はせずに値が設定されていることのみ確認するようにしています。

再度テストを実行すると成功します。

[GIN] 2021/**/** - **:**:** | 200 |     189.123µs |                 | POST     "/task"
PASS
ok      go-web  0.018s

これでタスク情報を登録する API のテストケースができました。

上記では、失敗するケースを変更して成功ケースを試しましたが、実際には別ケースとして実装しておく必要があります。 別ケースとして実装する場合は、テストの関数を別に分けてもよいですが、以下のように 1 つの関数内でケースを分ける実装方法もあります。

  • 関数でケースを分ける場合
func TestPostTaskRouteFail(t *testing.T) {
  // テスト内容
}
func TestPostTaskRouteSuccess(t *testing.T) {
  // テスト内容
}
  • 関数内でケースを分ける場合
func TestPostTaskRoute(t *testing.T) {
	t.Run("タスク情報を登録する リクエストBody無しのためエラー", func(t *testing.T) {
    // テスト内容
	})

	t.Run("タスク情報を登録する 正常に登録できる", func(t *testing.T)
    // テスト内容
  })
}

続けてタスク情報を取得する API のテストを実装します。

タスク情報を取得する API は、URL に ID を指定して取得します。

登録した ID を URL に追加します。

req, _ = http.NewRequest("GET", fmt.Sprintf("/task/%s", registered.ID), nil)

結果の確認は、登録時と同様にレスポンスを確認します。

var getted Task
err = json.Unmarshal(w.Body.Bytes(), &getted)
if assert.NoError(t, err) {
  assert.Equal(t, registered.Content, getted.Content)
  assert.Equal(t, registered.Done, getted.Done)
  assert.Equal(t, registered.ID, getted.ID)
}

テストを実行し確認します。

登録/取得の API のテストを実装/確認してきました。

ここまでの内容を利用すれば、他の API もテストを実装できるので他の API については割愛させて頂きます。

また他の API のテストについては、簡易版ではありますがソースをこちらに置いておくので、興味のある方は確認お願いします。

まとめ

テストを実装しやすい形にコードを変更し、Go 言語にある httptest 、 Testify を利用してテストを行いました。

Go 言語には他にもベンチマークを簡単に記載できる仕組みや、mock を使ったテストを行う仕組みなどもあります。

最後まで読んで頂きありがとうございました!