Olivine: Building a Redis-Compatible Service in Go. · 章節目錄

第 8 章

[Day 8] Testing

Testing is a native feature in Go. Create a file with _test.go name suffix, and using go test, that’s all.

It provides a foundation of testing, developers wouldn’t be blocked by “Which testing framework should I choose?” or “What is the best practice of creating a test cases?”.

The First Testing: SimpleString

The SimpleString defines in pkg/resp/value.go can help us to make our first testing. Create a file value_test.go with `func TestSimpleString_Marshal():

func TestSimpleString_Marshal(t *testing.T) {
	testcases := []struct {
		data   []byte
		expect []byte
	}{
		{
			data:   []byte("OK"),
			expect: []byte("+OK\r\n"),
		},
		{
			data:   []byte("PONG"),
			expect: []byte("+PONG\r\n"),
		},
	}

	for _, tc := range testcases {
		t.Run(string(tc.data), func(t *testing.T) {
			s := SimpleString(tc.data)

			marshaled := s.Marshal()
			if string(marshaled) != string(tc.expect) {
				t.Errorf("expect '%s', got '%s'", tc.expect, marshaled)
			}
		})
	}
}

Functions with Test* prefix in *_test.go is identified as a testing.

I usually make a testing with 2 parts:

  • Testcases definition.
  • Test runner
    • Arrange
    • Act
    • Assert

Testcases definition and runner

func TestFoo(t *testing.T) {
	// Test cases definition
	testcases := []struct{
		name string
	}{
		{
			name: "test case 1",
		},
		{
			name: "test case 2",
		},
	}

	for _, tc := range testcases {
		// Test Runner
		t.Run(tc.name, func (t *testing.T) {})
	}
}

When testing a function or reciever (functions in struct calls “reciever” in Go), we usually using 2 or more cases,

Go provides a t.Run() to run a sub test in single test case, it helps a lot.

Note: it is no needed to use t.Run() when there’s a single test case in testing, write a clear, readable and maintainable code is more important.

Arrange, Act and Assert

AAA pattern is a famous unit testing practice, it mark a testing with 3 stages:

  • Arrange: prepare the data, such as initialize a struct or open temp resources.
  • Act: Execute the things which I want to test.
  • Assert: Check the result is on expectation.
	for _, tc := range testcases {
		t.Run(string(tc.data), func(t *testing.T) {
			s := SimpleString(tc.data)

			marshaled := s.Marshal()
			if !slices.Equal(tc.expect, marshaled) {
				t.Errorf("expect '%s', got '%s'", tc.expect, marshaled)
			}
		})
	}

As the code above:

// arrange: create SimpleString by tc.data
s := SimpleString(tc.data)
// act: Invoke the s.Marshal() to get result
marshaled := s.Marshal()
// assert: check the marshaled and tc.expect is the same
if !slices.Equals(tc.expected, marshaled) {
	t.Errorf("expect '%s', got '%s'", tc.expect, marshaled)
}

The benchmarking

Benchmark testing is another testing feature built-in Go.

I made SimpleString.Marshal() with 2 different methods:

func (v SimpleString) bufferMarshal() []byte {
	var buf bytes.Buffer

	buf.WriteRune(MAGIC_SIMPLE_STRING)
	buf.Write(v)
	buf.WriteString(SENTINEL)

	return buf.Bytes()
}

func (s SimpleString) copyMarshaler() []byte {
	l := len(s)

	marshaled := make([]byte, l+3)
	marshaled[0] = MAGIC_SIMPLE_STRING
	marshaled[l+1] = '\r'
	marshaled[l+2] = '\n'
	copy(marshaled[1:], s)

	return marshaled
}

Note: it’s not a good practice to tune performance too early, this case is for learning benchmarking, I’ll not extends this method to other RESP values in this repository.

To write benchmark testing, create function with Benchmark* prefix with b *testing.B argument.


func BenchmarkSimpleString_bufferMarshal(b *testing.B) {
	s := SimpleString("OK")

	b.StartTimer()
	for b.Loop() {
		_ = s.bufferMarshal()
	}
	b.StopTimer()
}

func BenchmarkSimpleString_copyMarshal(b *testing.B) {
	s := SimpleString("OK")

	b.StartTimer()
	for b.Loop() {
		_ = s.copyMarshaler()
	}
	b.StopTimer()

It’s no need to call b.StartTimer() and b.EndTimer() if arrange doesn’t costs a lot, it takes an example.

Run tests

Use go test ./pkg/resp/value_test.go can run test cases, but benchmarking is ignored. Use go test -bench=. ./pkg/resp.value_test.go to run benchmarkings.

Some developers uses go test ./... and go test -bench=. ./... in project root to run testing and benchmarking for whole project scope test cases.

I always write this into Makefile to simply the command with make test:

test:
	go test ./...

Redis Command Testing

Let’s review the Command{} interface and implementations: if I want to add a new Redis command, I only need to implenent Command{} interface defines in internal/service/cmd/cmd.go and register in NewCommands() constructor.

type Command interface {
	Command() string
	Exec(context.Context, *resp.Command) (resp.Value, error)
}

func NewCommands(storage repo.Storage) []Command {
	return []Command{
		&Ping{},

		NewSet(storage),
		NewGet(storage),
	}
}

That’s how GET and SET commands do.

The Storage{}interface

I noticed that NewGet() and NewSet() require repo.Storage as argument, how can I get it in testing?

Mocking.

Sometimes, I don’t care about how storage works: it just input some data when Set; and output some data when Get.

We can use stub while testing.

func TestGet_Exec(t *testing.T) {
	cmd := NewGet(&stubStorage{})

	ret, err := cmd.Exec(t.Context(),
		resp.NewTestCommand(
			resp.NewArray([]resp.Value{
				resp.NewBulkString("GET"),
				resp.NewBulkString("foo"),
			}),
		),
	)
	if err != nil {
		 t.Error(err)
	}

	if !slices.Equals(ret.Marshal(), []byte("$3\r\nbar\r\n")) {
		 t.Error("expect '%s', got '%s', []byte("$3\r\nbar\r\n"), ret.Marshal())
	}
}

type stubStorage struct {}

func (*stubStorage) Set(ctx context.Context, k, v string) error { return nil }
func (*stubStorage) Get(ctx context.Context, k string) (string, error) { return "bar", nil }

I defined stubStorage{} struct and with Set() and Get() recievers (struct function), it is a valid repo.Storage{} interface implementation, and I can pass it as argument with NewGet().

Mock generator

Sometimes, developers use mock package such as stretchr/testify, but I don’t like testify mock, it reminds me as a Java developer. I use matryer/moq instead.

$ go get -tool github.com/matryer/moq

Note: moq official readme use go install github.com/matryer/moq@latest for installing, but it installs as a global executable, use go get -tool by installing a package scope tool instead after Go 1.24.

Add go generate comment on Storage{} interface:

//go:generate go tool moq -rm -out storage_mock.go . Storage
type Storage interface {
	Set(ctx context.Context, k, v string) error
	Get(ctx context.Context, k string) (string, error)
}

Run go generate ./..., it generates a storage_mock.go by moq.

Testing with mocked storage

It’s easy to test with moq:

func TestGet_Exec(t *testing.T) {
	cmd := NewGet(&repo.StorageMock{
		GetFunc: func(ctx context.Context, k string) (string, error) { return "bar", nil }
	})

	ret, err := cmd.Exec(t.Context(),
		resp.NewTestCommand(
			resp.NewArray([]resp.Value{
				resp.NewBulkString("GET"),
				resp.NewBulkString("foo"),
			}),
		),
	)
	if err != nil {
		 t.Error(err)
	}

	if !slices.Equals(ret.Marshal(), []byte("$3\r\nbar\r\n")) {
		 t.Error("expect '%s', got '%s', []byte("$3\r\nbar\r\n"), ret.Marshal())
	}
}

Now, you can review internal/service/cmd/get_test.go and internal/service/set_test.go in d8/testing branch.