詳解在Go語言單元測試中如何解決Redis存儲依賴問題
登錄程序示例
在 Web 開發(fā)中,登錄需求是一個較為常見的功能。假設(shè)我們有一個 Login 函數(shù),可以實現(xiàn)用戶登錄功能。它接收用戶手機(jī)號 + 短信驗證碼,然后根據(jù)手機(jī)號從 Redis 中獲取保存的驗證碼(驗證碼通常是在發(fā)送驗證碼這一操作時保存的),如果 Redis 中驗證碼與用戶輸入的驗證碼相同,則表示用戶信息正確,然后生成一個隨機(jī) token 作為登錄憑證,之后先將 token 寫入 Redis 中,再返回給用戶,表示登錄操作成功。
程序代碼實現(xiàn)如下:
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 函數(shù)有 4 個參數(shù),分別是用戶手機(jī)號、驗證碼、Redis 客戶端連接對象、輔助生成隨機(jī) token 的函數(shù)。
Redis 客戶端連接對象 *redis.Client 屬于 github.com/redis/go-redis/v9 包。
我們可以使用如下方式獲得:
func NewRedisClient() *redis.Client {
return redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
}generateToken 用來生成隨機(jī)長度 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 操作編寫幾個函數(shù),用來存取 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 函數(shù)使用方式如下:
func main() {
rdb := NewRedisClient()
token, err := Login("13800001111", "123456", rdb, GenerateToken)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(token)
}使用 redismock 測試
現(xiàn)在,我們要對 Login 函數(shù)進(jìn)行單元測試。
Login 函數(shù)依賴了 *redis.Client 以及 generateToken 函數(shù)。
由于我們設(shè)計的代碼是 Login 函數(shù)直接依賴了 *redis.Client ,沒有通過接口來解耦,所以不能使用 gomock 工具來生成 Mock 代碼。
不過,我們可以看看 go-redis 包的源碼倉庫有沒有什么線索。
很幸運(yùn),在 go-redis 包的 README.md 文檔里,我們可以看到一個 Redis Mock 鏈接:

