iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

DAY 22
1
Software Development

下班加減學點Golang與Docker系列 第 22

Gin框架 with httptest and testify的第一次接觸


來杯琴酒(Gin)+萊姆=琴蕾(Gimlet)吧(誤)

Gin

Gin是一個基於Golang實做的框架, 特色是簡單!!!

  • 設計精巧好懂的router/middleware系統
  • 簡單好用的上下文gin.Context
  • JSON、XML、DataBiding、Validation...

安裝Gin

go get -u github.com/gin-gonic/gin

Hello It Home

package main

import (
	"github.com/gin-gonic/gin"
)

func main() {
	router := gin.Default()
	router.GET("/hello", func(c *gin.Context) {
		c.Data(200, "text/plain", []byte("Hello, It Home!"))
	})
	
	router.Run()
}

昨天的hello路由, 這樣就寫完了.
且啟動時跟收到請求時, Gin有預設的log middleware會列印出耗費時間.
且還支援RESTful API的動詞(GET/POST/PATCH/DELETE/PUT...)
這個gin.Default()作用等同於net/http包內的DefaultServeMux, 只是這是gin包裝過的預設路由引擎.

// Default returns an Engine instance with the Logger and Recovery middleware already attached.
func Default() *Engine {
	debugPrintWARNINGDefault()
	engine := New()
	engine.Use(Logger(), Recovery())
	return engine
}

這預設引擎使用了Logger()和Recovery()
Logger就是負責我們終端機上看到的log.
Recovery負責的是當有panic發生時, 就進行http status 500的錯誤處理(避免服務因此就終止了).

測試

為了方便測試, 我們把路由處理放到一個單獨的資料夾內.
又開一個test資料夾.

然後安裝一下斷言包testify

main.go

package main

import (
	"github.com/tedmax100/gin-angular/router"
)

func main() {
	router := router.SetupRouter()
	router.Run()
}

helloRouter.go

package router

import (
	"github.com/gin-gonic/gin"
)

func SetupRouter() *gin.Engine {
	router := gin.Default()
	router.GET("/hello", func(c *gin.Context) {
		c.Data(200, "text/plain", []byte("Hello, It Home!"))
	})
	return router
}

我們會想測試這/hello會回給我們200的狀態跟Hello, It Home!
net/http包當中還提供了一樣神器httptest

httptest能讓我們快速的建立一個server, 或者建立一個recorder來紀錄response.
server我們就用Gin.
所以這裡就用recorder來捕捉response的內容來測試.

package test

import (
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/tedmax100/gin-angular/router"
)

func TestIHelloGetRouter(t *testing.T) {
	router := router.SetupRouter()

	w := httptest.NewRecorder()
	req, _ := http.NewRequest(http.MethodGet, "/hello", nil)

	router.ServeHTTP(w, req)

	assert.Equal(t, http.StatusOK, w.Code)
	assert.Equal(t, "Hello, It Home!", w.Body.String())
}
go test ./...
----------------
?       github.com/tedmax100/gin-angular        [no test files]
?       github.com/tedmax100/gin-angular/router [no test files]
ok      github.com/tedmax100/gin-angular/test   0.004s

測試成功!
來看看寫的測試程式碼.

net/http包也是能發出Request請求, 所以這裡就是透過http.NewRequest來對我們想測試的API發出請求.

重點在於router.ServeHTTP(w, req)

// ServeHTTP conforms to the http.Handler interface.
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	c := engine.pool.Get().(*Context)
	c.writermem.reset(w)
	c.Request = req
	c.reset()

	engine.handleHTTPRequest(c)

	engine.pool.Put(c)
}

我們的router是一個已經註冊好/hello的router了.
當收到請求後, gin會從連線池中取得一個空的context, 而不是每次都去生成一個新的context, 這樣效率會快很多.
然後再過engine.handleHTTPRequest(c), 來處理這context.

昨天提到的net/http的DefaultServeMux也有ServeHTTP(),
只是它沒有context池子跟context上下文物件的概念來處理請求.

然後就能測試status code跟body內容了.

URL Parameter 路徑參數

我們在寫RESTful API時, 因為都是以資源為維度在操作.
所以URL裡會有地方表示資源代碼或是名稱.
也不可能是hard code寫死. 所以這裡要透過Param()來取得這部份的表示.

helloRouter.go

package router

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

func SetupRouter() *gin.Engine {
	router := gin.Default()
	router.GET("/hello", func(ctx *gin.Context) {
		ctx.Data(200, "text/plain", []byte("Hello, It Home!"))
	})

	router.DELETE("/hello/:id", func(ctx *gin.Context) {
		id := ctx.Param("id")
		ctx.String(http.StatusOK, "hello DELETE %s", id)
	})
	return router
}

