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 的基本步骤如下:

  1. 导入 net/http/httptest 包。
  2. 创建一个 httptest.Server 实例,并指定你想要的服务器行为。
  3. 在测试代码中使用 httptest.NewRequest 创建一个模拟的 HTTP 请求,并将其发送到 httptest.Server
  4. 检查响应内容或状态码是否符合预期。

以下是一个简单的 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 函数定义了一个测试函数,用于测试用户登录功能的不同情况。

  1. testCases 列表定义了多个测试用例,每个测试用例包含了测试名称、请求体、期望的 HTTP 状态码和期望的响应体内容。
  2. 使用 for 循环遍历测试用例列表,每次循环创建一个新的测试子函数,并在其中模拟 HTTP 请求发送给登录接口。
  3. 在每个测试子函数中,先创建一个 Gin 的默认上下文和用户处理器 UserHandler,然后注册路由并创建一个模拟的 HTTP 请求。
  4. 通过 httptest.NewRecorder() 创建一个响应记录器,使用 server.ServeHTTP(resp, req) 处理模拟请求,得到响应结果。
  5. 最后使用断言来验证实际响应的 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 的基本用法如下:

  1. 启动拦截器:在测试开始前,使用 gock.New 函数启动拦截器,并指定你想要拦截的域名和端口。
  2. 定义拦截规则:你可以使用 gock.Intercept 方法来定义拦截规则,比如拦截特定的 URL、方法、头部信息等。
  3. 设置响应:你可以使用 gock.NewJsongock.NewText 等方法来设置拦截后的响应内容。
  4. 运行测试:在定义了拦截规则和响应后,你可以运行测试,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被触发
}
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,099评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,828评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,540评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,848评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,971评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,132评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,193评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,934评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,376评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,687评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,846评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,537评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,175评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,887评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,134评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,674评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,741评论 2 351

推荐阅读更多精彩内容