GoのAPIのテストにおける共通処理

timakin
timakin
Oct 9, 2018 · 20 min read
Image for post
Image for post

GoのAPIを書くとき、参考になるユニットテストの話は非常によく見ます。Table Driven Testをしましょうとか、サブテストの実行とか、そのあたりの話はたくさん書かれています。

また、テストキャッシュなども出てきましたので、ユニットテスト周りの機能・ノウハウは充実していると感じてます。

一方で、httptestを使ってテストサーバーを立て、リクエスト/レスポンスの内容を検証する場合、単一のリクエストを検証する程度のサンプルにとどまっていたり、あまり共通でこういう処理を書いてるよ、みたいなノウハウがなく、自前で一から書くとなると非常に腰が重くなります

事実自分はそういう経験をしました。そういった共通処理は普段internalパッケージの中の、testutilsとしてまとめる、などしています

今回はGoで上記のようなテストを書く場合、どういう共通処理が必要となったかをテーマとして、GoのAPIの、特にハンドラー周りのテスト環境作りについて書こうと思います

ちなみに、僕が最近テストを書くときこんな感じでハンドラーごとのテストを書いています。POSTリクエストを送信し、返却されたデータを検証するテストです。最終形として一旦これを目指して見ることにします。

package sample_test

func TestMain(m *testing.M) {
os.Exit(testutils.IntegrationTestRunner(m))
}

func TestSampleHandler(t *testing.T) {
db, teardown := testutils.GetTestDBConn()
defer teardown()

// この辺りはルーターの初期化処理
mux := chi.NewRouter()
repo := persistence.NewSampleRepository(db)
svc := application.NewSampleService(repo)
dep := &sample.Dependency{svc}
mux = sample.MakeHandler(dep, mux)

// リクエスト/レスポンスの検証
testutils.TryRequest(t, "[Success]", http.MethodPost, "/api/resources", `
{
"name": "Sample"
}`, mux, http.StatusCreated, `{
"id": 1,
"name": "Sample",
"created_at": 1539012526,
"updated_at": 1539012526
}
`)
}

インテグレーションテストで必要になる共通処理

僕はテストするってなったとき、少なくとも外部サービスのAPIが関わらない限り、DB接続とかは直で利用したい派です。

このとき、APIのテストを書くとき必要な処理はいくつかあるのですが、

  • DB起動
  • テーブル作成、Fixture適用、クリーンアップ
  • 時刻固定
  • req/resの検証処理
  • 外部APIのモック

この辺りでしょうか。

大きめのプロジェクトに後から入ってきてメンテナンスしていて、そこにテスト環境がある場合、この辺りが丁寧に整えられていたら楽だし、そうでない場合は地獄となります。

また、「テストを書きましょう!」と言い始めたものの、思ったよりメンテナンスがきついから処理を共通化したくなり、結局必要になるユーティリティ群が膨らむ & その設計に悩む、などはあると思います。

これに加えて、DockerないしデーモンプロセスとしてDBサーバーを立てなければならず、その環境を作らなければなりません。

DBサーバーの起動

テストを実行するときに、すすっとDBを起動する必要があるのですが、もしDockerを使わない場合は、lestrratさんのtest-mysqldを使うとかなり手早く環境を作ることができます。

一方で、「Docker使いたい!テストもDockerで回したいんだ!」という方は、パッケージとして利用する場合はory/dockertestを使うこともできます。かなり機能性が高く、Redisなども起動できるのですが、だいぶコネクションのハンドリング等がラップされていて、Dockerを使っているというよりGoのコードで重厚なテスト環境を作っている感じです。

こちらはサンプルを読んでいただければだいたいうまく行くかと思います。

もしこれらを使わず、かなりプレーンにdocker-composeを使ってテストするという場合は、dockerizeを使ってDBのプロセス起動を待ちつつ、テスト用DBに接続する設定を書かなければなりません。

version: '3'
services:
tester:
build:
context: .
dockerfile: Dockerfile.test
ports:
- "8080:8080"
links:
- sample_api_test_db
entrypoint: [
'dockerize',
'-timeout',
'60s',
'-wait',
'tcp://sample_api_test_db:3306'
]
environment:
TEST_MYSQL_DATABASE: "sample_api_test"
TEST_MYSQL_USER: "root"
TEST_MYSQL_PASSWORD: "password"
TEST_MYSQL_ADDRESS: "sample_api_test_db:3306"
command: go test ./... -v

sample_api_test_db:
image: mysql:5.7
environment:
MYSQL_ROOT_PASSWORD: 'password'
MYSQL_DATABASE: 'sample_api_test'
TZ: UTC
ports:
- "3306:3306"
volumes:
- ./docker/mysql/initdb.d:/docker-entrypoint-initdb.d
command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci

ここら辺の設定を書けばだいたいはうまくDBは起動する…はずです。

テーブル作成、Fixture適用、クリーンアップ

