在Go语言单元测试中如何解决Redis存储依赖问题

登录程序示例

在 Web 开发中,登录需求是一个较为常见的功能。假设我们有一个 Login 函数,可以实现用户登录功能。它接收用户手机号 + 短信验证码,然后根据手机号从 Redis 中获取保存的验证码(验证码通常是在发送验证码这一操作时保存的),如果 Redis 中验证码与用户输入的验证码相同,则表示用户信息正确,然后生成一个随机 token 作为登录凭证,之后先将 token 写入 Redis 中,再返回给用户,表示登录操作成功。

程序代码实现如下:

 func Login(mobile, smsCode string, rdb *redis.Client, generateToken func(int) (string, error)) (string, error) {ctx := context.Background()// 查找验证码captcha, err := GetSmsCaptchaFromRedis(ctx, rdb, mobile)if err != nil {if err == redis.Nil {return "", fmt.Errorf("invalid sms code or expired")}return "", err}if captcha != smsCode {return "", fmt.Errorf("invalid sms code")}// 登录,生成 token 并写入 Redistoken, _ := generateToken(32)err = SetAuthTokenToRedis(ctx, rdb, token, mobile)if err != nil {return "", err}return token, nil}

Login 函数有 4 个参数,分别是用户手机号、验证码、Redis 客户端连接对象、辅助生成随机 token 的函数。

Redis 客户端连接对象 *redis.Client 属于 github.com/redis/go-redis/v9 包。

我们可以使用如下方式获得:

 func NewRedisClient() *redis.Client {return redis.NewClient(&redis.Options{Addr:     "localhost:6379",})}

generateToken 用来生成随机长度 token,定义如下:

 func GenerateToken(length int) (string, error) {token := make([]byte, length)_, err := rand.Read(token)if err != nil {return "", err}return base64.URLEncoding.EncodeToString(token)[:length], nil}

 我们还要为 Redis 操作编写几个函数,用来存取 Redis 中的验证码和 token:

var (smsCaptchaExpire    = 5 * time.MinutesmsCaptchaKeyPrefix = "sms:captcha:%s"authTokenExpire    = 24 * time.HourauthTokenKeyPrefix = "auth:token:%s")func SetSmsCaptchaToRedis(ctx context.Context, redis *redis.Client, mobile, captcha string) error {key := fmt.Sprintf(smsCaptchaKeyPrefix, mobile)return redis.Set(ctx, key, captcha, smsCaptchaExpire).Err()}func GetSmsCaptchaFromRedis(ctx context.Context, redis *redis.Client, mobile string) (string, error) {key := fmt.Sprintf(smsCaptchaKeyPrefix, mobile)return redis.Get(ctx, key).Result()}func SetAuthTokenToRedis(ctx context.Context, redis *redis.Client, token, mobile string) error {key := fmt.Sprintf(authTokenKeyPrefix, mobile)return redis.Set(ctx, key, token, authTokenExpire).Err()}func GetAuthTokenFromRedis(ctx context.Context, redis *redis.Client, token string) (string, error) {key := fmt.Sprintf(authTokenKeyPrefix, token)return redis.Get(ctx, key).Result()}

 Login 函数使用方式如下:

 func main() {rdb := NewRedisClient()token, err := Login("13800001111", "123456", rdb, GenerateToken)if err != nil {fmt.Println(err)return}fmt.Println(token)}

使用 redismock 测试

现在,我们要对 Login 函数进行单元测试

Login 函数依赖了 *redis.Client 以及 generateToken 函数。

由于我们设计的代码是 Login 函数直接依赖了 *redis.Client,没有通过接口来解耦,所以不能使用 gomock 工具来生成 Mock 代码。

不过,我们可以看看 go-redis 包的源码仓库有没有什么线索。

很幸运,在 go-redis 包的 README.md 文档里,我们可以看到一个 Redis Mock 链接:

 

 

点击进去,我们就来到了一个叫 redismock 的仓库,redismock 为我们实现了一个模拟的 Redis 客户端。

使用如下方式安装 redismock:

 $ go get github.com/go-redis/redismock/v9

使用如下方式导入 redismock:

 import "github.com/go-redis/redismock/v9"

切记安装和导入的 redismock 包版本要与 go-redis 包版本一致,这里都为 v9。

可以通过如下方式快速创建一个 Redis 客户端 rdb,以及客户端 Mock 对象 mock:

rdb, mock := redismock.NewClientMock()

在测试代码中,调用 Login 函数时,就可以使用这个 rdb 作为 Redis 客户端了。

mock 对象提供了 ExpectXxx 方法,用来指定 rdb 客户端预期会调用哪些方法以及对应参数。

 // login successmock.ExpectGet("sms:captcha:13800138000").SetVal("123456")mock.ExpectSet("auth:token:Ta5EVtRgUD-HFmRwrujAwKZnx247lFfe", "13800138000", 24*time.Hour).SetVal("OK")

mock.ExpectGet 表示期待一个 Redis Get 操作,Key 为 sms:captcha:13800138000,SetVal("123456") 用来设置当前 Get 操作返回值为 123456。

同理,mock.ExpectSet 表示期待一个 Redis Set 操作,Key 为 auth:token:Ta5EVtRgUD-HFmRwrujAwKZnx247lFfe,Value 为 13800138000,过期时间为 24*time.Hour,返回 OK 表示这个 Set 操作成功。

以上指定的两个预期方法调用,是用来匹配 Login 成功时的用例。

Login 函数还有两种失败情况,当通过 GetSmsCaptchaFromRedis 函数查询 Redis 中验证码不存在时,返回 invalid sms code or expired 错误。当从 Redis 中查询的验证码与用户传递进来的验证码不匹配时,返回 invalid sms code 错误。

这两种用例可以按照如下方式模拟:

// invalid sms code or expiredmock.ExpectGet("sms:captcha:13900139000").RedisNil()// invalid sms codemock.ExpectGet("sms:captcha:13700137000").SetVal("123123")

现在,我们已经解决了 Redis 依赖,还需要解决 generateToken 函数依赖。

这时候 Fake object 就派上用场了:

func fakeGenerateToken(int) (string, error) {return "Ta5EVtRgUD-HFmRwrujAwKZnx247lFfe", nil}

我们使用 fakeGenerateToken 函数来替代 GenerateToken 函数,这样生成的 token 就固定下来了,方便测试。

Login 函数完整单元测试代码实现如下:

 func TestLogin(t *testing.T) {// mock redis clientrdb, mock := redismock.NewClientMock()// login successmock.ExpectGet("sms:captcha:13800138000").SetVal("123456")mock.ExpectSet("auth:token:Ta5EVtRgUD-HFmRwrujAwKZnx247lFfe", "13800138000", 24*time.Hour).SetVal("OK")// invalid sms code or expiredmock.ExpectGet("sms:captcha:13900139000").RedisNil()// invalid sms codemock.ExpectGet("sms:captcha:13700137000").SetVal("123123")type args struct {mobile  stringsmsCode string}tests := []struct {name    stringargs    argswant    stringwantErr string}{{name: "login success",args: args{mobile:  "13800138000",smsCode: "123456",},want: "Ta5EVtRgUD-HFmRwrujAwKZnx247lFfe",},{name: "invalid sms code or expired",args: args{mobile:  "13900139000",smsCode: "123459",},wantErr: "invalid sms code or expired",},{name: "invalid sms code",args: args{mobile:  "13700137000",smsCode: "123457",},wantErr: "invalid sms code",},}for _, tt := range tests {t.Run(tt.name, func(t *testing.T) {got, err := Login(tt.args.mobile, tt.args.smsCode, rdb, fakeGenerateToken)if tt.wantErr != "" {assert.Error(t, err)assert.Equal(t, tt.wantErr, err.Error())} else {assert.NoError(t, err)assert.Equal(t, tt.want, got)}})}}

这里使用了表格测试,提供了 3 个测试用例,覆盖了登录成功、验证码无效或过期、验证码无效 3 种场景。

使用 go test 来执行测试函数:

$ go test -v .                 === RUN   TestLogin=== RUN   TestLogin/login_success=== RUN   TestLogin/invalid_sms_code_or_expired=== RUN   TestLogin/invalid_sms_code--- PASS: TestLogin (0.00s)--- PASS: TestLogin/login_success (0.00s)--- PASS: TestLogin/invalid_sms_code_or_expired (0.00s)--- PASS: TestLogin/invalid_sms_code (0.00s)PASSok      github.com/jianghushinian/blog-go-example/test/redis    0.152s

测试通过。

Login 函数将 *redis.Client 和 generateToken 这两个外部依赖定义成了函数参数,而不是在函数内部直接使用这两个依赖。

这主要参考了「依赖注入」的思想,将依赖当作参数传入,而不是在函数内部直接引用。

这样,我们才有机会使用 Fake 对象 fakeGenerateToken 来替代真实对象 GenerateToken。

而对于 *redis.Client,我们也能够使用 redismock 提供的 Mock 对象来替代。

redismock 不仅能够模拟 RedisClient,它还支持模拟 RedisCluster,更多使用示例可以在官方示例中查看。

使用 Testcontainers 测试

虽然我们使用 redismock 提供的 Mock 对象解决了 Login 函数对 *redis.Client 的依赖问题。

但这需要运气,当我们使用其他数据库时,也许找不到现成的 Mock 库。

此时,我们还有另一个强大的工具「容器」可以使用。

如果程序所依赖的某个外部服务,实在找不到现成的 Mock 工具,自己实现 Fack object 又比较麻烦,这时就可以考虑使用容器来运行一个真正的外部服务了。

Testcontainers 就是用来解决这个问题的,我们可以用它来启动容器,运行任何外部服务。

Testcontainers 非常强大,不仅支持 Go 语言,还支持 JavaPython、Rust 等其他主流编程语言。它可以很容易地创建和清理基于容器的依赖,常被用于集成测试和冒烟测试。所以这也提醒我们在单元测试中慎用,因为容器也是一个外部依赖。

我们可以按照如下方式使用 Testcontainers 在容器中启动一个 Redis 服务:

 import ("context""fmt""github.com/redis/go-redis/v9""github.com/testcontainers/testcontainers-go""github.com/testcontainers/testcontainers-go/wait")// 在容器中运行一个 Redis 服务func RunWithRedisInContainer() (*redis.Client, func()) {ctx := context.Background()// 创建容器请求参数req := testcontainers.ContainerRequest{Image:        "redis:6.0.20-alpine",                      // 指定容器镜像ExposedPorts: []string{"6379/tcp"},                       // 指定容器暴露端口WaitingFor:   wait.ForLog("Ready to accept connections"), // 等待输出容器 Ready 日志}// 创建 Redis 容器redisC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: req,Started:          true,})if err != nil {panic(fmt.Sprintf("failed to start container: %s", err.Error()))}// 获取容器中 Redis 连接地址,e.g. localhost:50351endpoint, err := redisC.Endpoint(ctx, "") // 如果暴露多个端口,可以指定第二个参数if err != nil {panic(fmt.Sprintf("failed to get endpoint: %s", err.Error()))}// 连接容器中的 Redisclient := redis.NewClient(&redis.Options{Addr: endpoint,})// 返回 Redis Client 和 cleanup 函数return client, func() {if err := redisC.Terminate(ctx); err != nil {panic(fmt.Sprintf("failed to terminate container: %s", err.Error()))}}}

代码中我写了比较详细的注释,就不带大家一一解释代码内容了。

我们可以将容器的启动和释放操作放到 TestMain 函数中,这样在执行测试函数之前先启动容器,然后进行测试,最后在测试结束时销毁容器。

var rdbClient *redis.Clientfunc TestMain(m *testing.M) {client, f := RunWithRedisInContainer()defer f()rdbClient = clientm.Run()}

使用容器编写的 Login 单元测试函数如下:

func TestLogin_by_container(t *testing.T) {// 准备测试数据err := SetSmsCaptchaToRedis(context.Background(), rdbClient, "18900001111", "123456")assert.NoError(t, err)// 测试登录成功情况gotToken, err := Login("18900001111", "123456", rdbClient, GenerateToken)assert.NoError(t, err)assert.Equal(t, 32, len(gotToken))// 检查 Redis 中是否存在 tokengotMobile, err := GetAuthTokenFromRedis(context.Background(), rdbClient, gotToken)assert.NoError(t, err)assert.Equal(t, "18900001111", gotMobile)}

现在因为有了容器的存在,我们有了一个真实的 Redis 服务。所以编写测试代码时,无需再考虑如何模拟 Redis 客户端,只需要使用通过 RunWithRedisInContainer() 函数创建的真实客户端 rdbClient 即可,一切操作都是真实的。

并且,我们也不再需要实现 fakeGenerateToken 函数来固定生成的 token,直接使用 GenerateToken 生成真实的随机 token 即可。想要验证得到的 token 是否正确,可以直接从 Redis 服务中读取。

执行测试前,确保主机上已经安装了 Docker,Testcontainers 会使用主机上的 Docker 来运行容器。

根据输出日志可以发现,我们的确在主机上创建了一个 Redis 容器来运行 Redis 服务:

 Creating container for image redis:6.0.20-alpine

容器 ID 为 2b5e40d40af0:

Container created: 2b5e40d40af0

 并且测试结束后清理了容器:

 Container terminated: 2b5e40d40af0

以上,我们就利用容器技术,为 Login 函数登录成功情况编写了一个测试用例,登录失败情况的测试用例就留做作业交给你自己来完成吧。

总结

本文向大家介绍了在 Go 中编写单元测试时,如何解决 Redis 外部依赖的问题。

值得庆幸的是 redismock 包提供了模拟的 Redis 客户端,方便我们在测试过程中替换 Redis 外部依赖。

但有些时候,我们可能找不到这种现成的第三方包。Testcontainers 库则为我们提供了另一种解决方案,运行一个真实的容器,以此来提供 Redis 服务。

不过,虽然 Testcontainers 足够强大,但不到万不得已,不推荐使用。毕竟我们又引入了容器这个外部依赖,如果网络情况不好,如何拉取 Redis 镜像也是需要解决的问题。

更好的解决办法,是我们在编写代码时,就要考虑如何写出可测试的代码,好的代码设计,能够大大降低编写测试的难度。

最后感谢每一个认真阅读我文章的人,礼尚往来总是要有的,虽然不是什么很值钱的东西,如果你用得到的话可以直接拿走:

这些资料,对于【软件测试】的朋友来说应该是最全面最完整的备战仓库,这个仓库也陪伴上万个测试工程师们走过最艰难的路程,希望也能帮助到你!有需要的小伙伴可以点击下方小卡片领取  

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/60562.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

[C#] 简单的俄罗斯方块实现

一个控制台俄罗斯方块游戏的简单实现. 已在 github.com/SlimeNull/Tetris 开源. 思路 很简单, 一个二维数组存储当前游戏的方块地图, 用 bool 即可, true 表示当前块被填充, false 表示没有. 然后, 抽一个 “形状” 类, 形状表示当前玩家正在操作的一个形状, 例如方块, 直线…

Pycharm如何打断点进行调试?

断点调试,是编写程序中一个很重要的步骤,有些简单的程序使用print语句就可看出问题,而比较复杂的程序,函数和变量较多的情况下,这时候就需要打断点了,更容易定位问题。 一、添加断点 在代码的行标前面&…

吐血整理,Jenkins配置邮件发送测试报告持续集成,看这一篇就够了...

目录:导读 前言一、Python编程入门到精通二、接口自动化项目实战三、Web自动化项目实战四、App自动化项目实战五、一线大厂简历六、测试开发DevOps体系七、常用自动化测试工具八、JMeter性能测试九、总结(尾部小惊喜) 前言 开启SMTP服务 这…

Linux下进程的特点与环境变量

目录 进程的特点 进程特点的介绍 进程时如何实现并发性的 进程间如何切换 概念铺设 PC指针 上下文 环境变量 PATH 修改PATH HOME SHELL env 命令行参数 什么是命令行参数? 打印命令行参数 通过函数获得环境变量 getenv 命令行参数 env 修改环境变…

C 语言的逻辑运算符

C 语言的逻辑运算符包括三种: 逻辑运算符可以将两个关系表达式连接起来. Suppose exp1 and exp2 are two simple relational expressions, such as cat > rat and debt 1000 . Then you can state the following: ■ exp1 && exp2 is true only if bo…

百度chatgpt内测版

搜索AI伙伴 申请到了百度的chatgpt: 完整的窗口布局: 三个哲学问题: 灵感中心: 请做一副画,一个渔夫,冬天,下着大雪,在船上为了一家的生计在钓鱼,远处的山上也都是白雪&a…

北京多铁克FPGA笔试题目

1、使用D触发器来实现二分频 2、序列检测器,检测101,输出1,其余情况输出0 module Detect_101(input clk,input rst_n,input data, //输入的序列output reg flag_101 //检测到101序列的输出标志 );parameter S0 2d0;S1 2d1;S2 2d2;S4 …

W6100-EVB-PICO作为TCP Client 进行数据回环测试(五)

前言 上一章我们用W6100-EVB-PICO开发板通过DNS解析www.baidu.com(百度域名)成功得到其IP地址,那么本章我们将用我们的开发板作为客户端去连接服务器,并做数据回环测试:收到服务器发送的数据,并回传给服务器…

android开发之Android 自定义滑动解锁View

自定义滑动解锁View 需求如下: 近期需要做一个类似屏幕滑动解锁的功能,右划开始,左划暂停。 需求效果图如下 实现效果展示 自定义view如下 /** Desc 自定义滑动解锁View Author ZY Mail sunnyfor98gmail.com Date 2021/5/17 11:52 *…

【LeetCode】打家劫舍||

打家劫舍|| 题目描述算法分析编程代码 链接: 打家劫舍|| 在做这个题之前&#xff0c;建议大家做一下这个链接: 按摩师 我的博客里也有这个题的讲解&#xff0c;名字是按摩师 题目描述 算法分析 编程代码 class Solution { public:int maxrob(vector<int>nums,int left,…

ardupilot 为什么要采样四元数姿态控制

目录 文章目录 目录摘要1.姿态控制为什么要用到四元数2.四元数姿态控制摘要 本节主要说明清楚ardupilot姿态控制为什么要用到四元数,欢迎批评指正!!! 1.姿态控制为什么要用到四元数 对于ardupilot的姿态控制中主要用PID控制算法,姿态控制采用串级PID控制。主要包含:外环…

数据分析-python学习 (1)numpy相关

内容为&#xff1a;https://juejin.cn/book/7240731597035864121的学习笔记 导包 import numpy as np numpy数组创建 创建全0数组&#xff0c;正态分布、随机数组等就不说了&#xff0c;提供了相应的方法通过已有数据创建有两种 arr1np.array([1,2,3,4,5]) 或者datanp.loadt…