詳解在Go語言單元測試中如何解決Redis存儲依賴問題
登錄程序示例
在 Web 開發(fā)中,登錄需求是一個較為常見的功能。假設(shè)我們有一個 Login
函數(shù),可以實現(xiàn)用戶登錄功能。它接收用戶手機號 + 短信驗證碼,然后根據(jù)手機號從 Redis 中獲取保存的驗證碼(驗證碼通常是在發(fā)送驗證碼這一操作時保存的),如果 Redis 中驗證碼與用戶輸入的驗證碼相同,則表示用戶信息正確,然后生成一個隨機 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ù),分別是用戶手機號、驗證碼、Redis 客戶端連接對象、輔助生成隨機 token 的函數(shù)。
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 操作編寫幾個函數(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ù)進行單元測試。
Login
函數(shù)依賴了 *redis.Client
以及 generateToken
函數(shù)。
由于我們設(shè)計的代碼是 Login
函數(shù)直接依賴了 *redis.Client
,沒有通過接口來解耦,所以不能使用 gomock
工具來生成 Mock 代碼。
不過,我們可以看看 go-redis
包的源碼倉庫有沒有什么線索。
很幸運,在 go-redis
包的 README.md 文檔里,我們可以看到一個 Redis Mock 鏈接:
點擊進去,我們就來到了一個叫 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 中查詢的驗證碼與用戶傳遞進來的驗證碼不匹配時,返回 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)部直接引用。
這樣,我們才有機會使用 Fake 對象 fakeGenerateToken
來替代真實對象 GenerateToken
。
而對于 *redis.Client
,我們也能夠使用 redismock
提供的 Mock 對象來替代。
redismock
不僅能夠模擬 RedisClient,它還支持模擬 RedisCluster,更多使用示例可以在官方示例中查看。
使用 Testcontainers 測試
雖然我們使用 redismock
提供的 Mock 對象解決了 Login
函數(shù)對 *redis.Client
的依賴問題。
但這需要運氣,當(dāng)我們使用其他數(shù)據(jù)庫時,也許找不到現(xiàn)成的 Mock 庫。
此時,我們還有另一個強大的工具「容器」可以使用。
如果程序所依賴的某個外部服務(wù),實在找不到現(xiàn)成的 Mock 工具,自己實現(xiàn) Fack object 又比較麻煩,這時就可以考慮使用容器來運行一個真正的外部服務(wù)了。
Testcontainers 就是用來解決這個問題的,我們可以用它來啟動容器,運行任何外部服務(wù)。
Testcontainers
非常強大,不僅支持 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" ) // 在容器中運行一個 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())) } } }
代碼中我寫了比較詳細的注釋,就不帶大家一一解釋代碼內(nèi)容了。
我們可以將容器的啟動和釋放操作放到 TestMain
函數(shù)中,這樣在執(zhí)行測試函數(shù)之前先啟動容器,然后進行測試,最后在測試結(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
生成真實的隨機 token 即可。想要驗證得到的 token 是否正確,可以直接從 Redis 服務(wù)中讀取。
執(zhí)行測試前,確保主機上已經(jīng)安裝了 Docker, Testcontainers
會使用主機上的 Docker 來運行容器。
使用 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),我們的確在主機上創(chuàng)建了一個 Redis 容器來運行 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
庫則為我們提供了另一種解決方案,運行一個真實的容器,以此來提供 Redis 服務(wù)。
不過,雖然 Testcontainers
足夠強大,但不到萬不得已,不推薦使用。畢竟我們又引入了容器這個外部依賴,如果網(wǎng)絡(luò)情況不好,如何拉取 Redis 鏡像也是需要解決的問題。
更好的解決辦法,是我們在編寫代碼時,就要考慮如何寫出可測試的代碼,好的代碼設(shè)計,能夠大大降低編寫測試的難度。
以上就是詳解在Go語言單元測試中如何解決Redis存儲依賴問題的詳細內(nèi)容,更多關(guān)于Go單元測試解決Redis存儲依賴的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
詳解Go語言中關(guān)于包導(dǎo)入必學(xué)的 8 個知識點
這篇文章主要介紹了詳解Go語言中關(guān)于包導(dǎo)入必學(xué)的 8 個知識點,文中通過示例代碼介紹的非常詳細,對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2020-08-08Go實現(xiàn)自己的網(wǎng)絡(luò)流量解析和行為檢測引擎原理
這篇文章主要為大家介紹了Go實現(xiàn)自己的網(wǎng)絡(luò)流量解析和行為檢測引擎原理,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-11-11