DBが起動したらテスト用のFixtureを入力したり、テーブルを作成したり、クリーンアップ時のTRUNCATE処理を書かなくてはいけません。

今回はMySQLの例に限定していて恐縮ですが、やることはどのDBでも一緒…なのかもです。

詳しく全体的にやっていることを書くと、以下のような処理です。

  • DBの接続情報の初期化
  • 接続情報を返却する
  • Fixtureの入れ込み
  • 各Schemaの探索
  • TRUNCATE

接続をCloseしたり、Truncateする処理は func() 型の値として返却していて、テストごとやTestMainでdeferの中で呼ぶとかしています。

なお、 SetupOptionalFixtures というメソッドがありますが、これはテスト単位で読み込むFixtureを変えたくなるケースがあったりするので、デフォルトで読み込むFixtureとそうでないFixtureを分けるために作ったメソッドです。

package testutils

var dbConn *sql.DB

const schemaDirRelativePathFormat = "%s/../../schema/%s"
const fixturesDirRelativePathFormat = "%s/../../schema/fixtures/%s"

// SetupDBConn ... DBへの接続を持っておく
func SetupDBConn() func() {
c := mysql.Config{
DBName: os.Getenv("TEST_MYSQL_DATABASE"),
User: os.Getenv("TEST_MYSQL_USER"),
Passwd: os.Getenv("TEST_MYSQL_PASSWORD"),
Addr: os.Getenv("TEST_MYSQL_ADDRESS"),
Net: "tcp",
Loc: time.UTC,
ParseTime: true,
AllowNativePasswords: true,
}
db, err := sql.Open("mysql", c.FormatDSN())
if err != nil {
log.Fatalf("Could not connect to mysql: %s", err)
}

dbConn = db

return func() { dbConn.Close() }
}

// GetTestDBConn ... プールしてあるテスト用のDBコネクションを返す
func GetTestDBConn() (*sql.DB, func()) {
if dbConn == nil {
panic("mysql connection is not initialized yet")
}
SetupDefaultFixtures()
return dbConn, func() { truncateTables() }
}


// SetupDefaultFixtures ... 全テストに共通するFixtureのInsert
func SetupDefaultFixtures() {
_, pwd, _, _ := runtime.Caller(0)

defaultFixtureDir := fmt.Sprintf(fixturesDirRelativePathFormat, path.Dir(pwd), "default")
defaultFixturePathes := walkSchema(defaultFixtureDir)
for _, dpath := range defaultFixturePathes {
execSchema(dpath)
}
}

// SetupOptionalFixtures ... テストケースごとに任意に設定するFixtureのInsert
func SetupOptionalFixtures(names []string) {
_, pwd, _, _ := runtime.Caller(0)

optionalFixtureDir := fmt.Sprintf(fixturesDirRelativePathFormat, path.Dir(pwd), "optional")
for _, n := range names {
opath := filepath.Join(optionalFixtureDir, fmt.Sprintf("%s.sql", n))
execSchema(opath)
}
}

func walkSchema(dir string) []string {
files, err := ioutil.ReadDir(dir)
if err != nil {
panic(err)
}

var paths []string
for _, file := range files {
paths = append(paths, filepath.Join(dir, file.Name()))
}

return paths
}

func execSchema(fpath string) {
b, err := ioutil.ReadFile(fpath)
if err != nil {
log.Fatalf("schema reading error: %v", err)
}

queries := strings.Split(string(b), ";")

for _, query := range queries[:len(queries)-1] {
_, err = dbConn.Exec(query)
if err != nil {
log.Fatalf("exec schema error: %v, query: %s", err, query)
}
}
}

func createTablesIfNotExist() {
_, pwd, _, _ := runtime.Caller(0)
schemaPath := fmt.Sprintf(schemaDirRelativePathFormat, path.Dir(pwd), "schema.sql")
execSchema(schemaPath)
}

func truncateTables() {
rows, err := dbConn.Query("SHOW TABLES")
if err != nil {
log.Fatalf("show tables error: %#v", err)
}
defer rows.Close()

for rows.Next() {
var tableName string
err = rows.Scan(&tableName)
if err != nil {
log.Fatalf("show table error: %#v", err)
continue
}

cmds := []string{
"SET FOREIGN_KEY_CHECKS = 0",
fmt.Sprintf("TRUNCATE %s", tableName),
"SET FOREIGN_KEY_CHECKS = 1",
}
for _, cmd := range cmds {
if _, err := dbConn.Exec(cmd); err != nil {
log.Fatalf("truncate error: %#v", err)
continue
}
}
}
}

時刻固定

テストで100%問題になるのは、現在時刻をどう扱うかです。これについてはパッケージを利用したり現在時刻をメソッドの引数として渡すかとか、議論がいくつかあるとは思いますが、まず一旦はtimeパッケージをそのまま使わずに、テスト用にモックできるようにしたパッケージを介して現在時刻を取得するように変更しました。

package library

import "time"

var fakeTime time.Time

