單元測試中如何解決 Redis 存儲依賴問題
在編寫單元測試時,除了 MySQL 這個外部存儲依賴,Redis 應該是另一個最爲常見的外部存儲依賴了。我在《在 Go 語言單元測試中如何解決 MySQL 存儲依賴問題》一文中講解了如何解決 MySQL 外部依賴,本文就來講解下如何解決 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 並寫入 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
包。
我們可以使用如下方式獲得:
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.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
函數使用方式如下:
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 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: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 expired
mock.ExpectGet("sms:captcha:13900139000").RedisNil()
// invalid sms code
mock.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 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
來執行測試函數:
$ 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.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 語言,還支持 Java、Python、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: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
函數中,這樣在執行測試函數之前先啓動容器,然後進行測試,最後在測試結束時銷燬容器。
var rdbClient *redis.Client
func TestMain(m *testing.M) {
client, f := RunWithRedisInContainer()
defer f()
rdbClient = client
m.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 中是否存在 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
來執行測試函數:
$ 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 服務:
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 鏡像也是需要解決的問題。
更好的解決辦法,是我們在編寫代碼時,就要考慮如何寫出可測試的代碼,好的代碼設計,能夠大大降低編寫測試的難度。
本文完整代碼示例我放在了 GitHub 上,歡迎點擊查看。
希望此文能對你有所幫助。
參考
-
Redis client for Go:https://github.com/redis/go-redis
-
Redis client Mock:https://github.com/go-redis/redismock
-
Testcontainers:https://github.com/testcontainers/testcontainers-go
-
Testcontainers 文檔:https://golang.testcontainers.org/quickstart/
-
本文 GitHub 源碼:https://github.com/jianghushinian/blog-go-example/tree/main/test/redis
聯繫我
微信:jianghushinian
郵箱:jianghushinian007@outlook.com
博客地址:https://jianghushinian.cn
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/iOOBkatKltRxMtB6tFhFNw