31
12

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 5 years have passed since last update.

Go な WebAPI のテスト&ドキュメントの模索

Last updated at Posted at 2017-12-01

みなさん Go な WebAPI のテスト&ドキュメントどうしてますか。僕はまだ数ヶ月程度の Go 経験しかなく模索しながら、まだまだ彷徨っている状態です。そこで現時点での自分なりのやり方をまとめておこうと思います。こうやったほうが良いよ!というアドバイスがあればぜひお願いします :smile:

サンプルコードはこちらのリポジトリに雑においてあります。

ツール類

今記事では echo + sqlx な構成を例にしています。

使っているもの

  • Go 1.9.x
    • labstack/echo
    • jmoiron/sqlx
    • stretchr/testify/assert
  • API Blueprint
    • aglio
    • dredd
    • drakov

テストライブラリは stretchr/testify/assert だけ使っています。モックなどの機能もありますが、単純に assert としてしか使ってません。

ドキュメントには API Blueprint を使っています。ツール類は定番のものを採用しています。ドキュメントと実装の乖離が一番の懸念点でしたが、そこはドキュメントをベースに E2E テストをしてくれる dredd がある程度は担保してくれています。

使わなかったもの

  • DATA-DOG/go-sqlmock.v1

当初はモデルのユニットテストで SQL をモックするために使っていましたが、今は使っていません。

テスト方針

WebAPI をテストするにあたってどこをどうやってテストするかを決めました。

  • ハンドラはモックを使ってテストをする
  • モデルはテストデータベースを使ってテストする
  • API は E2E で JSON のテストをする

大まかに分けるとこの3つに分けます。

ハンドラのテスト

ハンドラは色んなものを呼ぶのでテストしにくい部分です。ここは Interface を使ってモックにさしかえながらテストをしていきます。

type handler struct {
	UserModel user.UserModelImpl
}

func NewHandler(u user.UserModelImpl) *handler {
	return &handler{u}
}

func (h *handler) GetIndex(c echo.Context) error {
	lists := h.UserModel.FindAll()
	u := &resultLists{
		Users: lists,
	}
	return c.JSON(http.StatusOK, u)
}

Userハンドラのコードです。 handler 構造体をレシーバとするメソッドを定義します。 hander 構造体は UserModelImpl というモデルの Interface を持つようにしています。

こうすることでモデルをモックに差し替えることができます。

e := echo.New()

d := db.DBConnect()
h := users.NewHandler(user.NewUserModel(d))

e.GET("/users", h.GetIndex)

メインのサーバ部分のコードです。Userハンドラにモデルを注入して生成しています。テスト時には user.NewUserModel(d)UserModelImpl Interface を満たしたものに差し替えるイメージです。

type (
	UserModelImpl interface {
	  FindAll() []User
	}

	User struct {
		ID   int    `json:"id" db:"id"`
		Name string `json:"name" db:"name"`
	}

	UserModel struct {
		db *sqlx.DB
	}
)

func NewUserModel(db *sqlx.DB) *UserModel {
	return &UserModel{
		db: db,
	}
}

func (u *UserModel) FindAll() []User {
	users := []User{}
	u.db.Select(&users, "SELECT * FROM users order by id asc")
	return users
}

モデルのコードです。ここでは FindAll() []User を実装していれば UserModelImpl Interface を満たしているということになります。

type (
	UsersModelStub struct{}
)

func (u *UsersModelStub) FindAll() []user.User {
	users := []user.User{}
	users = append(users, user.User{
		ID:   100,
		Name: "foo",
	})
	return users
}

func TestGetIndex(t *testing.T) {
	e := echo.New()
	req := httptest.NewRequest(echo.GET, "/", nil)
	rec := httptest.NewRecorder()
	c := e.NewContext(req, rec)
	c.SetPath("/users")

	u := &UsersModelStub{}
	h := NewHandler(u)

	var userJSON = `{"users":[{"id":100,"name":"foo"}]}`

	if assert.NoError(t, h.GetIndex(c)) {
		assert.Equal(t, http.StatusOK, rec.Code)
		assert.Equal(t, userJSON, rec.Body.String())
	}
}

Userハンドラのテストコードです。モデルのモックを作るためにここで UsersModelStub という構造体を作り FindAll() []User メソッドを定義します。返却する値をテストしたい適当なデータに差し替えます。

そして、テスト時にUserハンドラにモックモデルを注入することで FindAll() の挙動を変えられます。Go のテストは基本的にこの Interface を活用しながらモックでやっていくのが良いのかなと思っています。

モデルのテスト

悩んだのがモデルテストを実際のデータベースを利用するタイプにするか、SQL や DAO をモックにしてテストするかです。最初はユニットテストとして後者のモック形式でやっていましたが、SQL をモックにすると SQL 自体の妥当性や、実データにまつわるテストが曖昧になるなぁと思い、実行コストがかかっても前者の実際のデータベースを利用するタイプにしました。

テスト用のデータベースを作る

Go 1.4 から TestMain が使えるのでこれを利用してテスト前の初期化処理と後処理を作ります。 setup() でテストデータベースの作成から、マイグレーション、テストデータの読み込みを実行し、 clear() でデータベースをまるっと削除しています。

説明用に雑にまとめて書いているので実際は分割して共通化をする感じになると思います。

func setup() {
	err := godotenv.Load("../.env.test")
	if err != nil {
		log.Fatal("Error loading .env file")
	}

	exec.Command("mysql", "-h", os.Getenv("DB_HOST"), "-u", "root", "-e", "create database "+os.Getenv("DB_NAME")).Run()
	exec.Command("goose", "-dir", "../migrations", "mysql", "root@("+os.Getenv("DB_HOST")+")/"+os.Getenv("DB_NAME"), "up").Run()
	cmd := exec.Command("mysql", "-h", os.Getenv("DB_HOST"), "-u", "root", os.Getenv("DB_NAME"))
	readFile := "../seed/test.sql"
	input, err := ioutil.ReadFile(readFile)
	if err != nil {
		panic(err)
	}
	stdin, _ := cmd.StdinPipe()
	stdin.Write(input)
	stdin.Close()

	cmd.Run()
}