Gin就是能這樣快速的加入一個RESTful的路由.
我們的DELETE("/hello/:id"), 這裡會需要對hello id是?的資源作刪除的動作.
並且在response body加入被刪除的id.

來看看Context.Param()

// Param returns the value of the URL param.
// It is a shortcut for c.Params.ByName(key)
//     router.GET("/user/:id", func(c *gin.Context) {
//         // a GET request to /user/john
//         id := c.Param("id") // id == "john"
//     })
func (c *Context) Param(key string) string {
	return c.Params.ByName(key)
}


type Params []Param

type Param struct {
	Key   string
	Value string
}

func (ps Params) ByName(name string) (va string) {
	va, _ = ps.Get(name)
	return
}

func (ps Params) Get(name string) (string, bool) {
	for _, entry := range ps {
		if entry.Key == name {
			return entry.Value, true
		}
	}
	return "", false
}

就很簡單的在Param[]裡面, 嘗試找看看有沒有這名稱.

多了個方法, 又能來寫測試了.
因為我自己還不熟TDD這樣的開發習慣, 所以我都是先寫可執行程式後, 再補單元測試.

重構

程式碼搬家去

func SetupRouter() *gin.Engine {
	router := gin.Default()
	router.GET("/hello", func(ctx *gin.Context) {
		ctx.Data(200, "text/plain", []byte("Hello, It Home!"))
	})

	router.DELETE("/hello/:id", func(ctx *gin.Context) {
		id := ctx.Param("id")
		ctx.String(http.StatusOK, "hello DELETE %s", id)
	})
	return router
}

原本的SetupRouter這方法, 裡面有出現處理邏輯的部份.
這樣會讓這方法出現除了只定義路由與處理方法之外的職責.
我們把它們搬家.

建立了一個handler資料夾, 並在裡面建立了一個helloHandler.go

package handler

import (
	"net/http"
	"github.com/gin-gonic/gin"
)

func GetHello(ctx *gin.Context) {
	ctx.Data(200, "text/plain", []byte("Hello, It Home!"))
}

func DeleteHello(ctx *gin.Context) {
	id := ctx.Param("id")
	ctx.String(http.StatusOK, "hello DELETE %s", id)
}

搬家之後的SetupRouter()就變得很清爽了, 程式碼只有路由跟處理方法.
這算是設計原則中的單一職責的應用.

package router

import (
	"github.com/gin-gonic/gin"
	"github.com/tedmax100/gin-angular/handler"
)

func SetupRouter() *gin.Engine {
	router := gin.Default()
	router.GET("/hello", handler.GetHello)

	router.DELETE("/hello/:id", handler.DeleteHello)
	return router
}

因為搬了家, 之前寫好的測試這時一定要是ok! 跑看看測試.

go test ./...
----------------
?       github.com/tedmax100/gin-angular        [no test files]
?       github.com/tedmax100/gin-angular/router [no test files]
ok      github.com/tedmax100/gin-angular/test   0.004s

新增Delete測試

func TestIHelloDeleteRouter(t *testing.T) {
	id := "123"
	router := router.SetupRouter()

	w := httptest.NewRecorder()
	req, _ := http.NewRequest(http.MethodDelete, "/hello/"+id, nil)

	router.ServeHTTP(w, req)

	assert.Equal(t, http.StatusOK, w.Code)
	assert.Equal(t, "hello DELETE "+id, w.Body.String())
}

列印更多資訊, 只要加上-v (verbose的縮寫)

go test -v ./...  
----------------
?       github.com/tedmax100/gin-angular        [no test files]
?       github.com/tedmax100/gin-angular/handler        [no test files]
?       github.com/tedmax100/gin-angular/router [no test files]
=== RUN   TestIHelloGetRouter
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /hello                    --> github.com/tedmax100/gin-angular/handler.GetHello (3 handlers)
[GIN-debug] DELETE /hello/:id                --> github.com/tedmax100/gin-angular/handler.DeleteHello (3 handlers)
[GIN] 2019/09/29 - 00:02:31 | 200 |       4.699µs |                 | GET      /hello
--- PASS: TestIHelloGetRouter (0.00s)
=== RUN   TestIHelloDeleteRouter
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /hello                    --> github.com/tedmax100/gin-angular/handler.GetHello (3 handlers)
[GIN-debug] DELETE /hello/:id                --> github.com/tedmax100/gin-angular/handler.DeleteHello (3 handlers)
[GIN] 2019/09/29 - 00:02:31 | 200 |       2.938µs |                 | DELETE   /hello/123
--- PASS: TestIHelloDeleteRouter (0.00s)
PASS
ok      github.com/tedmax100/gin-angular/test   0.004s

可以看得出來2個測試都成功.
我們舊有的測試沒問題, 新增的測試也成功.
這就是回歸測試Regression Testing

