Posted at

Goで作ったWEBアプリケーションの環境ごとの接続設定とテストをする

More than 3 years have passed since last update.

前回の続きです。

せっかくオレオレWAFを作ったので、ユニットテストと、go-yamlを使った環境設定を行いたいと思います。


go-yaml

設定ファイルはyamlが扱いやすいので、go-yamlを使います。

$ go get gopkg.in/yaml.v2

設定用yamlはこのような感じをイメージ。


db.yml

driver: mysql

development:
user: root
password: ""
db: gorm
production:
user: hoge
password: fuga
db: gorm
test:
user: root
password: ""
db: gorm_test

go-yamlのサンプルには予め型が決まったyamlの読み込みの方法が記述されていますが、map[interface{}]interface{}を使うと型を決めずに読み込めるようです。

詳しいやり方は下記にまとまっていますので、このまま使わせていただきます。

http://qiita.com/yamasaki-masahide/items/d6e406c4c11d5870a1c6

前回main.goを、go-yamlを使って任意の接続先を選択できるように書き換えます。


main.go

package main

import (
"github.com/zenazn/goji"
"github.com/zenazn/goji/web"
"github.com/zenazn/goji/web/middleware"
_ "github.com/go-sql-driver/mysql"
"gopkg.in/yaml.v2"
"io/ioutil"
"os"
)

var db gorm.DB

// main()はルーティング情報だけ記述する
func main() {
user := web.New()
goji.Handle("/user/*", user)

user.Use(middleware.SubRouter)
user.Get("/index", UserIndex)
user.Get("/new", UserNew)
user.Post("/new", UserCreate)
user.Get("/edit/:id", UserEdit)
user.Post("/update/:id", UserUpdate)
user.Get("/delete/:id", UserDelete)

goji.Serve()
}

// 初期処理のDB接続をyamlから呼び出すように変更
func init(){
yml, err := ioutil.ReadFile("conf/db.yml")
if err != nil {
panic(err)
}

t := make(map[interface{}]interface{})

_ = yaml.Unmarshal([]byte(yml), &t)

conn := t[os.Getenv("GOJIENV")].(map[interface {}]interface {})
db, err = gorm.Open("mysql", conn["user"].(string)+conn["password"].(string)+"@/"+conn["db"].(string)+"?charset=utf8&parseTime=True")
if err != nil {
panic(err)
}
}


設定ファイルはconf/db.ymlとして、先ほどのdb.ymlを保存しています。

環境変数は何でもいいんですが、今回はGOJIENVにしました。

exportして環境変数を設定します。

$ export GOJIENV=development

起動してみます。

$ go run main.go user_controller.go

2014/12/09 03:45:42.106781 Starting Goji on [::]:8000

無事に起動しました。

これにより、GOJIENVproductiontestにすることで、ソースを変更せずに接続先を変更することができるようになりました。


テスト

次に、ソースの変更を行わず環境を変えることでテストDBが読み込めるようになったので、ユニットテストを行いたいと思います。

モデルのユニットテストはtestingを使って根気よくやれば特に躓くことなくできそうだったので、今回はコントローラー側のユニットテストに注力します。

httpまわりのテストはnet/http/httptestを使って行えるので、これを使ってテストを実施します。


アサーションの追加

標準のtestingだとアサーションを書くのが大変なのでassertを使用します。

$ go get github.com/stretchr/testify/assert


net/http/httptest

gojinet/http/httptestでユニットテストをまとめられた方がいるので、こちらを参考にさせていただきました。

http://qiita.com/r_rudi/items/727fb85030e91101801d

httptestでモックサーバを立てる場合、httptest.NewServer()にhttpマルチプレクサを渡す必要があります。

前回はWEBプレクサをmain()内に作成していましたが、httptestに引数として渡す必要があるため、ルーティングの部分をrooter()に分離します。

ついでにデータベースコネクトをinit()からconnect()に変更してmain()から呼び出すように変更してます。


main.go

package main

import (
"github.com/zenazn/goji"
"github.com/jinzhu/gorm"
"github.com/zenazn/goji/web"
"github.com/zenazn/goji/web/middleware"
_ "github.com/go-sql-driver/mysql"
"gopkg.in/yaml.v2"
"io/ioutil"
"os"
"net/http"
)

