單元測試中如何解決 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 鏈接:

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:13800138000SetVal("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.ClientgenerateToken 這兩個外部依賴定義成了函數參數,而不是在函數內部直接使用這兩個依賴。

這主要參考了「依賴注入」的思想,將依賴當作參數傳入,而不是在函數內部直接引用。

這樣,我們纔有機會使用 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 上,歡迎點擊查看。

希望此文能對你有所幫助。

參考

聯繫我

微信:jianghushinian

郵箱:jianghushinian007@outlook.com

博客地址:https://jianghushinian.cn

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/iOOBkatKltRxMtB6tFhFNw