Go 单元测试之HTTP请求与API测试
一、httptest
1.1 前置代码准备
假设我们的业务逻辑是搭建一个http server端,对外提供HTTP服务。用来处理用户登录请求,用户需要输入邮箱,密码。
package main
import (
regexp "github.com/dlclark/regexp2"
"github.com/gin-gonic/gin"
"net/http"
)
type UserHandler struct {
emailExp *regexp.Regexp
passwordExp *regexp.Regexp
}
func (u *UserHandler) RegisterRoutes(server *gin.Engine) {
ug := server.Group("/user")
ug.POST("/login", u.Login)
}
func NewUserHandler() *UserHandler {
const (
emailRegexPattern = "^\\w+([-+.]\\w+)*@\\w+([-.]\\w+)*\\.\\w+([-.]\\w+)*$"
passwordRegexPattern = `^(?=.*[A-Za-z])(?=.*\d)(?=.*[$@$!%*#?&])[A-Za-z\d$@$!%*#?&]{8,}$`
)
emailExp := regexp.MustCompile(emailRegexPattern, regexp.None)
passwordExp := regexp.MustCompile(passwordRegexPattern, regexp.None)
return &UserHandler{
emailExp: emailExp,
passwordExp: passwordExp,
}
}
type LoginRequest struct {
Email string `json:"email"`
Pwd string `json:"pwd"`
}
func (u *UserHandler) Login(ctx *gin.Context) {
var req LoginRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"msg": "参数不正确!"})
return
}
// 校验邮箱和密码是否为空
if req.Email == "" || req.Pwd == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"msg": "邮箱或密码不能为空"})
return
}
// 正则校验邮箱
ok, err := u.emailExp.MatchString(req.Email)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"msg": "系统错误!"})
return
}
if !ok {
ctx.JSON(http.StatusBadRequest, gin.H{"msg": "邮箱格式不正确"})
return
}
// 校验密码格式
ok, err = u.passwordExp.MatchString(req.Pwd)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"msg": "系统错误!"})
return
}
if !ok {
ctx.JSON(http.StatusBadRequest, gin.H{"msg": "密码必须大于8位,包含数字、特殊字符"})
return
}
// 校验邮箱和密码是否匹配特定的值来确定登录成功与否
if req.Email != "123@qq.com" || req.Pwd != "hello#world123" {
ctx.JSON(http.StatusBadRequest, gin.H{"msg": "邮箱或密码不匹配!"})
return
}
ctx.JSON(http.StatusOK, gin.H{"msg": "登录成功!"})
}
func InitWebServer(userHandler *UserHandler) *gin.Engine {
server := gin.Default()
userHandler.RegisterRoutes(server)
return server
}
func main() {
uh := &UserHandler{}
server := InitWebServer(uh)
server.Run(":8080") // 在8080端口启动服务器
}
1.2 介绍
在 Web 开发场景下,单元测试经常需要模拟 HTTP 请求和响应。使用 httptest
可以让我们在测试代码中创建一个 HTTP 服务器实例,并定义特定的请求和响应行为,从而模拟真实世界的网络交互,在Go语言中,一般都推荐使用Go标准库 net/http/httptest
进行测试。
1.3 基本用法
使用 httptest
的基本步骤如下:
- 导入
net/http/httptest
包。 - 创建一个
httptest.Server
实例,并指定你想要的服务器行为。 - 在测试代码中使用
httptest.NewRequest
创建一个模拟的 HTTP 请求,并将其发送到httptest.Server
。 - 检查响应内容或状态码是否符合预期。
以下是一个简单的 httptest
用法示例
package main
import (
"bytes"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"net/http"
"net/http/httptest"
"testing"
)
func TestUserHandler_Login(t *testing.T) {
// 定义测试用例
testCases := []struct {
name string
reqBody string
wantCode int
wantBody string
}{
{
name: "登录成功",
reqBody: `{"email": "123@qq.com", "pwd": "hello#world123"}`,
wantCode: http.StatusOK,
wantBody: `{"msg": "登录成功!"}`,
},
{
name: "参数不正确",
reqBody: `{"email": "123@qq.com", "pwd": "hello#world123",}`,
wantCode: http.StatusBadRequest,
wantBody: `{"msg": "参数不正确!"}`,
},
{
name: "邮箱或密码为空",
reqBody: `{"email": "", "pwd": ""}`,
wantCode: http.StatusBadRequest,
wantBody: `{"msg": "邮箱或密码不能为空"}`,
},
{
name: "邮箱格式不正确",
reqBody: `{"email": "invalidemail", "pwd": "hello#world123"}`,
wantCode: http.StatusBadRequest,
wantBody: `{"msg": "邮箱格式不正确"}`,
},
{
name: "密码格式不正确",
reqBody: `{"email": "123@qq.com", "pwd": "invalidpassword"}`,
wantCode: http.StatusBadRequest,
wantBody: `{"msg": "密码必须大于8位,包含数字、特殊字符"}`,
},
{
name: "邮箱或密码不匹配",
reqBody: `{"email": "123123@qq.com", "pwd": "hello#world123"}`,
wantCode: http.StatusBadRequest,
wantBody: `{"msg": "邮箱或密码不匹配!"}`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// 创建一个 gin 的上下文
server := gin.Default()
h := NewUserHandler()
h.RegisterRoutes(server)
// mock 创建一个 http 请求
req, err := http.NewRequest(
http.MethodPost, // 请求方法
"/user/login", // 请求路径
bytes.NewBuffer([]byte(tc.reqBody)), // 请求体
)
// 断言没有错误
assert.NoError(t, err)
// 设置请求头
req.Header.Set("Content-Type", "application/json")
// 创建一个响应
resp := httptest.NewRecorder()
// 服务端处理请求
server.ServeHTTP(resp, req)
// 断言响应码和响应体
assert.Equal(t, tc.wantCode, resp.Code)
// 断言 JSON 字符串是否相等
assert.JSONEq(t, tc.wantBody, resp.Body.String())
})
}
}
在这个例子中,我们创建了一个简单的 HTTP 请求,TestUserHandler_Login
函数定义了一个测试函数,用于测试用户登录功能的不同情况。
testCases
列表定义了多个测试用例,每个测试用例包含了测试名称、请求体、期望的 HTTP 状态码和期望的响应体内容。- 使用
for
循环遍历测试用例列表,每次循环创建一个新的测试子函数,并在其中模拟 HTTP 请求发送给登录接口。 - 在每个测试子函数中,先创建一个 Gin 的默认上下文和用户处理器
UserHandler
,然后注册路由并创建一个模拟的 HTTP 请求。 - 通过
httptest.NewRecorder()
创建一个响应记录器,使用server.ServeHTTP(resp, req)
处理模拟请求,得到响应结果。 - 最后使用断言来验证实际响应的 HTTP 状态码和响应体是否与测试用例中的期望一致。
最后,使用Goland 运行测试,结果如下:
二、gock
2.1介绍
gock 可以帮助你在测试过程中模拟 HTTP 请求和响应,这对于测试涉及外部 API 调用的应用程序非常有用。它可以让你轻松地定义模拟请求,并验证你的应用程序是否正确处理了这些请求。
GitHub 地址:github.com/h2non/gock
2.2 安装
你可以通过以下方式安装 gock:
go get -u github.com/h2non/gock
导入 gock 包:
import "github.com/h2non/gock"
2.3 基本使用
gock
的基本用法如下:
- 启动拦截器:在测试开始前,使用
gock.New
函数启动拦截器,并指定你想要拦截的域名和端口。 - 定义拦截规则:你可以使用
gock.Intercept
方法来定义拦截规则,比如拦截特定的 URL、方法、头部信息等。 - 设置响应:你可以使用
gock.NewJson
、gock.NewText
等方法来设置拦截后的响应内容。 - 运行测试:在定义了拦截规则和响应后,你可以运行测试,
gock
会拦截你的 HTTP 请求,并返回你设置的响应。
2.4 举个例子
2.4.1 前置代码
如果我们是在代码中请求外部API的场景(比如通过API调用其他服务获取返回值)又该怎么编写单元测试呢?
例如,我们有以下业务逻辑代码,依赖外部API:http://your-api.com/post
提供的数据。
// ReqParam API请求参数
type ReqParam struct {
X int `json:"x"`
}
// Result API返回结果
type Result struct {
Value int `json:"value"`
}
func GetResultByAPI(x, y int) int {
p := &ReqParam{X: x}
b, _ := json.Marshal(p)
// 调用其他服务的API
resp, err := http.Post(
"http://your-api.com/post",
"application/json",
bytes.NewBuffer(b),
)
if err != nil {
return -1
}
body, _ := ioutil.ReadAll(resp.Body)
var ret Result
if err := json.Unmarshal(body, &ret); err != nil {
return -1
}
// 这里是对API返回的数据做一些逻辑处理
return ret.Value + y
}
在对类似上述这类业务代码编写单元测试的时候,如果不想在测试过程中真正去发送请求或者依赖的外部接口还没有开发完成时,我们可以在单元测试中对依赖的API进行mock。
2.4.2 测试用例
使用gock
对外部API进行mock,即mock指定参数返回约定好的响应内容。 下面的代码中mock了两组数据,组成了两个测试用例。
package gock_demo
import (
"testing"
"github.com/stretchr/testify/assert"
"gopkg.in/h2non/gock.v1"
)
func TestGetResultByAPI(t *testing.T) {
defer gock.Off() // 测试执行后刷新挂起的mock
// mock 请求外部api时传参x=1返回100
gock.New("http://your-api.com").
Post("/post").
MatchType("json").
JSON(map[string]int{"x": 1}).
Reply(200).
JSON(map[string]int{"value": 100})
// 调用我们的业务函数
res := GetResultByAPI(1, 1)
// 校验返回结果是否符合预期
assert.Equal(t, res, 101)
// mock 请求外部api时传参x=2返回200
gock.New("http://your-api.com").
Post("/post").
MatchType("json").
JSON(map[string]int{"x": 2}).
Reply(200).
JSON(map[string]int{"value": 200})
// 调用我们的业务函数
res = GetResultByAPI(2, 2)
// 校验返回结果是否符合预期
assert.Equal(t, res, 202)
assert.True(t, gock.IsDone()) // 断言mock被触发
}