var db gorm.DB

func main() {
//main()内でconnectとrooterを呼び出す
connect() // ← DB接続
rooter(goji.DefaultMux) // ← Muxの設定
goji.Serve()
}

//Muxに対するルーティング情報をrooter()としてまとめる
func rooter(m *web.Mux) http.Handler {
m.Use(SuperSecure)
user := web.New()
goji.Handle("/user/*", user)
user.Use(middleware.SubRouter)
user.Get("/index", UserIndex)
user.Get("/new", UserNew)
user.Post("/new", UserCreate)
user.Get("/edit/:id", UserEdit)
user.Post("/update/:id", UserUpdate)
user.Get("/delete/:id", UserDelete)

return m
}

//init()から変更
func connect(){
yml, err := ioutil.ReadFile("conf/db.yml")
if err != nil {
panic(err)
}

t := make(map[interface{}]interface{})

_ = yaml.Unmarshal([]byte(yml), &t)

conn := t[os.Getenv("GOJIENV")].(map[interface {}]interface {})
db, err = gorm.Open("mysql", conn["user"].(string)+conn["password"].(string)+"@/"+conn["db"].(string)+"?charset=utf8&parseTime=True")
if err != nil {
panic(err)
}
}


ここまでできたので、一度テストをしてみます。

今回はベーシック認証でアクセスを制限してますが、goのhttp.Getは接続を簡易化してくれるかわりにカスタムヘッダの設定ができないので、http.NewRequesthttp.Client.Doを使う必要があります。


user_controller_test.go

package main

import (
"github.com/jinzhu/gorm"
"github.com/zenazn/goji/web"
_ "github.com/go-sql-driver/mysql"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v2"

"models"
"net/http"
"io/ioutil"
"testing"
"net/http/httptest"
)

func TestUserIndex(t *testing.T) {
m := web.New()
rooter(m)
ts := httptest.NewServer(m)
defer ts.Close()

req, _ := http.NewRequest("GET", ts.URL + "/user/index", nil)
// http.NewRequestを生成してベーシック認証用のカスタムヘッダを設定
req.Header.Set("Authorization", "Basic dXNlcjp1c2Vy")

// http.Clientのインスタンスを作ってDoで接続する
client := new(http.Client)
response, _ := client.Do(req)

assert.Equal(t, 200, response.StatusCode)
}

// テスト用のDB接続はinit()で。
// ここでテストDBのリストアも行います。
func init(){
yml, _ := ioutil.ReadFile("conf/db.yml")
t := make(map[interface{}]interface{})

_ = yaml.Unmarshal([]byte(yml), &t)

conn := t["test"].(map[interface {}]interface {})
db, _ = gorm.Open("mysql", conn["user"].(string)+conn["password"].(string)+"@/"+conn["db"].(string)+"?charset=utf8&parseTime=True")
db.DropTable(&models.User{})
db.CreateTable(&models.User{})
User := models.User{Name: "deluser"}
db.Save(&User)
}


Authorizationヘッダ用のbase64エンコードはbase64コマンドで行います。

$  echo -n "user:user" | base64

dXNlcjp1c2Vy

テストを実行します。

$ go test main.go user_controller.go user_nethttp_test.go

--- FAIL: TestUserIndex (0.00 seconds)
Location: user_nethttp_test.go:28
Error: Not equal: 200 (expected)
!= 404 (actual)

FAIL
FAIL command-line-arguments 0.303s

404エラーになってしまいました。。。

どうやらhttptestに渡しているMux内でgojisubRouterを設定しても引き継がれないのが原因のようです。

対応方法があるのかもしれないのですが、とりあえずsubRouterを使わなければ問題ないので、rooter()を書き換えます。


rooter()

func rooter(m *web.Mux) http.Handler {

m.Use(SuperSecure)
//サブルーターを廃止
m.Get("/user/index", UserIndex)
m.Get("/user/new", UserNew)
m.Post("/user/new", UserCreate)
m.Get("/user/edit/:id", UserEdit)
m.Post("/user/update/:id", UserUpdate)
m.Get("/user/delete/:id", UserDelete)

return m
}


再度テストを実行します。

