詳解在Go語言單元測試中如何解決Redis存儲依賴問題
登錄程序示例
在 Web 開發(fā)中,登錄需求是一個(gè)較為常見的功能。假設(shè)我們有一個(gè) Login 函數(shù),可以實(shí)現(xiàn)用戶登錄功能。它接收用戶手機(jī)號 + 短信驗(yàn)證碼,然后根據(jù)手機(jī)號從 Redis 中獲取保存的驗(yàn)證碼(驗(yàn)證碼通常是在發(fā)送驗(yàn)證碼這一操作時(shí)保存的),如果 Redis 中驗(yàn)證碼與用戶輸入的驗(yàn)證碼相同,則表示用戶信息正確,然后生成一個(gè)隨機(jī) token 作為登錄憑證,之后先將 token 寫入 Redis 中,再返回給用戶,表示登錄操作成功。
程序代碼實(shí)現(xiàn)如下:
func Login(mobile, smsCode string, rdb *redis.Client, generateToken func(int) (string, error)) (string, error) {
ctx := context.Background()
// 查找驗(yàn)證碼
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 個(gè)參數(shù),分別是用戶手機(jī)號、驗(yàn)證碼、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 操作編寫幾個(gè)函數(shù),用來存取 Redis 中的驗(yàn)證碼和 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è)計(jì)的代碼是 Login 函數(shù)直接依賴了 *redis.Client ,沒有通過接口來解耦,所以不能使用 gomock 工具來生成 Mock 代碼。
不過,我們可以看看 go-redis 包的源碼倉庫有沒有什么線索。
很幸運(yùn),在 go-redis 包的 README.md 文檔里,我們可以看到一個(gè) Redis Mock 鏈接:

