golangのミニマムREST APIのテスト


コード全体

記事の内容

以前書いた記事(golangでミニマムなREST APIを作る)は、単純なデータを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でテーブルの値を削除するという方法でそれぞれのテストを独立させて実行することができました。