コード全体
記事の内容
以前書いた記事([golangでミニマムなREST APIを作る]
(https://qiita.com/ngplus6655/items/d4b316150c44452a289b))は、単純なデータをGoで人気のORマッパーであるGORMを使ってMySQLを操作しREST APIとして配信するという内容でした。
今回はその続きで、テストを書いてみるというものです。
何をテストするのか
テストといっても、E2Eテストなのか単体テストなのか。DBにデータを入れるか、モックを使うのか。
色々と迷うところですが、学習目的なので時間をかけて着実に各HandlerFuncにたいして実際にDBにデータを入れた単体テストでカバレッジを上げていきたい。
GORMについて
GORMでテストを書くときRailsのActive Recordのように簡単にはいかないようです。テスト周りはrspecのように環境が整備されていない。
具体的には、テスト終了後にDBをcleanにする仕組みが必要そうです。
環境
シンプルなものにしたいのでDBはローカルのMySQLをそのまま使います。
Railsでは、テストコードのDB操作を内部でトランザクションとして実装して終了時にロールバックするようになっているらしい。Gormでもトランザクション処理は使えるが、真似しようとするとかなり複雑化してしまうため、テスト用に新たにデータベースを用意しテスト後にテーブルのデータを削除することにした。
参考: https://h3poteto.hatenablog.com/entry/2015/10/27/004958
DB接続
まずは、接続するDBを変更できるようにします。Database構造体を作って接続するメソッドとして再定義してみました。ごちゃごちゃしてきたので同じパッケージですが切り出します。
package main
import (
"github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/mysql"
"log"
)
type Database struct{
Service string
User string
Pass string
DatabaseName string
}
func (d Database) connect() (*gorm.DB, error) {
connStr := d.User + ":" + d.Pass + "@/" + d.DatabaseName + "?charset=utf8&parseTime=True&loc=Local"
db, err := gorm.Open(d.Service, connStr)
return db, err
}
func (d Database) init() *gorm.DB {
db, err := d.connect()
if err != nil {
log.Fatalln("データベースの接続に失敗しました。")
}
return db
各ハンドラから呼び出す用のDB構造体インスタンス化と接続を行う関数も定義します。環境変数から読み込むようにもしましょう。
var (
dbservice = "mysql"
dbuser = os.Getenv("MINIMUM_APP_DATABASE_USER")
dbpass = os.Getenv("MINIMUM_APP_DATABASE_PASS")
dbname = os.Getenv("MINIMUM_APP_DEV_DATABASE_NAME")
)
func DBConn() *gorm.DB {
d := Database{
Service: dbservice,
User: dbuser,
Pass: dbpass,
DatabaseName: dbname,
}
db := d.init()
return db
}
//各ハンドラを変更
- db := initDb()
+ db := DBConn()
DBにテストデータをINSERT
Test用のDBに接続する関数と、必要なTest関数の中でTestDataを作る関数です。
func connTestDB() *gorm.DB {
dbname = os.Getenv("MINIMUM_APP_TEST_DATABASE_NAME")
d := Database{
Service: dbservice,
User: dbuser,
Pass: dbpass,
DatabaseName: dbname,
}
db, err := d.connect()
if err != nil {
log.Fatalln("データベースの接続に失敗しました。")
}
db.AutoMigrate(&Article{})
return db
}
func setFixture() *gorm.DB {
db := connTestDB()
articles := Articles{
Article{Title: "test1", Desc: "test description1", Content: "test content1"},
Article{Title: "test2", Desc: "test description2", Content: "test content2"},
Article{Title: "test1", Desc: "test description3", Content: "test content3"},
}
for _, article := range articles {
db.Create(&article)
}
return db
}
データを削除する
データを削除する関数です。上の関数とセットで使います。
func CleanUpFixture(db *gorm.DB) {
db.Exec("TRUNCATE TABLE articles;")
db.Close()
}
/all GET
早速、returnAllArticles関数にたいしてテストを書いてみましょう。
func TestReturnAllArticles(t *testing.T) {
db := setFixture()
defer cleanUpFixture(db)
req := httptest.NewRequest("GET", "/all", nil)
w := httptest.NewRecorder()
returnAllArticles(w, req)
resp := w.Result()
resBodyByte, _ := ioutil.ReadAll(resp.Body)
var articles Articles
json.Unmarshal(resBodyByte, &articles)
assert.Equal(t, resp.StatusCode, 200, "StatusCodeの値が正しくありません。")
assert.Equal(t, "test1", articles[0].Title, "returnAllArticlesが正しい値を返しませんでした。")
assert.Equal(t, "test description2", articles[1].Desc, "returnAllArticlesが正しい値を返しませんでした。")
assert.Equal(t, "test content3", articles[2].Content, "returnAllArticlesが正しい値を返しませんでした。")
}
先ほど作成した、SetFixture関数とCleanUpFixtureを作ってデータを管理しています。
後は、httptestパッケージでリクエストとレスポンスレコーダーを作って帰ってくるデータを検証しています。サーバーから帰ってくる値はjSON形式ですが、goで扱いやすいようにjson.Unmarshalを使ってパース下値を比較に使っています。
/article/1 GET
続いてreturnSingleArticle関数のテストです。
func TestReturnSingleArticle(t *testing.T) {
db := setFixture()
defer cleanUpFixture(db)
router := mux.NewRouter()
router.HandleFunc("/article/{id}", returnSingleArticle)
req := httptest.NewRequest("GET", "/article/1", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
resp := w.Result()
resBodyByte, _ := ioutil.ReadAll(resp.Body)
var article Article
json.Unmarshal(resBodyByte, &article)
assert.Equal(t, resp.StatusCode, 200, "StatusCodeの値が正しくありません。")
assert.Equal(t, "test1", article.Title, "returnAllArticlesが正しい値を返しませんでした。")
assert.Equal(t, "test description1", article.Desc, "returnAllArticlesが正しい値を返しませんでした。")
assert.Equal(t, "test content1", article.Content, "returnAllArticlesが正しい値を返しませんでした。")
}
先ほどと変わっているのは、どのarticleを持ってくるかをパスパラメータで指定しているためURLを解析する必要があったことです。returnSingleArticle(w, req)という形で結果を記録しようとすると"/article/1"から1のid部分を解析することができず空のMapがかえってくるのです。
回避するには一旦gorilla/muxのルータを作って登録してから、ServeHTTPで結果を記録します。参考記事
httpパッケージの諸々を整理
主に自分のためにhttpパッケージのhandlerやらhandleFunc, handlerFunc, mux, 自分でfunc(ResponseWriter, *Request)を満たすように実装した関数の関係をここで整理したい。
-
handlerとは
type Handler interface { ServeHTTP(ResponseWriter, *Request) }
-
ServeMuxというのはURLパターンからマッチするHandlerを呼び出す辞書。
-
DefaultServeMuxというのがListenAndServeにnilを渡したときに使われる。
-
HandleFuncを使うとServeMuxにHandlerFuncをルーティングできる。
-
このHandlerFuncはなんだ?->func(ResponseWriter, *Request)の型。
-
自分で作った関数がルーティング可能になるのはhttp.HandleFuncの内部でfunc(w, r)をhttp.HandlerFuncに型変換してくれるから。
-
ServeHTTPはふたつある。HandlerFuncのメソッドとServeMuxのメソッド
-
前者の説明 -> ServeHTTP calls f(w, r).
-
後者の説明 -> ServeHTTP dispatches the request to the handler whose pattern most closely matches the request URL.
先ほどは、gorilla/muxでルートを作りました。ServeMux.ServeHTTPはリクエストのURLのパターンに最も近いhandlerに処理を送り込むため結果を取り出せたということかな( ^ω^)・・・
/article POST
リソースのPOST、createNewArticle関数にテストを書きました。
func TestCreateNewArticle(t *testing.T) {
db := connTestDB()
defer cleanUpFixture(db)
reqBody := strings.NewReader(`{"Title":"PostTest","desc":"testing POST methods","content":"Hello world!!"}`)
req := httptest.NewRequest("POST", "/article", reqBody)
w := httptest.NewRecorder()
createNewArticle(w, req)
resp := w.Result()
assert.Equal(t, resp.StatusCode, 200, "StatusCodeの値が正しくありません。")
var article Article
db.First(&article)
assert.Equal(t, "PostTest", article.Title, "Articleのタイトルの値が不正です")
}
ここまで来たらあとは速そうです。ここは特に難しい部分はありませんでした。今まで通りhttptestパッケージでリクエストを作ります。
値がしっかりPOSTされているかは直接データベースを読んでいます。
/ariticle/1 PUT
こちらも、いままでの組み合わせで対処できました
func TestUpdateArticle(t *testing.T) {
db := setFixture()
defer cleanUpFixture(db)
router := mux.NewRouter()
router.HandleFunc("/article/{id}", updateArticle)
reqBody := strings.NewReader(`{"Title":"PutTest","desc":"testing PUT methods","content":"UPDATED!!"}`)
req := httptest.NewRequest("PUT", "/article/1", reqBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
resp := w.Result()
assert.Equal(t, resp.StatusCode, 200, "StatusCodeの値が正しくありません。")
var article Article
db.Where("id = ?", 1).First(&article)
assert.Equal(t, "UPDATED!!", article.Content, "Articleのタイトルの値が不正です")
}
/article/1 DELETE
最後にDeleteです。
func TestDeleteArticle(t *testing.T) {
db := setFixture()
defer cleanUpFixture(db)
router := mux.NewRouter()
router.HandleFunc("/article/{id}", deleteArticle)
req := httptest.NewRequest("DELETE", "/article/1", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
resp := w.Result()
assert.Equal(t, resp.StatusCode, 200, "StatusCodeの値が正しくありません。")
var article Article
db.Where("id = ?", 1).First(&article)
assert.Equal(t, uint(0), article.ID, "ArticleのIDの値が不正です")
}
IDが1のarticleは削除されているはずなので、 db.Where("id = ?", 1).First(&article)をしても、Article型のゼロ値が帰ってくることをテストします。
実行
最後にテストを実行してみます。
go test
Endpoint Hit: returnAllArticles
called returnSingleArticle
called createNewArticle
called updateAtricle
called deleteAtricle
PASS
ok api_example 0.337s
それぞれのメソッドが呼び出されていること、テストにPASSしていることが確認できました。
まとめ
MySQLに実際にデータを入れてテスト後にTRUNCATEでテーブルの値を削除するという方法でそれぞれのテストを独立させて実行することができました。