路由分類分組

我們剛剛的routing都是/hello開頭相關的.
現在業務變多了, 加上user相關的!

把helloRouter.go 改名成SetupRouter.go
並且透過Group(), 來建立路由分組.

// Group creates a new router group. You should add all the routes that have common middlewares or the same path prefix.
// For example, all the routes that use a common middleware for authorization could be grouped.
func (group *RouterGroup) Group(relativePath string, handlers ...HandlerFunc) *RouterGroup {
	return &RouterGroup{
		Handlers: group.combineHandlers(handlers),
		basePath: group.calculateAbsolutePath(relativePath),
		engine:   group.engine,
	}
}
package router

import (
	"github.com/gin-gonic/gin"
	"github.com/tedmax100/gin-angular/handler"
)

func SetupRouter() *gin.Engine {
	router := gin.Default()
	helloRouting := router.Group("/hello")
	{
		helloRouting.GET("", handler.GetHello)

		helloRouting.DELETE("/:id", handler.DeleteHello)
	}

	userRouting := router.Group("/user")
	{
		userRouting.GET("", handler.GetUser)

	}
	return router
}

userHandler.go

package handler

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

func GetUser(ctx *gin.Context) {
	uid := ctx.Param("uid")
	ctx.JSON(http.StatusOK, gin.H{
		"userId": uid,
	})
}

老樣子先跑原本的測試. 都ok!

透過Postman打看看

再來新增測試
userRouter_test.go

package test

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/tedmax100/gin-angular/router"
)

func TestIUserGetRouter(t *testing.T) {
	type User struct {
		UserId string `json:"userId"`
	}
	user := User{
		UserId: "123",
	}
	expectedBody, _ := json.Marshal(user)
	router := router.SetupRouter()

	w := httptest.NewRecorder()
	req, _ := http.NewRequest(http.MethodGet, "/user/"+user.UserId, nil)

	router.ServeHTTP(w, req)

	assert.Equal(t, http.StatusOK, w.Code)
	assert.Equal(t, string(expectedBody), w.Body.String())
}
go test -v ./...
--------------
?       github.com/tedmax100/gin-angular        [no test files]
?       github.com/tedmax100/gin-angular/handler        [no test files]
?       github.com/tedmax100/gin-angular/router [no test files]
=== RUN   TestIHelloGetRouter
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /hello                    --> github.com/tedmax100/gin-angular/handler.GetHello (3 handlers)
[GIN-debug] DELETE /hello/:id                --> github.com/tedmax100/gin-angular/handler.DeleteHello (3 handlers)
[GIN-debug] GET    /user/:uid                --> github.com/tedmax100/gin-angular/handler.GetUser (3 handlers)
[GIN] 2019/09/29 - 00:49:09 | 200 |       9.512µs |                 | GET      /hello
--- PASS: TestIHelloGetRouter (0.00s)
=== RUN   TestIHelloDeleteRouter
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /hello                    --> github.com/tedmax100/gin-angular/handler.GetHello (3 handlers)
[GIN-debug] DELETE /hello/:id                --> github.com/tedmax100/gin-angular/handler.DeleteHello (3 handlers)
[GIN-debug] GET    /user/:uid                --> github.com/tedmax100/gin-angular/handler.GetUser (3 handlers)
[GIN] 2019/09/29 - 00:49:09 | 200 |        3.06µs |                 | DELETE   /hello/123
--- PASS: TestIHelloDeleteRouter (0.00s)
=== RUN   TestIUserGetRouter
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /hello                    --> github.com/tedmax100/gin-angular/handler.GetHello (3 handlers)
[GIN-debug] DELETE /hello/:id                --> github.com/tedmax100/gin-angular/handler.DeleteHello (3 handlers)
[GIN-debug] GET    /user/:uid                --> github.com/tedmax100/gin-angular/handler.GetUser (3 handlers)
[GIN] 2019/09/29 - 00:49:09 | 200 |        8.62µs |                 | GET      /user/123
--- PASS: TestIUserGetRouter (0.00s)
PASS
ok      github.com/tedmax100/gin-angular/test   0.004s


全綠燈~爽!!

以下是我在NodeJS的Express框架設定路由分組跟用Jest+Supertest寫Api測試.
可以發現Gin+httptest+testify, 讓我可以把以前的習慣帶過來.


我自己會儘可能補上必要的測試情境.
因為這是能替未來的自己在這專案上省時間的解法之一.
未來回頭重構或者是交接給別人, 我也能從測試這裡開始講解就好.
不必痛苦的一開始就看業務邏輯.


上一篇
Http Service淺談
下一篇
Gin框架搭配模板
系列文
下班加減學點Golang與Docker30

尚未有邦友留言

立即登入留言