Published on

Testing in Go When You Have a Redis Dependency

Authors
  • avatar
    Name
    Razvan
    Twitter

Miniredis and go-redis are both useful for testing Redis-related code. While you can use Miniredis regardless of the Redis client you use, you can use the go-redis test helpers only if you use go-redis in your code.

Miniredis

Miniredis is a lightweight, in-process Redis server implementation designed specifically for testing. It allows you to create a Redis server in your Go tests that runs in memory, which means that you don't need to have a separate Redis instance running on your machine or set up a connection to a remote Redis server. This can make your tests faster, more reliable, and easier to write.

go-redis

go-redis, on the other hand, is a Go client for Redis that provides a set of helper functions for testing Redis-related code. This package includes several functions that simplify working with Redis in tests. In order to be able to easily mock Redis, go-redis provides a Cmdable interface that is implemented by the redis client, and can be mocked in tests. This allows you to easily mock Redis in your tests.

Here's a simple implementation of a cache that uses Redis, that we'll use to demonstrate the testing part.

package cache

import (
	"context"
	"time"

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

// GetterSetter is an interface for a cache client that can get and set values. You can
// extend this interface to add more methods, or create other interfaces that you can compose, rather
// than using a single interface. From a testing perspective it's usually better to have multiple smaller interfaces,
// rather than a single large one.
type GetterSetter interface {
	Get(ctx context.Context, key string) (string, error)
	Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error
}

// Client is used to implement the GetterSetter interface
type Client struct {
	// Using an interface rather than a concrete type allows us to use a mock in our tests.
	// In this case a concrete type would be *redis.Client.
	RedisClient redis.Cmdable
}

// Get returns the value for the given key.
func (c *Client) Get(ctx context.Context, key string) (string, error) {
	return c.RedisClient.Get(ctx, key).Result()
}

// Set sets the value for the given key.
func (c *Client) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
	return c.RedisClient.Set(ctx, key, value, expiration).Err()
}

// NewClient returns a GetterSetter that wraps a cache client.
func NewClient() GetterSetter {

	rdb := redis.NewClient(&redis.Options{
		Addr:     "localhost:6379", // Update with your address
		Password: "",               // no password set
		DB:       0,                // use default DB
	})

	return &Client{
		RedisClient: rdb,
	}
}

And here are two options for testing, one using mocking.

package cache_test

import (
	"context"
	"errors"
	"testing"
	"time"

	"github.com/alicebob/miniredis/v2"
	"github.com/everquoteinc/go-testing-patterns/cache"
	"github.com/redis/go-redis/v9"
	"github.com/stretchr/testify/assert"
)

// Testing using Miniredis

func TestClient_GetWithMiniredis(t *testing.T) {
	tests := []struct {
		name    string
		key     string
		want    string
		wantErr bool
	}{
		{
			name:    "key does not exist",
			key:     "no-value",
			want:    "",
			wantErr: true,
		},
		{
			name:    "key exists",
			key:     "key-exists",
			want:    "exists",
			wantErr: false,
		},
	}

	// set up Miniredis
	mr := miniredis.RunT(t)
	// Set key used in test
	mr.Set("key-exists", "exists")

	// Cleanup registers a function to be called when the test (or subtest) and all its subtests complete.
	// Cleanup functions will be called in last added, first called order.
	t.Cleanup(func() {
		mr.Close()
	})

	for _, testCase := range tests {
		t.Run(testCase.name, func(t *testing.T) {
			t.Parallel() // Run in parallel with other parallel tests

			// Set up the client
			rc := redis.NewClient(&redis.Options{
				Addr: mr.Addr(),
			})
			defer rc.Close()

			cacheClient := &cache.Client{
				RedisClient: rc,
			}

			got, err := cacheClient.Get(context.Background(), testCase.key)
			assert.Equal(t, testCase.want, got)
			if testCase.wantErr {
				assert.NotNil(t, err)
			}

		})
	}
}

