在编写单元测试时,除了 MySQL 这个外部存储依赖,Redis 应该是另一个最为常见的外部存储依赖了。我在《在 Go 语言单元测试中如何解决 MySQL 存储依赖问题》一文中讲解了如何解决 MySQL 外部依赖,本文就来讲解下如何解决 Redis 外部依赖。

登录程序示例

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

程序代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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 并写入 Redis
token, _ := 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 包。

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

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

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

1
2
3
4
5
6
7
8
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
var (
smsCaptchaExpire = 5 * time.Minute
smsCaptchaKeyPrefix = "sms:captcha:%s"

authTokenExpire = 24 * time.Hour
authTokenKeyPrefix = "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 函数使用方式如下:

1
2
3
4
5
6
7
8
9
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 链接:

Redis Mock
Redis Mock

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

使用如下方式安装 redismock

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

使用如下方式导入 redismock

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

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

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

1
rdb, mock := redismock.NewClientMock()

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

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

1
2
3
// login success
mock.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:13800138000SetVal("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 错误。

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

1
2
3
4
// invalid sms code or expired
mock.ExpectGet("sms:captcha:13900139000").RedisNil()
// invalid sms code
mock.ExpectGet("sms:captcha:13700137000").SetVal("123123")

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
func TestLogin(t *testing.T) {
// mock redis client
rdb, mock := redismock.NewClientMock()

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

// invalid sms code or expired
mock.ExpectGet("sms:captcha:13900139000").RedisNil()

// invalid sms code
mock.ExpectGet("sms:captcha:13700137000").SetVal("123123")

type args struct {
mobile string
smsCode string
}
tests := []struct {
name string
args args
want string
wantErr 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 来执行测试函数:

1
2
3
4
5
6
7
8
9
10
11
$ 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)
PASS
ok github.com/jianghushinian/blog-go-example/test/redis 0.152s

测试通过。

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

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

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

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

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

使用 Testcontainers 测试

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

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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:50351
endpoint, err := redisC.Endpoint(ctx, "") // 如果暴露多个端口,可以指定第二个参数
if err != nil {
panic(fmt.Sprintf("failed to get endpoint: %s", err.Error()))
}

// 连接容器中的 Redis
client := 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 函数中,这样在执行测试函数之前先启动容器,然后进行测试,最后在测试结束时销毁容器。

1
2
3
4
5
6
7
8
var rdbClient *redis.Client

func TestMain(m *testing.M) {
client, f := RunWithRedisInContainer()
defer f()
rdbClient = client
m.Run()
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 中是否存在 token
gotMobile, 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 来运行容器。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ go test -v -run="TestLogin_by_container"
2023/07/17 22:59:34 github.com/testcontainers/testcontainers-go - Connected to docker:
Server Version: 20.10.21
API Version: 1.41
Operating System: Docker Desktop
Total Memory: 7851 MB
2023/07/17 22:59:34 🐳 Creating container for image docker.io/testcontainers/ryuk:0.5.1
2023/07/17 22:59:34 ✅ Container created: 92e327ad7b70
2023/07/17 22:59:34 🐳 Starting container: 92e327ad7b70
2023/07/17 22:59:35 ✅ Container started: 92e327ad7b70
2023/07/17 22:59:35 🚧 Waiting for container id 92e327ad7b70 image: docker.io/testcontainers/ryuk:0.5.1. Waiting for: &{Port:8080/tcp timeout:<nil> PollInterval:100ms}
2023/07/17 22:59:35 🐳 Creating container for image redis:6.0.20-alpine
2023/07/17 22:59:35 ✅ Container created: 2b5e40d40af0
2023/07/17 22:59:35 🐳 Starting container: 2b5e40d40af0
2023/07/17 22:59:35 ✅ Container started: 2b5e40d40af0
2023/07/17 22:59:35 🚧 Waiting for container id 2b5e40d40af0 image: redis:6.0.20-alpine. Waiting for: &{timeout:<nil> Log:Ready to accept connections Occurrence:1 PollInterval:100ms}
=== RUN TestLogin_by_container
--- PASS: TestLogin_by_container (0.00s)
PASS
2023/07/17 22:59:36 🐳 Terminating container: 2b5e40d40af0
2023/07/17 22:59:36 🚫 Container terminated: 2b5e40d40af0
ok github.com/jianghushinian/blog-go-example/test/redis 1.545s

测试通过。

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

1
Creating container for image redis:6.0.20-alpine

容器 ID 为 2b5e40d40af0

1
Container created: 2b5e40d40af0

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

1
Container terminated: 2b5e40d40af0

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

总结

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

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

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

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

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

本文完整代码示例我放在了 GitHub 上,欢迎点击查看。

希望此文能对你有所帮助。

联系我

参考