點(diǎn)擊進(jìn)去,我們就來到了一個叫 redismock 的倉庫, redismock 為我們實現(xiàn)了一個模擬的 Redis 客戶端。
使用如下方式安裝 redismock :
$ go get github.com/go-redis/redismock/v9
使用如下方式導(dǎo)入 redismock :
import "github.com/go-redis/redismock/v9"
切記安裝和導(dǎo)入的 redismock 包版本要與 go-redis 包版本一致,這里都為 v9 。
可以通過如下方式快速創(chuàng)建一個 Redis 客戶端 rdb ,以及客戶端 Mock 對象 mock :
rdb, mock := redismock.NewClientMock()
在測試代碼中,調(diào)用 Login 函數(shù)時,就可以使用這個 rdb 作為 Redis 客戶端了。
mock 對象提供了 ExpectXxx 方法,用來指定 rdb 客戶端預(yù)期會調(diào)用哪些方法以及對應(yīng)參數(shù)。
// 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") 用來設(shè)置當(dāng)前 Get 操作返回值為 123456 。
同理, mock.ExpectSet 表示期待一個 Redis Set 操作,Key 為 auth:token:Ta5EVtRgUD-HFmRwrujAwKZnx247lFfe ,Value 為 13800138000 ,過期時間為 24*time.Hour ,返回 OK 表示這個 Set 操作成功。
以上指定的兩個預(yù)期方法調(diào)用,是用來匹配 Login 成功時的用例。
Login 函數(shù)還有兩種失敗情況,當(dāng)通過 GetSmsCaptchaFromRedis 函數(shù)查詢 Redis 中驗證碼不存在時,返回 invalid sms code or expired 錯誤。當(dāng)從 Redis 中查詢的驗證碼與用戶傳遞進(jìn)來的驗證碼不匹配時,返回 invalid sms code 錯誤。
這兩種用例可以按照如下方式模擬:
// invalid sms code or expired
mock.ExpectGet("sms:captcha:13900139000").RedisNil()
// invalid sms code
mock.ExpectGet("sms:captcha:13700137000").SetVal("123123")現(xiàn)在,我們已經(jīng)解決了 Redis 依賴,還需要解決 generateToken 函數(shù)依賴。
這時候 Fake object 就派上用場了:
func fakeGenerateToken(int) (string, error) {
return "Ta5EVtRgUD-HFmRwrujAwKZnx247lFfe", nil
}我們使用 fakeGenerateToken 函數(shù)來替代 GenerateToken 函數(shù),這樣生成的 token 就固定下來了,方便測試。
Login 函數(shù)完整單元測試代碼實現(xiàn)如下:
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 來執(zhí)行測試函數(shù):
$ 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 函數(shù)將 *redis.Client 和 generateToken 這兩個外部依賴定義成了函數(shù)參數(shù),而不是在函數(shù)內(nèi)部直接使用這兩個依賴。
這主要參考了「依賴注入」的思想,將依賴當(dāng)作參數(shù)傳入,而不是在函數(shù)內(nèi)部直接引用。
這樣,我們才有機(jī)會使用 Fake 對象 fakeGenerateToken 來替代真實對象 GenerateToken 。
而對于 *redis.Client ,我們也能夠使用 redismock 提供的 Mock 對象來替代。
redismock 不僅能夠模擬 RedisClient,它還支持模擬 RedisCluster,更多使用示例可以在官方示例中查看。
使用 Testcontainers 測試
雖然我們使用 redismock 提供的 Mock 對象解決了 Login 函數(shù)對 *redis.Client 的依賴問題。
但這需要運(yùn)氣,當(dāng)我們使用其他數(shù)據(jù)庫時,也許找不到現(xiàn)成的 Mock 庫。
此時,我們還有另一個強(qiáng)大的工具「容器」可以使用。
如果程序所依賴的某個外部服務(wù),實在找不到現(xiàn)成的 Mock 工具,自己實現(xiàn) Fack object 又比較麻煩,這時就可以考慮使用容器來運(yùn)行一個真正的外部服務(wù)了。
Testcontainers 就是用來解決這個問題的,我們可以用它來啟動容器,運(yùn)行任何外部服務(wù)。
Testcontainers 非常強(qiáng)大,不僅支持 Go 語言,還支持 Java、Python、Rust 等其他主流編程語言。它可以很容易地創(chuàng)建和清理基于容器的依賴,常被用于集成測試和冒煙測試。所以這也提醒我們在單元測試中慎用,因為容器也是一個外部依賴。
我們可以按照如下方式使用 Testcontainers 在容器中啟動一個 Redis 服務(wù):
import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
// 在容器中運(yùn)行一個 Redis 服務(wù)
func RunWithRedisInContainer() (*redis.Client, func()) {
ctx := context.Background()
// 創(chuàng)建容器請求參數(shù)
req := testcontainers.ContainerRequest{
Image: "redis:6.0.20-alpine", // 指定容器鏡像
ExposedPorts: []string{"6379/tcp"}, // 指定容器暴露端口
WaitingFor: wait.ForLog("Ready to accept connections"), // 等待輸出容器 Ready 日志
}
// 創(chuàng)建 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, "") // 如果暴露多個端口,可以指定第二個參數(shù)
if err != nil {
panic(fmt.Sprintf("failed to get endpoint: %s", err.Error()))
}
// 連接容器中的 Redis
client := redis.NewClient(&redis.Options{
Addr: endpoint,
})
// 返回 Redis Client 和 cleanup 函數(shù)
return client, func() {
if err := redisC.Terminate(ctx); err != nil {
panic(fmt.Sprintf("failed to terminate container: %s", err.Error()))
}
}
}代碼中我寫了比較詳細(xì)的注釋,就不帶大家一一解釋代碼內(nèi)容了。
我們可以將容器的啟動和釋放操作放到 TestMain 函數(shù)中,這樣在執(zhí)行測試函數(shù)之前先啟動容器,然后進(jìn)行測試,最后在測試結(jié)束時銷毀容器。
var rdbClient *redis.Client
func TestMain(m *testing.M) {
client, f := RunWithRedisInContainer()
defer f()
rdbClient = client
m.Run()
}使用容器編寫的 Login 單元測試函數(shù)如下:
func TestLogin_by_container(t *testing.T) {
// 準(zhǔn)備測試數(shù)據(jù)
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)
}現(xiàn)在因為有了容器的存在,我們有了一個真實的 Redis 服務(wù)。所以編寫測試代碼時,無需再考慮如何模擬 Redis 客戶端,只需要使用通過 RunWithRedisInContainer() 函數(shù)創(chuàng)建的真實客戶端 rdbClient 即可,一切操作都是真實的。
并且,我們也不再需要實現(xiàn) fakeGenerateToken 函數(shù)來固定生成的 token,直接使用 GenerateToken 生成真實的隨機(jī) token 即可。想要驗證得到的 token 是否正確,可以直接從 Redis 服務(wù)中讀取。
執(zhí)行測試前,確保主機(jī)上已經(jīng)安裝了 Docker, Testcontainers 會使用主機(jī)上的 Docker 來運(yùn)行容器。
使用 go test 來執(zhí)行測試函數(shù):
$ 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測試通過。
根據(jù)輸出日志可以發(fā)現(xiàn),我們的確在主機(jī)上創(chuàng)建了一個 Redis 容器來運(yùn)行 Redis 服務(wù):
Creating container for image redis:6.0.20-alpine
容器 ID 為 2b5e40d40af0 :
Container created: 2b5e40d40af0
并且測試結(jié)束后清理了容器:
Container terminated: 2b5e40d40af0
以上,我們就利用容器技術(shù),為 Login 函數(shù)登錄成功情況編寫了一個測試用例,登錄失敗情況的測試用例就留做作業(yè)交給你自己來完成吧。
總結(jié)
本文向大家介紹了在 Go 中編寫單元測試時,如何解決 Redis 外部依賴的問題。
值得慶幸的是 redismock 包提供了模擬的 Redis 客戶端,方便我們在測試過程中替換 Redis 外部依賴。
但有些時候,我們可能找不到這種現(xiàn)成的第三方包。 Testcontainers 庫則為我們提供了另一種解決方案,運(yùn)行一個真實的容器,以此來提供 Redis 服務(wù)。
不過,雖然 Testcontainers 足夠強(qiáng)大,但不到萬不得已,不推薦使用。畢竟我們又引入了容器這個外部依賴,如果網(wǎng)絡(luò)情況不好,如何拉取 Redis 鏡像也是需要解決的問題。
更好的解決辦法,是我們在編寫代碼時,就要考慮如何寫出可測試的代碼,好的代碼設(shè)計,能夠大大降低編寫測試的難度。
以上就是詳解在Go語言單元測試中如何解決Redis存儲依賴問題的詳細(xì)內(nèi)容,更多關(guān)于Go單元測試解決Redis存儲依賴的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Golang連接PostgreSQL基本操作的實現(xiàn)
PostgreSQL是常見的免費(fèi)的大型關(guān)系型數(shù)據(jù)庫,本文主要介紹了Golang連接PostgreSQL基本操作的實現(xiàn),具有一定的參考價值,感興趣的可以了解一下2024-02-02
golang包循環(huán)引用的幾種解決方案總結(jié)
golang有包循環(huán)引用問題,用過的應(yīng)該都知道,下面這篇文章主要給大家介紹了關(guān)于golang包循環(huán)引用的幾種解決方案,文中通過實例代碼介紹的非常詳細(xì),需要的朋友可以參考下2022-09-09
GO語言判斷一個網(wǎng)段是否屬于另一個網(wǎng)段的子網(wǎng)
這篇文章主要介紹了GO語言判斷一個網(wǎng)段是否屬于另一個網(wǎng)段的子網(wǎng)的相關(guān)資料,內(nèi)容介紹詳細(xì),具有一定的參考價值,需要的朋友可任意參考一下2022-03-03

