GAE/Go+ginでHTTPリクエストも含めてEnd to Endなテストをする話

この記事は最終更新日から3年以上が経過しています。

はじめに

GAE/Goとginフレームワークを使って
・JSONのPOSTを受け取ったら
  ・ginのBindJSONで構造体を作成する
  ・datastoreに対してその構造体を利用してPutする

・GETを受け取ったらその情報を取得する
  ・GETリクエスト時にくっついているパラメータをからEntityにアクセスするためのKeyIDを取得
  ・そのKeyIDを用いてdatastoreから情報を取得する。
  ・JSONとして値を返す
という簡単なWebアプリを作ってテストしていました。


実は前回の記事でもGAE/Goのテストに関する記事を書いていたのですが、
(参照: http://qiita.com/CST_negi/items/f2fe571c5e64291d5157 )
これだと、上記例で言うところの
「・datastoreに対してその構造体を利用してPutする」
「そのKeyIDを用いてdatastoreから情報を取得する。」
しかテストできていません。

なので、今回はPOSTやGETなどのHTTPリクエストに関わる部分も含めて(End to End)入出力を検証するテストに関する知見を共有します。
ちなみに結果から話すと、だいぶ前回の記事と書き方が違ったし参考になる情報もそこまでネットに落ちていなかったので細かいところから丁寧に書いていこうと思います。

最後に注意点として自己流の書き方をしているので、こうした方が良いよというのがありましたらコメントや編集リクエストを送っていただけると嬉しいです。

テスト方法の概要

①上に書いたようなHTTPリクエストによって様々な動作をするWebアプリを作る
②goapp serve でWebアプリを動かす
③テストファイルで、localhost:8080に対してHTTPリクエストを行うようなテストを書く
④レスポンスコードから正常にテストが終了したかを検証する。
⑤go test (※goapp testではない)でテストファイルを実行し結果を確認する

基本的にこれらを元に話をしていきます。
それとオマケとして、いろいろ試して"うまくいかなかった"方法も書きます。

①Webアプリを作る

作ったのはTodoを表示するアプリです。
localhost:8080/ にアクセスすると…
スクリーンショット 2016-06-30 11.08.52.png

このようなリンクが出てきます。これはdatastoreに既にあるTodoのEntityを列挙したもので、リンクをクリックすると
スクリーンショット 2016-06-30 11.10.18.png

のようにTodoの「タイトル(Title)」と「内容(Description)」がJSONで返されるだけの簡単なアプリです。

またCurlコマンドで適当なPOSTをすると、その内容がdatastoreにEntityとなって格納されます。

コードです。

server.go
package mygae

import (
    "ds" //自作のライブラリ群(後述します)
    "html/template"
    "log"
    "net/http"

    "github.com/gin-gonic/gin"
)
//初期化
func init() {
    http.Handle("/", GetMainEngine())
}

func GetMainEngine() *gin.Engine {
    LoadTemplaytes()
    server := gin.Default()
    server.LoadHTMLGlob("templates/*")
    server.GET("/", Listing)
    server.GET("/get/:id", GetTodo)
    server.POST("/post/", MakeTodo)
    return server
}
//localhost:8080/にアクセス(GETリクエスト)した時の処理。
//Datastoreに存在する全てのTodoを列挙し、そのStringIDを抽出。
//それをHTMLテンプレートに流し込みリンクとして列挙するようにしています。
func Listing(g *gin.Context) {
    keys, err := DataStoreManager.ListingTodoData(g)
    if err != nil {
        g.String(500, "Get Error at Listing %v", err)
        return
    }
    ids := make([]string, len(keys))
    for i, v := range keys {
        ids[i] = v.StringID()
    }
    g.HTML(200, "t.html", gin.H{
        "KeyIDs": ids,
    })
}
//localhost:8080/get/(ID数値)にアクセス(GETリクエスト)した時の処理
//IDを抽出し、それをKeyとしてTodoのEntityを構造体として持ってくる。
//最後にそれをJSONで返している。
func GetTodo(g *gin.Context) {
    ids := g.Param("id")
    todo, err := DataStoreManager.GetTodoList(ids, g)
    if err != nil {
        g.String(500, "Get Error at GetTodo %v", err)
    }
    g.JSON(200, todo)
}

//localhost:8080/post/にPOSTがあった時に行われる処理
//curl -X POST -H 'Content-Type: application/json' -d '{"Title" : "Title1" , "Description" : "いろいろやる"}' localhost:8080/post/
//↑のような例をやると正しくDatastoreに格納される。
func MakeTodo(g *gin.Context) {
    var todo DataStoreManager.Todo
    if g.BindJSON(&todo) == nil {
        if err := DataStoreManager.PutToDoList(todo, g); err != nil {
            g.String(500, "Put Error %v", err)
            return
        }
        g.String(200, "PutSuccess")
    } else {
        g.String(500, "Cant Make JSON")
    }
}
//HTMLテンプレートをロードする。
//データを列挙するページでのみ使用している。
func LoadTemplaytes() {
    baseTemplate, err := template.New("root").Parse("t.html")
    template.Must(todoTemplate, err2)
}

dsライブラリ群の中身は以下

ToDoListManager.go
package DataStoreManager

import (
    "log"
    "github.com/gin-gonic/gin"
    "google.golang.org/appengine"
    "google.golang.org/appengine/datastore"
)

type (
    //jsonとdatastoreでの名前設定。そしてbindJSONするときに必ず値が入るようにrequiredを設定
    Todo struct {
        Title       string `datastore:"Title" json:"Title" binding:"required"`
        Description string `datastore:"Description" json:"Description" binding:"required"`
    }
)
//datastoreからTodoKindの全てのKeyを返す。
//最終的にKeyのIDのみあれば良いのでKeyを返却している。
//Entityを全て取得するより早いので公式にもKeyのみであればKeysOnlyを使うように推奨されている
func ListingTodoData(g *gin.Context) ([]*datastore.Key, error) {
    c := appengine.NewContext(g.Request)
    query := datastore.NewQuery("Todo").KeysOnly()
    var keys = []*datastore.Key{}
    var err error

    if keys, err = query.GetAll(c, nil); err != nil {
        return nil, err
    }
    return keys, nil
}
//todoIDの文字列からKeyを作成。
//そのKeyからEntityを引っ張ってきてそれを構造体として返す。
func GetTodoList(todoID string, g *gin.Context) (*Todo, error) {
    c := appengine.NewContext(g.Request)

    todoKey := datastore.NewKey(c, "Todo", todoID, 0, nil)
    todoData := Todo{}
    if err := datastore.Get(c, todoKey, &todoData); err != nil {
        return nil, err
    }
    return &todoData, nil
}

//引数の構造体をそのままdatastoreにPutする。
//その際KeyはAllocateID関数で作成されたUniqueなIDを用いて作成されており被ることはない。
func PutToDoList(todo Todo, g *gin.Context) error {
    c := appengine.NewContext(g.Request)
    ids, _ := AllocateID(c, "Todo")
    todoKey := datastore.NewKey(c, "Todo", ids, 0, nil)

    if _, err := datastore.Put(c, todoKey, &todo); err != nil {
        log.Fatalf("Error Occured while Putting data: %v", err)
        return err
    }
    return nil
}
//UniqueなIDを文字列で返してくれるいいやつ
func AllocateID(c context.Context, kind string) (string, error) {
    id, _, err := datastore.AllocateIDs(c, kind, nil, 1)
    return fmt.Sprint(id), err
}

②goapp serve でWebアプリを動かす

cd server.go,app.yamlファイルがあるフォルダ
goapp serve
で動く

③④テストファイルを書く

前提としてテストファイルとして認識されるために気をつけることは
前回の記事で書いたので参照してください。意外にクセがあります。

server_test.go
package mygae
import (
    "bytes"
    "log"
    "net/http"
    "net/http/httptest"
    "testing"
    "time"
)
//Listing機能をテストする
func TestServerListing(t *testing.T) {
    httptest.NewServer(GetMainEngine())
    testListing(t)
}
//データを引っ張ってくるのをテストする
func TestServerGet(t *testing.T) {
    testGet(t)
}
//POSTした結果をテストする
func TestServerPOST(t *testing.T) {
    testPost(t)
}
//Listingをテストする。goapp serveで実際のサーバは8080Portで既に立っているためそこにGETを送る。
func testListing(t *testing.T) {
    resp, err := http.Get("http://localhost:8080/")
    if err != nil {
        t.Fatalf("geterror %v", err)
        return
    }
    // 関数を抜ける際に必ずresponseをcloseするようにdeferでcloseを呼ぶ
    defer resp.Body.Close()
    if resp.StatusCode != 200 {
        t.Fatalf("%v", resp.StatusCode)
    }
}
//GETも同様にTodoを取得するためにGETリクエストを適当なURLに行う
//testIDCaseはIDとテストが通るか(通ると想定するケースはTrue)をMapとして持っておく。
//期待した結果ともし違う場合はt.Fatalfメソッドでテストを失敗として処理する。
func testGet(t *testing.T) {
    testIDCase := map[string]bool{"1": true, "2": true, "n": false}
    for key, value := range testIDCase {
        log.Println(key, value)
        resp, _ := http.Get("http://localhost:8080/get/" + key)

        if resp.StatusCode == 200 != value {
            t.Fatalf("%v", resp.StatusCode)
        }
        defer resp.Body.Close()
    }
}
//POSTもGETTodoのGETがPOSTになっただけでやることはほとんど変わらない。
//通ると想定するケースはTrue、通らないと想定するケースはfalseを各String値に紐付けてテストを行う。
func testPost(t *testing.T) {
    strs := map[string]bool{`{"Title" : "Title1" , "Description" : "いろいろやる"}`: true,
        `{"Title" : "Title1" , "Description2" : "いろいろやる"}`:                         false,
        `{"Title2" : "Title1" , "Description" : "いろいろやる"}`:                         false,
        `{"Title" : "gajgapgndfsngdsgkhnpsdknhsdgkhmkgsdhn" , "Description" : ""}`: false,
        `{"Title" : "" , "Description" : "いろいろやる"}`:                                false}

    for key, value := range strs {
        log.Println(key, value)
        url := "http://localhost:8080/post/"

        req, err := http.NewRequest(
            "POST",
            url,
            bytes.NewBuffer([]byte(key)),
        )

        if err != nil {
            t.Fatalf("Error Occured %v", err)
        }

        client := &http.Client{Timeout: time.Duration(15 * time.Second)}
        resp, _ := client.Do(req)
        if resp.StatusCode == 200 != value {
            t.Fatalf("Error Occured: key is %v", key)
        }
        defer resp.Body.Close()
    }
}

⑤go test (※goapp testではない)でテストファイルを実行し結果を確認する

goapp testではなくgo testする。
テストが通れば

Terminal.bash
1 true
2 true
n false
{"Title" : "Title1" , "Description" : "いろいろやる"} true
{"Title" : "Title1" , "Description2" : "いろいろやる"} false
{"Title2" : "Title1" , "Description" : "いろいろやる"} false
{"Title" : "gajgapgndfsngdsgkhnpsdknhsdgkhmkgsdhn" , "Description" : ""} false
{"Title" : "" , "Description" : "いろいろやる"} false
PASS
ok      _/Users/takumi.negishi/go_appengine/gopath/gae_projects/todolist

というような結果が帰ってくる。

議論

なぜステータスコードで判断をするのか

ginを用いたルーティングでは
func Listing(g *gin.Context) {}のように引数は*gin.Context、返り値は何もないメソッド、にする必要があります。なのでエラーをテストファイルから検出しようにもサーバからerror変数自体は返すことができません。

それに対応するため
g.String(500,"Error Message")のようにg.String()をエラーの検出として用いることによってエラーがあったことをサーバに通知します。

今回は例えばGetTodoでは構造体の中身に正しく値が入っているかのテストは行っていません。(構造体が返されたかどうかの判定だけ)
仮にそういった判定をしてエラーハンドリングする場合は基本的にModel側(TodoListManager.go)でなんとかして、コントローラー(server.go)はそれらをハンドリングして様々なステータスコードを返すことに専念するのが良いでしょう。

log.Fatalln()は何故使わないのか

log.Fatalln("Error %v",errhoge)は動きとして"Error errhogeの値"をPrintlnした後os.Exit(1)で終了してしまいます。

なので例えば不正な値を入れてエラーが検出して一度でもlog.Fatalln()をしてしまうと、その後の正常な値を入力しても500エラーが発生してテストが正常に実行されません。
os.Exitしてしまうのでサーバが終了してしまうためです。

その点g.String(500,"Error %v",errhoge)は同等な出力ができる上にサーバが終了しないので、テストを正常に実行することができます。

これが今回g.String()をエラーの表示として用いた理由です。

デバッグに使用してたdatastoreの中身を汚しちゃうのでは

基本的に、goapp serveで動かしてそこにPutしているので何もしなければ確かにそのとおりです。

しかしこの問題の回避は簡単で、app.yamlのapplication:の名前を変更することで、テストの際はテスト用にdatastoreを立てることによってこれは回避ができます。

(元々使っていた方の)app.yaml
    application: todolist #元々デバッグで使っていたdatastoreは汚したくない。。
    version: 1
    runtime: go
    api_version: go1
    handlers:
    - url: /.*
      script: _go_app
#…以下省略

(テストで使う)app.yaml
    application: todolist-test #こんな感じに変える。別に-testじゃなくても良い。
    version: 1
    runtime: go
    api_version: go1
    handlers:
    - url: /.*
      script: _go_app
#…以下省略

失敗談

最初はgoapp testでTestメソッドが実行された時にserverを動かせばいいんじゃないかと考えました。
↓のような感じ

(old)server_test.go
func TestServer(t *testing.T) {
    LoadTemplates()
    server := gin.Default()
    server.GET("/test/", func(g *gin.Context) {
        Listing(g)
    })
    http.Handle("/", server)

    //サーバをたてて
    resp, err := http.Get("http://localhost:8080/test/")

    defer resp.Body.Close()
    if err != nil {
        t.Fatalf("Error:: %v", err)
        return
    }
//respによって判断する。
}

しかしrespにそもそも値が入らない(サーバが立っていないのか、正常にアクセスできていない)という問題が発生し、やむなく断念…
これに関しては未だによくわかってないです。

所感

割とゴリ押しした割には被テストコードにほとんど変更を与えず(GetMainEngine()くらい)、スマートにテストできた気がします。

最初にも言いましたがかなり自己流のやり方です。何かもっと良いやり方があったり、ここが間違ってるよというのがあればコメントをいただけるととてもうれしいです。

以上です。

ユーザー登録して、Qiitaをもっと便利に使ってみませんか。
  1. あなたにマッチした記事をお届けします
    ユーザーやタグをフォローすることで、あなたが興味を持つ技術分野の情報をまとめてキャッチアップできます
  2. 便利な情報をあとで効率的に読み返せます
    気に入った記事を「ストック」することで、あとからすぐに検索できます
コメント
この記事にコメントはありません。
あなたもコメントしてみませんか :)
すでにアカウントを持っている方は
ユーザーは見つかりませんでした