解析 Golang 测试(7)- 如何针对 Redis 进行 Fake 测试

时间:2022-9-2     作者:smarteng     分类: Go语言


今天继续我们的【解析Golang测试】第七篇,对此前文章感兴趣的同学可以点击进入:

上一篇中,我们学习了 SQLite 和 go-mysql-server 这两种作为 MySQL Fake 的方式,这样可以让我们早单测阶段就暴露 SQL 语句可能的问题。

今天我们来看看针对 Redis 做 Fake 的经典方案:Miniredis。(如果大家对 Test Double, Fake, Mock 名词不熟悉,可以回顾一下第四篇文章)

Miniredis

Pure Go Redis test server, used in Go unittests.
Miniredis implements (parts of) the Redis server, to be used in unittests. It enables a simple, cheap, in-memory, Redis replacement, with a real TCP interface. Think of it as the Redis version of net/http/httptest .
It saves you from using mock code, and since the redis server lives in the test process you can query for values directly, without going through the server stack.

Miniredis 对自己的定位是针对 Golang 单测的 Redis 测试 server 实现,完美贴合我们的场景,在业内使用的也比较多。

和 go-mysql-server 类似,Miniredis 也是一个单机内存解决方案,无需经过网络请求。

使用方法

miniredis 用起来很简单:跑单测的时候先运行redis, 让需要使用redis的被测对象直接连接该redis。本次 demo 源代码依然在我们此前用的 go-test-demo 项目中。

目前官方建议使用 v2 版本:

import "github.com/alicebob/miniredis/v2"
复制代码

完整的 miniredis 支持的命令参照官方 readme,从日常开发角度看,目前所支持的命令已足以覆盖我们会用到的 redis 命令了。

我们来看一个示例,在此我们使用 redigo 这个 client 库来进行访问:

package miniredisexp

import (
"testing"
"time"

"github.com/alicebob/miniredis/v2"
"github.com/gomodule/redigo/redis"
)

func TestSomething(t *testing.T) {
s := miniredis.RunT(t)

// Optionally set some keys your code expects:
s.Set("foo", "bar")
s.HSet("some", "other", "key")

// Run your code and see if it behaves.
// An example using the redigo library from "github.com/gomodule/redigo/redis":
c, _ := redis.Dial("tcp", s.Addr())
_, _ = c.Do("SET", "foo", "bar")

// Optionally check values in redis...
if got, err := s.Get("foo"); err != nil || got != "bar" {
t.Error("'foo' has the wrong value")
}
// ... or use a helper for that:
s.CheckGet(t, "foo", "bar")

// TTL and expiration:
s.Set("foo", "bar")
s.SetTTL("foo", 10*time.Second)
s.FastForward(11 * time.Second)
if s.Exists("foo") {
t.Fatal("'foo' should not have existed anymore")
}
}
复制代码

一些要点我们过一下:

  • 我们通过 miniredis.RunT(t) ,把 *testing.T 传入,来构建出一个 miniredis 实例,底层会启动一个 redis server,监听 localhost 的某个端口,可以通过 Addr() 方法获取。此外我们也不需要担心单测结束后资源清理问题, RunT 这个方法内部会注册一个 cleanUp 函数用来自动做这件事,不需要我们来 defer m.Close
// Miniredis is a Redis server implementation.
type Miniredis struct {
sync.Mutex
srv         *server.Server
port        int
passwords   map[string]string // username password
dbs         map[int]*RedisDB
selectedDB  int               // DB id used in the direct Get(), Set() &c.
scripts     map[string]string // sha1 -> lua src
signal      *sync.Cond
now         time.Time // time.Now() if not set.
subscribers map[*Subscriber]struct{}
rand        *rand.Rand
Ctx         context.Context
CtxCancel   context.CancelFunc
}

// RunT start a new miniredis, pass it a testing.T. It also registers the cleanup after your test is done.
func RunT(t Tester) *Miniredis {
m := NewMiniRedis()
if err := m.Start(); err != nil {
t.Fatalf("could not start miniredis: %s", err)
// not reached
}
t.Cleanup(m.Close)
return m
}

// Start starts a server. It listens on a random port on localhost. See also
// Addr().
func (m *Miniredis) Start() error {
s, err := server.NewServer(fmt.Sprintf("127.0.0.1:%d", m.port))
if err != nil {
return err
}
return m.start(s)
}
复制代码

