詳解在Go語(yǔ)言單元測(cè)試中如何解決Redis存儲(chǔ)依賴(lài)問(wèn)題
登錄程序示例
在 Web 開(kāi)發(fā)中,登錄需求是一個(gè)較為常見(jiàn)的功能。假設(shè)我們有一個(gè) Login
函數(shù),可以實(shí)現(xiàn)用戶(hù)登錄功能。它接收用戶(hù)手機(jī)號(hào) + 短信驗(yàn)證碼,然后根據(jù)手機(jī)號(hào)從 Redis 中獲取保存的驗(yàn)證碼(驗(yàn)證碼通常是在發(fā)送驗(yàn)證碼這一操作時(shí)保存的),如果 Redis 中驗(yàn)證碼與用戶(hù)輸入的驗(yàn)證碼相同,則表示用戶(hù)信息正確,然后生成一個(gè)隨機(jī) token 作為登錄憑證,之后先將 token 寫(xiě)入 Redis 中,再返回給用戶(hù),表示登錄操作成功。
程序代碼實(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 并寫(xiě)入 Redis token, _ := generateToken(32) err = SetAuthTokenToRedis(ctx, rdb, token, mobile) if err != nil { return "", err } return token, nil }
Login
函數(shù)有 4 個(gè)參數(shù),分別是用戶(hù)手機(jī)號(hào)、驗(yàn)證碼、Redis 客戶(hù)端連接對(duì)象、輔助生成隨機(jī) token 的函數(shù)。
Redis 客戶(hù)端連接對(duì)象 *redis.Client
屬于 github.com/redis/go-redis/v9
包。
我們可以使用如下方式獲得:
func NewRedisClient() *redis.Client { return redis.NewClient(&redis.Options{ Addr: "localhost:6379", }) }
generateToken
用來(lái)生成隨機(jī)長(zhǎng)度 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 操作編寫(xiě)幾個(gè)函數(shù),用來(lái)存取 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 測(cè)試
現(xiàn)在,我們要對(duì) Login
函數(shù)進(jìn)行單元測(cè)試。
Login
函數(shù)依賴(lài)了 *redis.Client
以及 generateToken
函數(shù)。
由于我們?cè)O(shè)計(jì)的代碼是 Login
函數(shù)直接依賴(lài)了 *redis.Client
,沒(méi)有通過(guò)接口來(lái)解耦,所以不能使用 gomock
工具來(lái)生成 Mock 代碼。
不過(guò),我們可以看看 go-redis
包的源碼倉(cāng)庫(kù)有沒(méi)有什么線(xiàn)索。
很幸運(yùn),在 go-redis
包的 README.md 文檔里,我們可以看到一個(gè) Redis Mock 鏈接:
點(diǎn)擊進(jìn)去,我們就來(lái)到了一個(gè)叫 redismock
的倉(cāng)庫(kù), redismock
為我們實(shí)現(xiàn)了一個(gè)模擬的 Redis 客戶(hù)端。
使用如下方式安裝 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
。
可以通過(guò)如下方式快速創(chuàng)建一個(gè) Redis 客戶(hù)端 rdb
,以及客戶(hù)端 Mock 對(duì)象 mock
:
rdb, mock := redismock.NewClientMock()
在測(cè)試代碼中,調(diào)用 Login
函數(shù)時(shí),就可以使用這個(gè) rdb
作為 Redis 客戶(hù)端了。
mock
對(duì)象提供了 ExpectXxx
方法,用來(lái)指定 rdb
客戶(hù)端預(yù)期會(huì)調(diào)用哪些方法以及對(duì)應(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")
用來(lái)設(shè)置當(dāng)前 Get
操作返回值為 123456
。
同理, mock.ExpectSet
表示期待一個(gè) Redis Set
操作,Key 為 auth:token:Ta5EVtRgUD-HFmRwrujAwKZnx247lFfe
,Value 為 13800138000
,過(guò)期時(shí)間為 24*time.Hour
,返回 OK
表示這個(gè) Set
操作成功。
以上指定的兩個(gè)預(yù)期方法調(diào)用,是用來(lái)匹配 Login
成功時(shí)的用例。
Login
函數(shù)還有兩種失敗情況,當(dāng)通過(guò) GetSmsCaptchaFromRedis
函數(shù)查詢(xún) Redis 中驗(yàn)證碼不存在時(shí),返回 invalid sms code or expired
錯(cuò)誤。當(dāng)從 Redis 中查詢(xún)的驗(yàn)證碼與用戶(hù)傳遞進(jìn)來(lái)的驗(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 依賴(lài),還需要解決 generateToken
函數(shù)依賴(lài)。
這時(shí)候 Fake object 就派上用場(chǎng)了:
func fakeGenerateToken(int) (string, error) { return "Ta5EVtRgUD-HFmRwrujAwKZnx247lFfe", nil }
我們使用 fakeGenerateToken
函數(shù)來(lái)替代 GenerateToken
函數(shù),這樣生成的 token 就固定下來(lái)了,方便測(cè)試。
Login
函數(shù)完整單元測(cè)試代碼實(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) } }) } }
這里使用了表格測(cè)試,提供了 3 個(gè)測(cè)試用例,覆蓋了登錄成功、驗(yàn)證碼無(wú)效或過(guò)期、驗(yàn)證碼無(wú)效 3 種場(chǎng)景。
使用 go test
來(lái)執(zhí)行測(cè)試函數(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
測(cè)試通過(guò)。
Login
函數(shù)將 *redis.Client
和 generateToken
這兩個(gè)外部依賴(lài)定義成了函數(shù)參數(shù),而不是在函數(shù)內(nèi)部直接使用這兩個(gè)依賴(lài)。
這主要參考了「依賴(lài)注入」的思想,將依賴(lài)當(dāng)作參數(shù)傳入,而不是在函數(shù)內(nèi)部直接引用。
這樣,我們才有機(jī)會(huì)使用 Fake 對(duì)象 fakeGenerateToken
來(lái)替代真實(shí)對(duì)象 GenerateToken
。
而對(duì)于 *redis.Client
,我們也能夠使用 redismock
提供的 Mock 對(duì)象來(lái)替代。
redismock
不僅能夠模擬 RedisClient,它還支持模擬 RedisCluster,更多使用示例可以在官方示例中查看。
使用 Testcontainers 測(cè)試
雖然我們使用 redismock
提供的 Mock 對(duì)象解決了 Login
函數(shù)對(duì) *redis.Client
的依賴(lài)問(wèn)題。
但這需要運(yùn)氣,當(dāng)我們使用其他數(shù)據(jù)庫(kù)時(shí),也許找不到現(xiàn)成的 Mock 庫(kù)。
此時(shí),我們還有另一個(gè)強(qiáng)大的工具「容器」可以使用。
如果程序所依賴(lài)的某個(gè)外部服務(wù),實(shí)在找不到現(xiàn)成的 Mock 工具,自己實(shí)現(xiàn) Fack object 又比較麻煩,這時(shí)就可以考慮使用容器來(lái)運(yùn)行一個(gè)真正的外部服務(wù)了。
Testcontainers 就是用來(lái)解決這個(gè)問(wèn)題的,我們可以用它來(lái)啟動(dòng)容器,運(yùn)行任何外部服務(wù)。
Testcontainers
非常強(qiáng)大,不僅支持 Go 語(yǔ)言,還支持 Java、Python、Rust 等其他主流編程語(yǔ)言。它可以很容易地創(chuàng)建和清理基于容器的依賴(lài),常被用于集成測(cè)試和冒煙測(cè)試。所以這也提醒我們?cè)趩卧獪y(cè)試中慎用,因?yàn)槿萜饕彩且粋€(gè)外部依賴(lài)。
我們可以按照如下方式使用 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)建容器請(qǐ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())) } } }
代碼中我寫(xiě)了比較詳細(xì)的注釋?zhuān)筒粠Т蠹乙灰唤忉尨a內(nèi)容了。
我們可以將容器的啟動(dòng)和釋放操作放到 TestMain
函數(shù)中,這樣在執(zhí)行測(cè)試函數(shù)之前先啟動(dòng)容器,然后進(jìn)行測(cè)試,最后在測(cè)試結(jié)束時(shí)銷(xiāo)毀容器。
var rdbClient *redis.Client func TestMain(m *testing.M) { client, f := RunWithRedisInContainer() defer f() rdbClient = client m.Run() }
使用容器編寫(xiě)的 Login
單元測(cè)試函數(shù)如下:
func TestLogin_by_container(t *testing.T) { // 準(zhǔn)備測(cè)試數(shù)據(jù) err := SetSmsCaptchaToRedis(context.Background(), rdbClient, "18900001111", "123456") assert.NoError(t, err) // 測(cè)試登錄成功情況 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ù)。所以編寫(xiě)測(cè)試代碼時(shí),無(wú)需再考慮如何模擬 Redis 客戶(hù)端,只需要使用通過(guò) RunWithRedisInContainer()
函數(shù)創(chuàng)建的真實(shí)客戶(hù)端 rdbClient
即可,一切操作都是真實(shí)的。
并且,我們也不再需要實(shí)現(xiàn) fakeGenerateToken
函數(shù)來(lái)固定生成的 token,直接使用 GenerateToken
生成真實(shí)的隨機(jī) token 即可。想要驗(yàn)證得到的 token 是否正確,可以直接從 Redis 服務(wù)中讀取。
執(zhí)行測(cè)試前,確保主機(jī)上已經(jīng)安裝了 Docker, Testcontainers
會(huì)使用主機(jī)上的 Docker 來(lái)運(yùn)行容器。
使用 go test
來(lái)執(zhí)行測(cè)試函數(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
測(cè)試通過(guò)。
根據(jù)輸出日志可以發(fā)現(xiàn),我們的確在主機(jī)上創(chuàng)建了一個(gè) Redis 容器來(lái)運(yùn)行 Redis 服務(wù):
Creating container for image redis:6.0.20-alpine
容器 ID 為 2b5e40d40af0
:
Container created: 2b5e40d40af0
并且測(cè)試結(jié)束后清理了容器:
Container terminated: 2b5e40d40af0
以上,我們就利用容器技術(shù),為 Login
函數(shù)登錄成功情況編寫(xiě)了一個(gè)測(cè)試用例,登錄失敗情況的測(cè)試用例就留做作業(yè)交給你自己來(lái)完成吧。
總結(jié)
本文向大家介紹了在 Go 中編寫(xiě)單元測(cè)試時(shí),如何解決 Redis 外部依賴(lài)的問(wèn)題。
值得慶幸的是 redismock
包提供了模擬的 Redis 客戶(hù)端,方便我們?cè)跍y(cè)試過(guò)程中替換 Redis 外部依賴(lài)。
但有些時(shí)候,我們可能找不到這種現(xiàn)成的第三方包。 Testcontainers
庫(kù)則為我們提供了另一種解決方案,運(yùn)行一個(gè)真實(shí)的容器,以此來(lái)提供 Redis 服務(wù)。
不過(guò),雖然 Testcontainers
足夠強(qiáng)大,但不到萬(wàn)不得已,不推薦使用。畢竟我們又引入了容器這個(gè)外部依賴(lài),如果網(wǎng)絡(luò)情況不好,如何拉取 Redis 鏡像也是需要解決的問(wèn)題。
更好的解決辦法,是我們?cè)诰帉?xiě)代碼時(shí),就要考慮如何寫(xiě)出可測(cè)試的代碼,好的代碼設(shè)計(jì),能夠大大降低編寫(xiě)測(cè)試的難度。
以上就是詳解在Go語(yǔ)言單元測(cè)試中如何解決Redis存儲(chǔ)依賴(lài)問(wèn)題的詳細(xì)內(nèi)容,更多關(guān)于Go單元測(cè)試解決Redis存儲(chǔ)依賴(lài)的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Go Module依賴(lài)管理的實(shí)現(xiàn)
Go Module是Go語(yǔ)言的官方依賴(lài)管理解決方案,其提供了一種簡(jiǎn)單、可靠的方式來(lái)管理項(xiàng)目的依賴(lài)關(guān)系,本文主要介紹了Go Module依賴(lài)管理的實(shí)現(xiàn),感興趣的可以了解一下2024-06-06go語(yǔ)言fasthttp使用實(shí)例小結(jié)
fasthttp?是一個(gè)使用?Go?語(yǔ)言開(kāi)發(fā)的?HTTP?包,主打高性能,針對(duì)?HTTP?請(qǐng)求響應(yīng)流程中的?hot?path?代碼進(jìn)行了優(yōu)化,下面我們就來(lái)介紹go語(yǔ)言fasthttp使用實(shí)例小結(jié),感興趣的朋友跟隨小編一起看看吧2024-03-03詳解Go語(yǔ)言中關(guān)于包導(dǎo)入必學(xué)的 8 個(gè)知識(shí)點(diǎn)
這篇文章主要介紹了詳解Go語(yǔ)言中關(guān)于包導(dǎo)入必學(xué)的 8 個(gè)知識(shí)點(diǎn),文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2020-08-08詳解Go語(yǔ)言中select語(yǔ)句的常見(jiàn)用法
這篇文章主要是來(lái)和大家介紹一下Go語(yǔ)言中select?語(yǔ)句的常見(jiàn)用法,以及在使用過(guò)程中的注意事項(xiàng),文中的示例代碼講解詳細(xì),感興趣的小伙伴可以了解一下2023-07-07go實(shí)現(xiàn)redigo的簡(jiǎn)單操作
golang操作redis主要有兩個(gè)庫(kù),go-redis和redigo,今天我們就一起來(lái)介紹一下redigo的實(shí)現(xiàn)方法,需要的朋友可以參考下2018-07-07Go實(shí)現(xiàn)自己的網(wǎng)絡(luò)流量解析和行為檢測(cè)引擎原理
這篇文章主要為大家介紹了Go實(shí)現(xiàn)自己的網(wǎng)絡(luò)流量解析和行為檢測(cè)引擎原理,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-11-11