func TestClient_SetWithMiniredis(t *testing.T) {
	t.Run("test Set using Miniredis", func(t *testing.T) {
		const wantKey = "new-key"
		const wantValue = "value"
		// set up Miniredis
		mr := miniredis.RunT(t)
		// Set up the client
		rc := redis.NewClient(&redis.Options{
			Addr: mr.Addr(),
		})
		defer func() {
			rc.Close()
			mr.Close()
		}()

		cacheClient := &cache.Client{
			RedisClient: rc,
		}

		gotValue, _ := mr.Get(wantKey)
		assert.Equal(t, gotValue, "")

		keyTTL := 1 * time.Minute
		err := cacheClient.Set(context.Background(), wantKey, wantValue, keyTTL)
		assert.Nil(t, err)

		gotValue, _ = mr.Get(wantKey)
		assert.Equal(t, gotValue, wantValue)

		// 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.
		mr.FastForward(keyTTL)

		gotValue, _ = mr.Get(wantKey)
		assert.Equal(t, gotValue, "")

	})
}

// Testing using mocks

type MockRedis struct {
	// redis.Cmdable is embeded in the struct, so it implements the Cmdable interface,
	// and we only need to implement the methods we care about.
	redis.Cmdable
	returnValue   string
	returnError   error
	receivedKey   string
	receivedValue any
	receivedTTL   time.Duration
}

// Implement the Get method defined by the Cmdable interface
func (mr *MockRedis) Get(_ context.Context, key string) *redis.StringCmd {
	// go-redis provides NewStringResult, as well as other similar methods that can be used for tests.
	return redis.NewStringResult(mr.returnValue, mr.returnError)
}

func (mr *MockRedis) Set(_ context.Context, key string, value interface{}, expiration time.Duration) *redis.StatusCmd {
	mr.receivedKey = key
	mr.receivedValue = value
	mr.receivedTTL = expiration
	return redis.NewStatusResult(mr.returnValue, mr.returnError)
}

func TestClient_GetWithMock(t *testing.T) {
	tests := []struct {
		name            string
		mockRedisClient *MockRedis
		key             string
		want            string
		wantErr         bool
	}{
		{
			name: "key does not exist",
			mockRedisClient: &MockRedis{
				returnError: errors.New("ERR no such key"),
			},
			key:     "no-value",
			want:    "",
			wantErr: true,
		},
		{
			name: "key exists",
			mockRedisClient: &MockRedis{
				returnValue: "exists",
			},
			key:     "key-exists",
			want:    "exists",
			wantErr: false,
		},
	}

	for _, testCase := range tests {
		t.Run(testCase.name, func(t *testing.T) {
			t.Parallel()

			cacheClient := &cache.Client{
				RedisClient: testCase.mockRedisClient,
			}

			got, err := cacheClient.Get(context.Background(), testCase.key)
			assert.Equal(t, testCase.want, got)
			if testCase.wantErr {
				assert.NotNil(t, err)
			}
		})
	}
}

func TestClient_SetWithMock(t *testing.T) {
	tests := []struct {
		name                string
		mockRedisClient     *MockRedis
		sequenceNumberKey   string
		sequenceNumberValue string
		wantTTL             time.Duration
		wantError           bool
	}{
		{
			name: "set with error",
			mockRedisClient: &MockRedis{
				returnError: errors.New("something went wrong"),
			},
			sequenceNumberKey:   "sequenceNumberKey",
			sequenceNumberValue: "sequenceNumberValue",
			wantTTL:             1 * time.Minute,
			wantError:           true,
		},
		{
			name:                "set successfully",
			mockRedisClient:     &MockRedis{},
			sequenceNumberKey:   "sequenceNumberKey",
			sequenceNumberValue: "sequenceNumberValue",
			wantTTL:             1 * time.Minute,
			wantError:           false,
		},
	}
	for _, testCase := range tests {
		t.Run(testCase.name, func(t *testing.T) {
			cacheClient := &cache.Client{
				RedisClient: testCase.mockRedisClient,
			}
			err := cacheClient.Set(context.Background(), testCase.sequenceNumberKey, testCase.sequenceNumberValue, 1*time.Minute)
			assert.Equal(t, testCase.sequenceNumberKey, testCase.mockRedisClient.receivedKey)
			assert.Equal(t, testCase.sequenceNumberValue, testCase.mockRedisClient.receivedValue)
			assert.Equal(t, testCase.wantTTL, testCase.mockRedisClient.receivedTTL)
			if testCase.wantError {
				assert.NotNil(t, err)
			}
		})
	}
}