$ go test main.go user_controller.go user_nethttp_test.go

ok command-line-arguments 0.325s

今度はちゃんと通りました。

ひと通りCRUDのテストを書きます。

http.Get()http.Set()が使えないとつらみが・・・


user_controller_test.go

package main

import (
"github.com/jinzhu/gorm"
"github.com/zenazn/goji/web"
_ "github.com/go-sql-driver/mysql"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v2"

"models"
"net/http"
"io/ioutil"
"net/url"
"strings"
"testing"
"net/http/httptest"
)

func TestUserIndex(t *testing.T) {
m := web.New()
rooter(m)
ts := httptest.NewServer(m)
defer ts.Close()

req, _ := http.NewRequest("GET", ts.URL + "/user/index", nil)
req.Header.Set("Authorization", "Basic dXNlcjp1c2Vy")
client := new(http.Client)
response, _ := client.Do(req)

assert.Equal(t, 200, response.StatusCode)
}

func TestUserGet(t *testing.T) {
m := web.New()
rooter(m)
ts := httptest.NewServer(m)
defer ts.Close()

req, _ := http.NewRequest("GET", ts.URL + "/user/edit/1", nil)
req.Header.Set("Authorization", "Basic dXNlcjp1c2Vy")
client := new(http.Client)
response, _ := client.Do(req)

assert.Equal(t, 200, response.StatusCode)
}

func TestUserCreate(t *testing.T) {
count_before := 0
count_after := 0

m := web.New()
rooter(m)
ts := httptest.NewServer(m)
defer ts.Close()

db.Table("users").Count(&count_before)
values := url.Values{}
values.Add("Name","testman")

// POSTのあたりつらい
req, _ := http.NewRequest("POST", ts.URL + "/user/new",strings.NewReader(values.Encode()))
req.Header.Set("Authorization", "Basic dXNlcjp1c2Vy")
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")

client := new(http.Client)
response, _ := client.Do(req)

db.Table("users").Count(&count_after)

assert.Equal(t, 301, response.StatusCode)
assert.Equal(t, count_before + 1, count_after)
}

func TestUserCreateError(t *testing.T) {
count_before := 0
count_after := 0

m := web.New()
rooter(m)
ts := httptest.NewServer(m)
defer ts.Close()

db.Table("users").Count(&count_before)
values := url.Values{}
values.Add("Name","エラー")

req, _ := http.NewRequest("POST", ts.URL + "/user/new",strings.NewReader(values.Encode()))
req.Header.Set("Authorization", "Basic dXNlcjp1c2Vy")
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")

client := new(http.Client)
response, _ := client.Do(req)

db.Table("users").Count(&count_after)

assert.Equal(t, 200, response.StatusCode)
assert.Equal(t, count_before, count_after)
}

func TestUserDelete(t *testing.T) {
count_before := 0
count_after := 0
Users := [] models.User{}

m := web.New()
rooter(m)
ts := httptest.NewServer(m)
defer ts.Close()

db.Find(&Users).Count(&count_before)
req, _ := http.NewRequest("GET", ts.URL + "/user/delete/1", nil)
req.Header.Set("Authorization", "Basic dXNlcjp1c2Vy")
client := new(http.Client)
client.Do(req)
db.Find(&Users).Count(&count_after)

assert.Equal(t, count_before - 1, count_after)
}

func init(){
yml, _ := ioutil.ReadFile("conf/db.yml")
t := make(map[interface{}]interface{})

_ = yaml.Unmarshal([]byte(yml), &t)

conn := t["test"].(map[interface {}]interface {})
db, _ = gorm.Open("mysql", conn["user"].(string)+conn["password"].(string)+"@/"+conn["db"].(string)+"?charset=utf8&parseTime=True")
db.DropTable(&models.User{})
db.CreateTable(&models.User{})
User := models.User{Name: "deluser"}
db.Save(&User)
}


実行します。

$ go test main.go user_controller.go user_nethttp_test.go

ok command-line-arguments 0.346s

無事全パターン通りました。


testflight

net/http/httptestだけでテストは可能なんですが、接続の周りをうまい具合にラッピングしてくれるtestflightというパッケージがあるので、それを使うと下記のように書き換えることができます。

