第 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@latestfor installing, but it installs as a global executable, usego get -toolby 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.