通过源码可以看到,Miniredis 对象其实就是对 redis server 的一套实现,包含了一些配置信息以及一个 server.Server 对象。这里调用 Start 方法会在本地启动服务。

  • 有了一个 Miniredis 对象,我们就可以在这个基础上使用 redis 的常见命令来测试了,比如
s.Set("foo", "bar")
s.HSet("some", "other", "key")
复制代码
  • 我们也可以通过 s.Addr() 获取 redis server 地址,新建一个专用于 fake 的 client 来执行后续逻辑:
// Run your code and see if it behaves.
// An example using the redigo library from "github.com/gomodule/redigo/redis":
c, _ := redis.Dial("tcp", s.Addr())
_, _ = c.Do("SET", "foo", "bar")
复制代码
  • 有了一些写操作之后,我们就可以去 GET , HGET 等读操作来验证数据是否正确,我们可以自行拿到数据判断,也可以用 Miniredis 已经封装好的方法,比如 CheckGet:
// Optionally check values in redis...
if got, err := s.Get("foo"); err != nil || got != "bar" {
        t.Error("'foo' has the wrong value")
}
// ... or use a helper for that:
s.CheckGet(t, "foo", "bar")
复制代码
  • 对 TTL 的处理是很经典的。我们知道在单测里,通常我们会追求运行一定要飞速结束,不可能等待十几,二十分钟来执行单测,负担越重,大家跑单测的意愿越低。那这个问题怎么解决呢?Miniredis 提供了一个 FastForward 方法来让时间“快跑”,传入一个 time.Duration 即可。

Since miniredis is intended to be used in unittests TTLs don't decrease automatically. You can use TTL() to get the TTL (as a time.Duration) of a key. It will return 0 when no TTL is set.
m.FastForward(d) can be used to decrement all TTLs. All TTLs which become <= 0 will be removed.

结合到我们的例子中看一下:

// TTL and expiration:
s.Set("foo", "bar")
s.SetTTL("foo", 10*time.Second)
s.FastForward(11 * time.Second)
if s.Exists("foo") {
        t.Fatal("'foo' should not have existed anymore")
}
复制代码

下面我们来执行一下 TestSomething 这个单测方法,看看 FastForward 耗时多少:

$ go test -v
=== RUN   TestSomething
--- PASS: TestSomething (0.00s)
PASS
ok      github.com/ag9920/go-test-demo/miniredisexp     0.850s
复制代码

结果符合我们的预期,本来需要 10 秒等过期,现在因为 FastForward 的存在,不到一秒的时间单测就结束了。

在项目中使用

上面我们了解了如何使用 Miniredis 实例来测试我们的命令是否正确。但现实开发中,我们还是希望 redis 作为一个底层的 infrastructure 能够被其他代码依赖,整体来验证一些逻辑。

而我们的 client 库可能各种各样,这个时候怎样用到 Miniredis 这个内存 server 实例呢?

第一步:我们还是通过 miniredis.Runminiredis.RunT 方法来获取一个 Miniredis 实例的指针,二者底层都是依赖同一个 m.Start() 来实现 server 的启动,并返回实例。

第二步:调用实例的 Addr() 方法,拿到实例访问地址:

// Addr returns '127.0.0.1:12345'. Can be given to a Dial(). See also Host()
// and Port(), which return the same things.
func (m *Miniredis) Addr() string {
m.Lock()
defer m.Unlock()
return m.srv.Addr().String()
}
复制代码

第三步,将这个 addr 传入你的 redis client 生成的地方,通过指定地址来新建一个 client,可能需要一些配置,根据自身选择的 client 库来确定;

这样,你拿到的 client 就是基于 miniredis 这种 Fake 方案生成的了,我们就可以在项目中依赖它进行后续的逻辑验证。

结语

今天我们了解了 miniredis 的用法,其实和 SQLite 是比较像的,基于内存做存储,New 出来一个 server 后按照 addr 连接即可,这样没有副作用。

比起 MySQL 复杂的关系表,redis 的数据结构比较简单,所以也没有 AutoMigrate 这样的机制。测试时直接基于 KV 操作即可。

到目前位置,我们了解了

  • 针对 MySQL 的 mock 用法:sqlmock;

  • 针对 MySQL 的 fake 用法:SQLite, go-mysql-server;

  • 针对 Redis 的 fake 用法:miniredis。

对此前几个不熟悉的同学建议再看下我们文章开头列出来的历史文章,这里还是建议大家多用 fake,提前暴露用法问题,把问题扼杀在单测阶段。

标签: 测试