Skip to content
gohacks.dev
Testing

Test Redis Code in Go With miniredis and When Not To

Test Go code that uses Redis with miniredis: an in-process server, a fast-forwarded clock for expiry, injected errors, and the commands it does not support.

8 min read
Summarize in AI
Flat illustration of a test report with one failing test, with the Redis logo.

Redis code is easy to test badly. A mock of the client interface proves your code called Incr, and nothing about whether the key expires, whether two keys interfere, or whether the code survives a server that answers with an error. A real Redis proves all of it but needs a container and a few seconds. miniredis sits between the two: a Redis compatible server written in Go that runs inside your test binary, speaks the real protocol and listens on a random local port.

Tested with Go 1.25.6, miniredis v2.37.0, go-redis v9.17.0, macOS on Apple Silicon (arm64)

You keep your production client, go-redis here, and change one address. This article tests a fixed window rate limiter on Go 1.25.6, shows the two features that justify the package (a controllable clock and injected failures), and ends with the places where you should reach for real Redis instead.

The code under test

counter.goGo
package main

import (
	"context"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

// Limiter allows n hits per window per key, using INCR and EXPIRE.
type Limiter struct {
	rdb    redis.Cmdable
	limit  int64
	window time.Duration
}

func NewLimiter(rdb redis.Cmdable, limit int64, window time.Duration) *Limiter {
	return &Limiter{rdb: rdb, limit: limit, window: window}
}

func (l *Limiter) Allow(ctx context.Context, key string) (bool, error) {
	k := "rl:" + key
	n, err := l.rdb.Incr(ctx, k).Result()
	if err != nil {
		return false, fmt.Errorf("incr %s: %w", k, err)
	}
	if n == 1 {
		if err := l.rdb.Expire(ctx, k, l.window).Err(); err != nil {
			return false, fmt.Errorf("expire %s: %w", k, err)
		}
	}
	return n <= l.limit, nil
}

func main() {}

Allow increments a counter for the key, sets an expiry on the first hit, and compares the count with the limit. The limiter depends on redis.Cmdable, the interface that *redis.Client, *redis.ClusterClient and a pipeline all satisfy, so the test passes a plain client and production passes whatever it runs.

Taking the narrow interface also means you can use miniredis for the cases it does well and a hand-written fake for the rest, which is the trade-off covered in fakes, stubs and mocks.

An in-process server

counter_test.goGo
package main

import (
	"testing"
	"time"

	"github.com/alicebob/miniredis/v2"
	"github.com/redis/go-redis/v9"
)

func newLimiter(t *testing.T) (*Limiter, *miniredis.Miniredis) {
	t.Helper()
	mr := miniredis.RunT(t) // closed by t.Cleanup
	rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
	t.Cleanup(func() { _ = rdb.Close() })
	return NewLimiter(rdb, 3, time.Minute), mr
}

func TestLimitThenWindowResets(t *testing.T) {
	l, mr := newLimiter(t)
	for i := 1; i <= 4; i++ {
		ok, err := l.Allow(t.Context(), "ada")
		if err != nil {
			t.Fatal(err)
		}
		t.Logf("hit %d allowed=%v", i, ok)
	}
	t.Logf("ttl on the key: %v", mr.TTL("rl:ada"))

	mr.FastForward(61 * time.Second) // moves miniredis's clock, no sleeping
	ok, _ := l.Allow(t.Context(), "ada")
	t.Logf("after fast forward allowed=%v", ok)
}

func TestKeysAreIndependent(t *testing.T) {
	l, _ := newLimiter(t)
	for range 3 {
		_, _ = l.Allow(t.Context(), "ada")
	}
	ok, _ := l.Allow(t.Context(), "grace")
	t.Logf("another key allowed=%v", ok)
}

func TestServerFailure(t *testing.T) {
	l, mr := newLimiter(t)
	mr.SetError("LOADING Redis is loading the dataset in memory")
	_, err := l.Allow(t.Context(), "ada")
	t.Logf("error: %v", err)
	if err == nil {
		t.Fatal("expected an error")
	}
}

func TestCommandsSeen(t *testing.T) {
	l, mr := newLimiter(t)
	_, _ = l.Allow(t.Context(), "x")
	_, _ = l.Allow(t.Context(), "x")
	t.Logf("commands the server saw for 2 Allow calls: %d", mr.CommandCount())
}
Shell
go test -count=1 -v . 2>&1 | grep -vE '^(=== RUN|=== PAUSE|=== CONT|PASS|ok)' | sed -E 's/ \([0-9.]+s\)//; s#^ +[a-z_]+\.go:[0-9]+: #    #'
Text
    hit 1 allowed=true
    hit 2 allowed=true
    hit 3 allowed=true
    hit 4 allowed=false
    ttl on the key: 1m0s
    after fast forward allowed=true
--- PASS: TestLimitThenWindowResets
    another key allowed=true
--- PASS: TestKeysAreIndependent
    error: incr rl:ada: LOADING Redis is loading the dataset in memory
--- PASS: TestServerFailure
    commands the server saw for 2 Allow calls: 7
--- PASS: TestCommandsSeen

newLimiter is the whole setup. miniredis.RunT(t) starts a server on 127.0.0.1 with an operating system chosen port and registers Close with t.Cleanup, so a test cannot leak a listener. The redis.NewClient points at mr.Addr(), and from there the code is exactly what production runs. Every test gets its own server, which is cheap, and no test can see another's keys. That is the same property you get from a private SQLite database per test, and it makes t.Parallel safe without any key prefixing.

Time without sleeping

The first test is the reason to use the package. A rate limiter is about time, and testing a one minute window by waiting a minute is not a test anyone will run. Expiry in miniredis is driven by its own clock, which only moves when you tell it to. After four hits, the log shows the key's TTL as 1m0s, the full window, because the clock has not advanced. mr.FastForward(61 * time.Second) then moves it past the expiry and the next call is allowed again.

Two details are worth knowing. First, FastForward affects only miniredis. A client side context.WithTimeout or a time.Sleep in your own code still runs on the real clock, so a component that mixes both needs the clock injected as well, which is the subject of testing time dependent code. Second, expiry is applied lazily on access plus when you fast forward, which is what Redis does, so a test that reads the key straight after the fast forward sees it gone.

Failing on purpose

Error paths are where mocks of clients usually win, and miniredis closes the gap. mr.SetError("...") makes every following command return that error until you clear it. The recorded line shows what the code under test returned: incr rl:ada: LOADING Redis is loading the dataset in memory. The %w wrapping in Allow kept the server's words and added the key, and that is the value of testing against something that returns real protocol errors: the assertion is on the message an on-call engineer will read.

The same test took 0.30 seconds, which is long for an in-memory test. The cause is the client, not the server. go-redis retries commands that fail with LOADING and similar transient replies, with a backoff between attempts, before it returns the error. In a test, build the client with MaxRetries: -1 to turn retries off, and the test returns at once. Keep the default in at least one test if the retry behaviour matters to you.

A related method, mr.Close(), shuts the server down so the next command fails to connect. It works, but go-redis logs a line for each failed dial attempt and spends over two seconds retrying with default settings, which is why the recorded lab does not include it. With retries disabled it is a quick way to test how code behaves when Redis is down.

What you can assert on

miniredis exposes the state directly: mr.Get, mr.Keys, mr.TTL, mr.HGet, mr.SMembers, mr.ZScore and so on. These read the data store without going through the protocol, so an assertion does not depend on the client under test. Use them to check the result of a write, for example that a session key exists with the right TTL, without issuing another command from the code being tested.

Resist asserting on how many commands ran. TestCommandsSeen shows 7 commands for two Allow calls, although the code issues three: INCR, EXPIRE and INCR. The rest is the connection handshake, where the client announces its protocol and library name. A command count would break the next time a client upgrade adds one. Assert on the keys, values and TTLs that result.

Where miniredis is the wrong tool

It is a reimplementation of a large and subtle server, written by one project. It supports most of the commands applications use, and it documents the ones it does not. The risks are in the gaps and in behaviour that differs from the real thing.

  • Lua scripts. miniredis embeds a Lua interpreter and runs EVAL, but script atomicity, redis.call error details and the cjson, cmsgpack and struct libraries differ in detail. A script that implements a lock or a rate limit deserves a run against real Redis.
  • Streams, consumer groups and blocking commands. Supported in part. Timing of XREADGROUP with BLOCK and claim semantics are where reimplementations drift.
  • Cluster, Sentinel and replication. Not simulated. Slot redirection, failover and replica reads cannot be exercised.
  • Memory and eviction. There is no maxmemory, no eviction policy and no OBJECT introspection, so cache behaviour under pressure is untested.
  • Version specific behaviour. The server reports a fixed version, and commands added in recent Redis releases may be missing.
  • Persistence and modules. RDB, AOF, RediSearch and the rest are out of scope.

A sensible split is to use miniredis for the bulk of tests, where the code stores and reads ordinary data, and to run a small contract suite against a real Redis started with testcontainers for the scripts, streams and anything where atomicity is the point. Run the same assertions against both where you can: if a behaviour passes on real Redis and fails on miniredis, you have found a gap before it reaches production.

The Redis command reference is the source of truth for what each command should do, and the miniredis README lists the supported and unsupported commands. Check both when a test fails in a way that looks like a server bug.

Pinning and versions

go-redis and miniredis release often and recent versions require a recent Go. The lab uses miniredis v2.37.0 and go-redis v9.17.0 because they build on Go 1.25; the latest releases of both need a newer toolchain. Pin the pair in go.mod and upgrade them together, since a protocol feature in one is only useful if the other understands it.

Also inDatabases
Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.