Bài 12: Testing & Mocking - Viết Unit Test cho mã nguồn dùng go-redis
Mục tiêu bài học:
-
Hiểu tầm quan trọng của Dependency Injection để làm cho mã nguồn có thể test được.
-
Sử dụng
alicebob/miniredisđể giả lập một server Redis hoàn chỉnh ngay trong bộ nhớ (Integration-like Test). -
Sử dụng
go-redis/redismockđể giả lập (mock) hành vi và kiểm thử các kịch bản lỗi (Unit Test).
1. Nguyên tắc thiết kế: Dependency Injection
Để viết Unit Test, mã nguồn của bạn không được khởi tạo Redis Client trực tiếp (hardcode) bên trong hàm nghiệp vụ. Thay vào đó, hãy truyền *redis.Client (hoặc tốt hơn là interface redis.Cmdable) vào từ bên ngoài.
Đoạn code nghiệp vụ cần test (file user.go):
Go
package main
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
// UserRepo nhận vào redis.Cmdable (hỗ trợ cả Standalone và Cluster)
type UserRepo struct {
rdb redis.Cmdable
}
func NewUserRepo(rdb redis.Cmdable) *UserRepo {
return &UserRepo{rdb: rdb}
}
// Hàm cần viết test: Cache thông tin user
func (repo *UserRepo) CacheUser(ctx context.Context, userID string, data string) error {
key := fmt.Sprintf("user:%s", userID)
return repo.rdb.Set(ctx, key, data, 10*time.Minute).Err()
}
// Hàm cần viết test: Lấy thông tin user
func (repo *UserRepo) GetUser(ctx context.Context, userID string) (string, error) {
key := fmt.Sprintf("user:%s", userID)
val, err := repo.rdb.Get(ctx, key).Result()
if err == redis.Nil {
return "", nil // Xử lý Cache Miss, không trả về lỗi
}
return val, err
}
2. Cách 1: Test với miniredis (Khuyên dùng)
Mock từng lệnh Redis đôi khi rất rườm rà. [github.com/alicebob/miniredis/v2](https://github.com/alicebob/miniredis/v2) là một server Redis thuần Go chạy hoàn toàn trên RAM. Nó mô phỏng logic của Redis thực tế, giúp bạn test các lệnh phức tạp (Pipeline, Lua, Transaction, TTL) cực kỳ chính xác.
Cài đặt: go get [github.com/alicebob/miniredis/v2](https://github.com/alicebob/miniredis/v2)
File test (user_test.go):
Go
package main
import (
"context"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
func TestUserRepo_WithMiniRedis(t *testing.T) {
// 1. Khởi tạo miniredis server
mr, err := miniredis.Run()
if err != nil {
t.Fatalf("Không thể khởi tạo miniredis: %v", err)
}
defer mr.Close()
// 2. Kết nối go-redis tới server miniredis giả lập
rdb := redis.NewClient(&redis.Options{
Addr: mr.Addr(),
})
repo := NewUserRepo(rdb)
ctx := context.Background()
// 3. Test hàm CacheUser
t.Run("CacheUser_Success", func(t *testing.T) {
err := repo.CacheUser(ctx, "123", "Nguyen Van A")
if err != nil {
t.Errorf("Lỗi không mong muốn: %v", err)
}
// Kiểm tra trực tiếp trên miniredis xem dữ liệu có thực sự được ghi không
mr.CheckGet(t, "user:123", "Nguyen Van A")
})
// 4. Test hàm GetUser
t.Run("GetUser_CacheMiss", func(t *testing.T) {
val, err := repo.GetUser(ctx, "999")
if err != nil {
t.Errorf("Lỗi không mong muốn: %v", err)
}
if val != "" {
t.Errorf("Mong đợi chuỗi rỗng nhưng nhận được: %s", val)
}
})
// 5. Test TTL (Sử dụng tính năng thao tác thời gian của miniredis)
t.Run("TTL_Expiration", func(t *testing.T) {
repo.CacheUser(ctx, "456", "Nguyen Van B")
// Tua nhanh thời gian hệ thống giả lập đi 11 phút (vượt qua TTL 10 phút)
mr.FastForward(11 * time.Minute)
val, _ := repo.GetUser(ctx, "456")
if val != "" {
t.Errorf("Dữ liệu phải bị xóa sau khi hết TTL, nhưng vẫn còn: %s", val)
}
})
}
3. Cách 2: Test với redismock (Kiểm soát kịch bản lỗi)
Tuy miniredis rất tuyệt, nhưng nó không thể giúp bạn giả lập các trường hợp mạng chập chờn, timeout, hay Redis đột ngột báo lỗi nội bộ. Đó là lúc bạn cần dùng [github.com/go-redis/redismock/v9](https://github.com/go-redis/redismock/v9).
Cài đặt: go get [github.com/go-redis/redismock/v9](https://github.com/go-redis/redismock/v9)
File test (user_mock_test.go):
Go
package main
import (
"context"
"errors"
"testing"
"time"
"github.com/go-redis/redismock/v9"
)
func TestUserRepo_WithRedismock(t *testing.T) {
// 1. Khởi tạo Mock Client
rdb, mock := redismock.NewClientMock()
repo := NewUserRepo(rdb)
ctx := context.Background()
t.Run("GetUser_NetworkError", func(t *testing.T) {
// 2. Định nghĩa kịch bản: Khi gọi Get("user:123"), phải trả về lỗi mạng
mock.ExpectGet("user:123").SetErr(errors.New("connection timeout"))
// 3. Chạy hàm nghiệp vụ
_, err := repo.GetUser(ctx, "123")
// 4. Kiểm tra xem code của bạn có trả đúng lỗi mạng đó lên không
if err == nil || err.Error() != "connection timeout" {
t.Errorf("Mong đợi lỗi 'connection timeout', nhưng nhận được: %v", err)
}
// Đảm bảo rằng tất cả các kịch bản mong đợi đã được gọi đến
if err := mock.ExpectationsWereMet(); err != nil {
t.Error(err)
}
})
t.Run("CacheUser_Success", func(t *testing.T) {
// Yêu cầu phải gọi đúng lệnh SET với đúng tham số
mock.ExpectSet("user:789", "Data", 10*time.Minute).SetVal("OK")
err := repo.CacheUser(ctx, "789", "Data")
if err != nil {
t.Errorf("Không mong đợi lỗi: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Error(err)
}
})
}
Kinh nghiệm: Dùng miniredis cho 90% các Unit Test thông thường liên quan đến logic dữ liệu, và dùng redismock cho 10% còn lại để kiểm thử các luồng Error Handling.
All rights reserved