testflightを入れる。

$ go get github.com/drewolson/testflight

テストを書き換える。


user_controller_testflight_test.go

package main

import (
"github.com/zenazn/goji"
"github.com/jinzhu/gorm"
_ "github.com/go-sql-driver/mysql"
"github.com/drewolson/testflight"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v2"

"models"
"testing"
"io/ioutil"
"net/http"
"net/url"
"strings"
)

func TestUserIndex(t *testing.T) {
testflight.WithServer(rooter(goji.DefaultMux), func(r *testflight.Requester) {
req, _ := http.NewRequest("GET", "/user/index", nil)
req.Header.Set("Authorization", "Basic dXNlcjp1c2Vy")
response := r.Do(req)
assert.Equal(t, 200, response.StatusCode)
})
}

func TestUserGet(t *testing.T) {
testflight.WithServer(rooter(goji.DefaultMux), func(r *testflight.Requester) {
req, _ := http.NewRequest("GET", "/user/edit/22", nil)
req.Header.Set("Authorization", "Basic dXNlcjp1c2Vy")
response := r.Do(req)
assert.Equal(t, 200, response.StatusCode)
})
}

func TestUserCreate(t *testing.T) {
testflight.WithServer(rooter(goji.DefaultMux), func(r *testflight.Requester) {
count_before := 0
count_after := 0
db.Table("users").Count(&count_before)

values := url.Values{}
values.Add("Name","testman")

req, _ := http.NewRequest("POST", "/user/new", strings.NewReader(values.Encode()))
req.Header.Set("Authorization", "Basic dXNlcjp1c2Vy")
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
response := r.Do(req)

db.Table("users").Count(&count_after)
assert.Equal(t, 301, response.StatusCode)
assert.Equal(t, count_before + 1, count_after)
})
}

func TestUserCreateError(t *testing.T) {
testflight.WithServer(rooter(goji.DefaultMux), func(r *testflight.Requester) {
count_before := 0
count_after := 0
db.Table("users").Count(&count_before)

values := url.Values{}
values.Add("Name","エラー")

req, _ := http.NewRequest("POST", "/user/new", strings.NewReader(values.Encode()))
req.Header.Set("Authorization", "Basic dXNlcjp1c2Vy")
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
response := r.Do(req)

db.Table("users").Count(&count_after)
assert.Equal(t, 200, response.StatusCode)
assert.Equal(t, count_before, count_after)
})
}

func TestUserDelete(t *testing.T) {
testflight.WithServer(rooter(goji.DefaultMux), func(r *testflight.Requester) {
Users := [] models.User{}
count_before := 0
count_after := 0
db.Find(&Users).Count(&count_before)

req, _ := http.NewRequest("GET", "/user/delete/1", nil)
req.Header.Set("Authorization", "Basic dXNlcjp1c2Vy")
r.Do(req)

db.Find(&Users).Count(&count_after)
assert.Equal(t, count_before - 1, count_after)
})
}

func init(){
yml, _ := ioutil.ReadFile("conf/db.yml")
t := make(map[interface{}]interface{})

_ = yaml.Unmarshal([]byte(yml), &t)

conn := t["test"].(map[interface {}]interface {})
db, _ = gorm.Open("mysql", conn["user"].(string)+conn["password"].(string)+"@/"+conn["db"].(string)+"?charset=utf8&parseTime=True")
db.DropTable(&models.User{})
db.CreateTable(&models.User{})
User := models.User{Name: "deluser"}
db.Save(&User)
}


実行します。

$ go test main.go user_controller.go user_testflight_test.go

ok command-line-arguments 0.443s

通りました。

net/http/httptestを使うかtestflightなどのパッケージを使うかはお好みで。

コードはgithubに置きました。

goji_waf_sample


まとめ


  • テストを書くことでgo言語の仕様とか理解が深まるのでテストはやっぱり大事です。

  • go言語はテストフレームワークを内包しているので、テストしやすいところがとても良いと思いました。

  • 個人的な感想として、WAFやるだけならRailsなどのフレームワークを使ったほうが良いと思います。Go言語は処理速度を求めるエンドポイントとか、そういった部分に向いてそうです。

  • 次はgorutineとかChannelとか練習したい。