☁ Problem
- Writing unit tests for your code is not simple, especially if code coverage as close to 100% as possible is aimed for.
⚑ Goal
- easily writing unit tests for your code
- getting up to 100% code coverage
- without using a third-party library
- without resorting to "monkey patching"
- without writing complete mock implementations
- overriding (injecting) only the needed behaviours
- by using plain Go functions
☂ Solution
Abstracting function(alitie)s away through interfaces whose implementations have exported fields of type func
that are counterparts for each function exposed by the interface, so that all that the definitions of the interface methods have to do is to delegate to these functions stored as fields.
This provides a way to inject different definitions for any of the interface (implementation) methods.
Since Go uses structural typing on methods to determine compatibility of a type with an interface, one could define one's own interface with only the subset of methods that are used from a (third-party) library and the struct
exposed by the library would automatically be implementing it.
In the example below this case is not presented, but it would basically mean that the signatures of all methods in the DB
interface perfectly match the ones of the methods from the Client
struct exported by the go-redis
library. The methods of the DB
interface in the example below differ from their counterparts from the go-redis
library in the return type: they return plain Go error
instead of go-redis
-specific types and the reson for this is to minimize the code coupling to the third-party library.
☵ Example
A books library that allows storing and loading authors and books using a Redis database.
Boilerplate
Create a folder named injectable-method-definitions
, cd
into it and initialize it as a Go module by running go mod init injectable-method-definitions
.
Then create the following tree of folders and files:
internal
- books
- library.go
- library_test.go
- db
- db.go
- db_test.go
main.go
Code
internal/db/db.go
package db
import (
"time"
"github.com/go-redis/redis"
)
type DB interface {
Ping() error
Set(string, interface{}, time.Duration) error
Get(string) (string, error)
}
type RedisClient struct {
*redis.Client
PingF func() error
SetF func(string, interface{}, time.Duration) error
GetF func(string) (string, error)
}
func NewRedisClient(c *redis.Client) *RedisClient {
rc := &RedisClient{Client: c}
rc.PingF = func() error {
_, err := rc.Client.Ping().Result()
return err
}
rc.SetF = func(key string, value interface{}, expiration time.Duration) error {
return rc.Client.Set(key, value, expiration).Err()
}
rc.GetF = func(key string) (string, error) {
return rc.Client.Get(key).Result()
}
return rc
}
func (rc *RedisClient) Ping() error {
return rc.PingF()
}
func (rc *RedisClient) Set(key string, value interface{}, expiration time.Duration) error {
return rc.SetF(key, value, expiration)
}
func (rc *RedisClient) Get(key string) (string, error) {
return rc.GetF(key)
}
internal/db/db_test.go
package db
import (
"testing"
"time"
"github.com/go-redis/redis"
"github.com/stretchr/testify/require"
)
func TestRedisClient(t *testing.T) {
k, v := "k", "v"
client := NewRedisClient(redis.NewClient(&redis.Options{}))
require.Error(t, client.Ping())
require.Error(t, client.Set(k, v, 0))
_, err := client.Get(k)
require.Error(t, err)
client.PingF = func() error {
return nil
}
require.NoError(t, client.Ping())
client.SetF = func(string, interface{}, time.Duration) error {
return nil
}
require.NoError(t, client.Set(k, v, 0))
books := "book title 1|book title 2"
client.GetF = func(string) (string, error) {
return books, nil
}
books2, err := client.Get(k)
require.NoError(t, err)
require.Equal(t, books, books2)
}
internal/books/library.go
package books
import "injectable-method-definitions/internal/db"
type Library struct {
DB db.DB
}
func (l *Library) AddBooks(author string, titles string) error {
return l.DB.Set(author, titles, 0)
}
func (l *Library) GetBooks(author string) (string, error) {
return l.DB.Get(author)
}
internal/books/library_test.go
package books
import (
"testing"
"time"
"injectable-method-definitions/internal/db"
"github.com/stretchr/testify/require"
)
func TestLibrary(t *testing.T) {
client := db.NewRedisClient(nil)
library := Library{client}
author := "Henry Hazlitt"
books := "Economics in One Lesson|" +
"The Failure of the 'New Economics': An Analysis of the Keynesian Fallacies"
client.SetF = func(string, interface{}, time.Duration) error {
return nil
}
err := library.AddBooks(author, books)
require.NoError(t, err)
client.GetF = func(string) (string, error) {
return books, nil
}
books2, err := library.GetBooks(author)
require.NoError(t, err)
require.Equal(t, books, books2)
}
main.go
package main
import (
"fmt"
"os"
"strings"
"injectable-method-definitions/internal/books"
"injectable-method-definitions/internal/db"
"github.com/go-redis/redis"
)
func main() {
client := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "",
DB: 0,
})
redisClient := db.NewRedisClient(client)
if err := redisClient.Ping(); err != nil {
fmt.Println(err)
os.Exit(1)
}
library := &books.Library{DB: redisClient}
author := "Henry Hazlitt"
err := library.AddBooks(
author,
"Economics in One Lesson|"+
"The Failure of the 'New Economics': An Analysis of the Keynesian Fallacies",
)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
books, err := library.GetBooks(author)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Printf("%s's books:\n%s\n", author, strings.ReplaceAll(books, "|", "\n"))
}
Reading the code above one can notice the following:
main.go
code uses the "real"go-redis
clientdb_test.go
andlibrary_test.go
code injects "mock" behaviour for theDB
interface by setting custom functions as values for thePingF
,SetF
andGetF
fields of theRedisClient
implementation
Running the tests and checking their code coverage:
➤ go test ./internal/... -count=1 -coverprofile=coverage.txt -covermode=atomic
ok injectable-method-definitions/internal/books 0.099s coverage: 100.0% of statements
ok injectable-method-definitions/internal/db 0.200s coverage: 100.0% of statements
➤ go tool cover -func=coverage.txt
injectable-method-definitions/internal/books/library.go:11: AddBooks 100.0%
injectable-method-definitions/internal/books/library.go:16: GetBooks 100.0%
injectable-method-definitions/internal/db/db.go:25: NewRedisClient 100.0%
injectable-method-definitions/internal/db/db.go:41: Ping 100.0%
injectable-method-definitions/internal/db/db.go:46: Set 100.0%
injectable-method-definitions/internal/db/db.go:51: Get 100.0%
total: (statements) 100.0%
Running the main code:
- Install Redis on your machine - e.g. on macOS:
brew install redis
- Start the Redis server - e.g. on macOS:
redis-server /usr/local/etc/redis.conf
- Run the main code:
➤ go run main.go
Henry Hazlitt's books:
Economics in One Lesson
The Failure of the 'New Economics': An Analysis of the Keynesian Fallacies