func SetFakeTime(t time.Time) {
fakeTime = t
}

func ResetFake() {
fakeTime = time.Time{}
}

func TimeNow() time.Time {
if !fakeTime.IsZero() {
return fakeTime
}
return time.Now()
}

その後、テスト時に現在時刻が必要となる場合は、モック用のFakeTimeを挿入してあげるようにしています。

// MockTimeNow ... 現在時刻をモックする
func MockTimeNow() func() {
library.SetFakeTime(time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local))
return func() { library.ResetFake() }
}

req/resの検証処理

リクエストレスポンスの検証処理をベタ書きでハンドラごとに書いてるサンプルをよく見ますが、個人的には毎度テストサーバーを初期化する処理などを書いているとちょっと手間なので、コールバック形式でラップすることにしました。なお、このやり方はGoogle AppEngineの内部パッケージのテストで使われていたやり方を参考にしています。

流れとしては、

  • httptestのテストサーバーを立てる
  • request bodyがあれば使いながらJSONリクエストを生成し、送信する
  • 返ってきたステータスコードとレスポンスボディを検証する

という感じです。

package testutils

func TryRequest(t *testing.T, desc, method, path, payload string, mux *chi.Mux, wantCode int, wantBody string) {
srv := httptest.NewServer(mux)
defer srv.Close()

req, err := http.NewRequest(method, srv.URL+path, strings.NewReader(payload))
if err != nil {
t.Errorf("%s: generate request: %v", desc, err)
return
}
req.Header.Set("Content-Type", "application/json")

c := http.DefaultClient

resp, err := c.Do(req)
if err != nil {
t.Errorf("%s: http.Get: %v", desc, err)
return
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Errorf("%s: reading body: %v", desc, err)
return
}

if resp.StatusCode != wantCode {
t.Errorf("%s: got HTTP %d, want %d", desc, resp.StatusCode, wantCode)
t.Errorf("response body: %s", string(body))
return
}

if wantBody != "" && string(body) != wantBody {
t.Errorf("%s: got HTTP body %q, want %q", desc, body, wantBody)
return
}
}

外部APIのモック

DB接続に関しては極力モックしたくない派ですが、どうしても外部APIに依存するコードはモックしなければならないので、gomockを使います。あんまり工夫がないですが、共通処理として一旦切り出してみることにします。DBの接続情報と同じで、これもdeferを使ってテスト終了時にFinishする関数を返却します。

// SetupMockCtrl ... interfaceのモック実行用のコントローラー作成
func SetupMockCtrl(t *testing.T) (*gomock.Controller, func()) {
ctrl := gomock.NewController(t)
return ctrl, func() { ctrl.Finish() }
}

TestMain

さて、ここまでで共通処理をだいたい書いたわけですが、冒頭の最終形のTestMainは、こんな感じになってました。

func TestMain(m *testing.M) {
os.Exit(testutils.IntegrationTestRunner(m))
}

IntegrationTestRunnerというところにあらかたTestMainで必要なものを切り出しているのですが、その実装は以下のようになります。

package testutils

// IntegrationTestRunner ... インテグレーションテストで走る共通処理
func IntegrationTestRunner(m *testing.M) int {
shutdown := SetupDBConn()
defer shutdown()

createTablesIfNotExist()
truncateTables()

SetupDefaultFixtures()

resetTimer := MockTimeNow()
defer resetTimer()

return m.Run()
}

ぱっと見そこまで奇抜なことはしていないのですが、今回はじめの方に書いたDBの起動、Fixture適用、時刻固定などをしています。

で、これとreq/resの検証などの機構を併せて、冒頭で書いた最終形のハンドラーのテストをかけるようになりました。

package sample_test

func TestMain(m *testing.M) {
os.Exit(testutils.IntegrationTestRunner(m))
}

func TestSampleHandler(t *testing.T) {
db, teardown := testutils.GetTestDBConn()
defer teardown()

// この辺りはルーターの初期化処理
mux := chi.NewRouter()
repo := persistence.NewSampleRepository(db)
svc := application.NewSampleService(repo)
dep := &sample.Dependency{svc}
mux = sample.MakeHandler(dep, mux)

// リクエスト/レスポンスの検証
testutils.TryRequest(t, "[Success]", http.MethodPost, "/api/resources", `
{
"name": "Sample"
}`, mux, http.StatusCreated, `{
"id": 1,
"name": "Sample",
"created_at": 1539012526,
"updated_at": 1539012526
}
`)
}

まとめ

さて、GoのAPIのテストを書くときに、自分で探していると httptest のサンプルか、重厚なテストがよく出てくるので、0から書くときに必要な共通の処理をだいたいかいてみました。「ぶっちゃけラップしすぎじゃね!?」みたいなところもあるとは思いますが、そうはいってもテストをちゃんと書くときの動機付けになればと思い、まとめてみました。

拡張していったときに必要になりそうなこと、チームではこんな風に実装してるよ!などのご意見あれば教えてください。

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store