func clear() {
	exec.Command("mysql", "-h", os.Getenv("DB_HOST"), "-u", "root", "-e", "drop database "+os.Getenv("DB_NAME")).Run()
}

func TestMain(m *testing.M) {
	setup()
	ret := m.Run()
	clear()

	os.Exit(ret)
}

テストコードは非常にシンプルですね。

func TestFindAll(t *testing.T) {

	d := db.DBConnect()
	um := NewUserModel(d)

	u1 := User{
		ID:   1,
		Name: "zaru",
		Rank: 1,
	}

	u := um.FindAll()

	expect := []User{}
	expect = append(expect, u1)
	assert.Equal(t, expect, u)
}

DATA-DOG/go-sqlmock.v1 を使ったテスト

結果的に採用はしませんでしたが、 DATA-DOG/go-sqlmock.v1 を使ったテストコードも記載しておきます。

import (
	"database/sql"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/jmoiron/sqlx"
	sqlmock "gopkg.in/DATA-DOG/go-sqlmock.v1"
)

func MockDB(t *testing.T) (*sql.DB, sqlmock.Sqlmock, *sqlx.DB) {
	mockDB, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("An error '%s' was not expecting", err)
	}

	sqlxDB := sqlx.NewDb(mockDB, "sqlmock")
	return mockDB, mock, sqlxDB
}

func TestFindAll(t *testing.T) {
	mockDB, mock, sqlxDB := test.MockDB(t)
	defer mockDB.Close()

	u1 := User{ID: 1, Name: "foobar"}
	u2 := User{ID: 2, Name: "barbaz"}

	var cols []string = []string{"id", "name"}
	mock.ExpectQuery("SELECT *").WillReturnRows(sqlmock.NewRows(cols).
		AddRow(u1.ID, u1.Name).
		AddRow(u2.ID, u2.Name))

	um := NewUserModel(sqlxDB)
	u := um.FindAll()

	expect := []User{}
	expect = append(expect, u1, u2)
	assert.Equal(t, expect, u)
}

これは SELECT * が含まれている SQL の結果を全て差し替えるようにしています。他にも正規表現などでマッチングさせたりもできます。

E2Eテスト

最後に WebAPI の出力を E2E でテストします。

API Blueprint はマークダウンで API の仕様を記述できます。 Attributes セクション(MSON)を書くことで自動で JSON Schema に変換してくれます。MSON フルスペックな JSON Schema を表現することはできませんが、最低限レベルのことは書けるので今はこれでやっています。

FORMAT: 1A

# Go API ドキュメント デモ

# Group グループです

## ユーザ [/users]

### 一覧 [GET]

+ Response 200 (application/json; charset=UTF-8)

    + Attributes
      + users (array[User], fixed-type)

## Data Structures

### User
+ id: `100` (number) - User ID
+ name: `zaru` (string) - User name
+ rank: `1` (enum[number]) - User rank
  + 1
  + 2
  + 3
  + Default: 1

Dredd のフックを使う

モデルのテストと同様にテスト用のデータベースを用意する必要があります。さいわい Dredd にはフックが用意されているのでそれを利用します。フック用の言語は Go, JavaScript, Ruby など一通り用意されています。

ここでは JavaScript で書いてみます。

beforeAll() でデータベースの作成、マイグレーションなどを実行します。そして afterAll() でデータベースの削除をします。モデルと同じノリですね。

ハマったのは、Dredd はテスト時にテスト対象のサーバを起動してくれるのですが、テスト終了後にプロセスを kill してくれないという現象です。ここでは lsof コマンドを使って手動でプロセスを kill しています…。

const hooks = require('hooks')
const execSync = require('child_process').execSync

const testDBHost = 'db'
const testDBName = 'sample_test'
const testAppPort = '1323'

hooks.beforeAll(function (transactions, done) {
  hooks.log('before all')

  let result = execSync('mysql -h ' + testDBHost + ' -u root -e "create database ' + testDBName + '"').toString()
  hooks.log('create database: ' + result)

  result = execSync('goose -dir ./migrations mysql "root@(' + testDBHost + ')/' + testDBName + '" up').toString()
  hooks.log('migration: ' + result)

  result = execSync('mysql -h ' + testDBHost + ' -u root ' + testDBName + ' < ./seed/test.sql').toString()
  hooks.log('seed: ' + result)

  done()
})

hooks.beforeEach(function (transaction, done) {
  hooks.log('before each')
  done()
})

hooks.afterEach(function (transaction, done) {
  hooks.log('after each')
  done()
})

hooks.afterAll(function (transactions, done) {
  hooks.log('after all')

  let result = execSync('mysql -h ' + testDBHost + ' -u root -e "drop database ' + testDBName + '"').toString()
  hooks.log('drop database: ' + result)

  result = execSync('kill `lsof -ti tcp:' + testAppPort + '`')
  hooks.log('kill app: ' + result)

  done()
})

まとめ

以上で、ハンドラなどのモックを使ったテスト、実データベースを使ったモデル・E2E テストができるようになりました。また、API Blueprint でドキュメントと実装の乖離をある程度防げるようになりました。ここまで来るのにすごい苦労をしたような気がしますが、それでも Go は書いてて楽しい気がしています。

俺はこうやってるよ〜という意見お待ちしております。ありがとうございました。

31
12
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
31
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?