點(diǎn)擊進(jìn)去,我們就來到了一個(gè)叫 redismock 的倉庫, redismock 為我們實(shí)現(xiàn)了一個(gè)模擬的 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)建一個(gè) Redis 客戶端 rdb ,以及客戶端 Mock 對象 mock :
rdb, mock := redismock.NewClientMock()
在測試代碼中,調(diào)用 Login 函數(shù)時(shí),就可以使用這個(gè) 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 表示期待一個(gè) Redis Get 操作,Key 為 sms:captcha:13800138000 , SetVal("123456") 用來設(shè)置當(dāng)前 Get 操作返回值為 123456 。
同理, mock.ExpectSet 表示期待一個(gè) Redis Set 操作,Key 為 auth:token:Ta5EVtRgUD-HFmRwrujAwKZnx247lFfe ,Value 為 13800138000 ,過期時(shí)間為 24*time.Hour ,返回 OK 表示這個(gè) Set 操作成功。
以上指定的兩個(gè)預(yù)期方法調(diào)用,是用來匹配 Login 成功時(shí)的用例。
Login 函數(shù)還有兩種失敗情況,當(dāng)通過 GetSmsCaptchaFromRedis 函數(shù)查詢 Redis 中驗(yàn)證碼不存在時(shí),返回 invalid sms code or expired 錯(cuò)誤。當(dāng)從 Redis 中查詢的驗(yàn)證碼與用戶傳遞進(jìn)來的驗(yàn)證碼不匹配時(shí),返回 invalid sms code 錯(cuò)誤。
這兩種用例可以按照如下方式模擬:
// 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ù)依賴。
這時(shí)候 Fake object 就派上用場了:
func fakeGenerateToken(int) (string, error) {
return "Ta5EVtRgUD-HFmRwrujAwKZnx247lFfe", nil
}我們使用 fakeGenerateToken 函數(shù)來替代 GenerateToken 函數(shù),這樣生成的 token 就固定下來了,方便測試。
Login 函數(shù)完整單元測試代碼實(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 個(gè)測試用例,覆蓋了登錄成功、驗(yàn)證碼無效或過期、驗(yàn)證碼無效 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 這兩個(gè)外部依賴定義成了函數(shù)參數(shù),而不是在函數(shù)內(nèi)部直接使用這兩個(gè)依賴。
這主要參考了「依賴注入」的思想,將依賴當(dāng)作參數(shù)傳入,而不是在函數(shù)內(nèi)部直接引用。
這樣,我們才有機(jī)會使用 Fake 對象 fakeGenerateToken 來替代真實(shí)對象 GenerateToken 。
而對于 *redis.Client ,我們也能夠使用 redismock 提供的 Mock 對象來替代。
redismock 不僅能夠模擬 RedisClient,它還支持模擬 RedisCluster,更多使用示例可以在官方示例中查看。
使用 Testcontainers 測試
雖然我們使用 redismock 提供的 Mock 對象解決了 Login 函數(shù)對 *redis.Client 的依賴問題。
但這需要運(yùn)氣,當(dāng)我們使用其他數(shù)據(jù)庫時(shí),也許找不到現(xiàn)成的 Mock 庫。
此時(shí),我們還有另一個(gè)強(qiáng)大的工具「容器」可以使用。
如果程序所依賴的某個(gè)外部服務(wù),實(shí)在找不到現(xiàn)成的 Mock 工具,自己實(shí)現(xiàn) Fack object 又比較麻煩,這時(shí)就可以考慮使用容器來運(yùn)行一個(gè)真正的外部服務(wù)了。
Testcontainers 就是用來解決這個(gè)問題的,我們可以用它來啟動(dòng)容器,運(yùn)行任何外部服務(wù)。
Testcontainers 非常強(qiáng)大,不僅支持 Go 語言,還支持 Java、Python、Rust 等其他主流編程語言。它可以很容易地創(chuàng)建和清理基于容器的依賴,常被用于集成測試和冒煙測試。所以這也提醒我們在單元測試中慎用,因?yàn)槿萜饕彩且粋€(gè)外部依賴。
我們可以按照如下方式使用 Testcontainers 在容器中啟動(dòng)一個(gè) Redis 服務(wù):
import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
// 在容器中運(yùn)行一個(gè) 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, "") // 如果暴露多個(gè)端口,可以指定第二個(gè)參數(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)容了。
我們可以將容器的啟動(dòng)和釋放操作放到 TestMain 函數(shù)中,這樣在執(zhí)行測試函數(shù)之前先啟動(dòng)容器,然后進(jìn)行測試,最后在測試結(jié)束時(shí)銷毀容器。
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)在因?yàn)橛辛巳萜鞯拇嬖?,我們有了一個(gè)真實(shí)的 Redis 服務(wù)。所以編寫測試代碼時(shí),無需再考慮如何模擬 Redis 客戶端,只需要使用通過 RunWithRedisInContainer() 函數(shù)創(chuàng)建的真實(shí)客戶端 rdbClient 即可,一切操作都是真實(shí)的。
并且,我們也不再需要實(shí)現(xiàn) fakeGenerateToken 函數(shù)來固定生成的 token,直接使用 GenerateToken 生成真實(shí)的隨機(jī) token 即可。想要驗(yàn)證得到的 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)建了一個(gè) 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ù)登錄成功情況編寫了一個(gè)測試用例,登錄失敗情況的測試用例就留做作業(yè)交給你自己來完成吧。
總結(jié)
本文向大家介紹了在 Go 中編寫單元測試時(shí),如何解決 Redis 外部依賴的問題。
值得慶幸的是 redismock 包提供了模擬的 Redis 客戶端,方便我們在測試過程中替換 Redis 外部依賴。
但有些時(shí)候,我們可能找不到這種現(xiàn)成的第三方包。 Testcontainers 庫則為我們提供了另一種解決方案,運(yùn)行一個(gè)真實(shí)的容器,以此來提供 Redis 服務(wù)。
不過,雖然 Testcontainers 足夠強(qiáng)大,但不到萬不得已,不推薦使用。畢竟我們又引入了容器這個(gè)外部依賴,如果網(wǎng)絡(luò)情況不好,如何拉取 Redis 鏡像也是需要解決的問題。
更好的解決辦法,是我們在編寫代碼時(shí),就要考慮如何寫出可測試的代碼,好的代碼設(shè)計(jì),能夠大大降低編寫測試的難度。
以上就是詳解在Go語言單元測試中如何解決Redis存儲依賴問題的詳細(xì)內(nèi)容,更多關(guān)于Go單元測試解決Redis存儲依賴的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
詳解Go語言中關(guān)于包導(dǎo)入必學(xué)的 8 個(gè)知識點(diǎn)
這篇文章主要介紹了詳解Go語言中關(guān)于包導(dǎo)入必學(xué)的 8 個(gè)知識點(diǎn),文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2020-08-08
Go實(shí)現(xiàn)自己的網(wǎng)絡(luò)流量解析和行為檢測引擎原理
這篇文章主要為大家介紹了Go實(shí)現(xiàn)自己的網(wǎng)絡(luò)流量解析和行為檢測引擎原理,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-11-11

