Golang How to mock an object by yourself for unit testing

eye-catch Golang

Mocking is an important thing for unit testing. There are many mocking frameworks that can help us to write unit tests but they might be too big to install for a small project. There might be only one place to use the framework. It’s not good to install the big module in this case.

Maybe you are in such a situation and thus come to this post. Let’s try to create a mock object by ourselves.

You can see the complete code in my GitHub repository.

Creating an interface to mock

Firstly, let’s check the code that we want to write unit tests.

package selfmock

import "fmt"

type Provider struct {
    reader Reader
}

func NewProvider(reader Reader) *Provider{
    instance := new(Provider)
    instance.reader = reader

    return instance
}

func (p *Provider) ProvideData() (string, error) {
    err := p.reader.Open()
    if err != nil {
        return "", fmt.Errorf("failed to open: %w", err)
    }
    defer p.reader.Close()

    data, err := p.reader.Read(99)
    if err != nil {
        return "", fmt.Errorf("failed to read: %w", err)
    }
    return data, nil
}

The Provider has Reader which is used in the main function ProvideData. To write tests for the function, we need to control the behavior. It means that the mock object needs to be injected from outside. That’s why NewProvider accepts Reader parameter.

The following code is the actual code for the Reader.

package selfmock

import "fmt"

type FileReader struct {
    Path string
}

func NewFileReader(path string) *FileReader {
    instance := new(FileReader)
    instance.Path = path

    return instance
}

func (f *FileReader) Open() error {
    fmt.Printf("path: %s", f.Path)
    return nil
}

func (f *FileReader) Close() {
}
func (f *FileReader) Read(size int) (string, error) {
    return "abcde", nil
}
func (f *FileReader) Something() error {
    fmt.Println("Do something")
    return nil
}

Assume that the code is provided by built-in or 3rd party module. Then, create an interface against the module.

type Reader interface {
    Open() error
    Close()
    Read(size int) (string, error)
    Something() error
}

It might have a lot of functions but only a few functions are used in production code. In this case, define the only used functions to make it more maintainable.

Define a mock struct against the interface

Once an interface is defined, we need to define a struct that fulfills the interface.

type FileReaderMock struct {
    selfmock.Reader
    spy       Spy
    FakeOpen  func() error
    FakeClose func()
    FakeRead  func(size int) (string, error)
}

All the functions defined in Reader interface can be used in the FileReaderMock struct in this way without defining them explicitly.

Other variables Fakexxxx are used to define the behavior depending on a test requirement. For example, we need to make the function call success for a normal case. On the other hand, it needs to return an error for the error case. That control is done via FakeOpen.

spy is used to have the number of function calls and the args.

Note that the Reader is defined in package selfmock and the test code is in package selfmock_test.

Define Spy functions

The number of function calls and the arguments are sometimes need to be checked in unit tests. Since we don’t use a library, we need to define them.

I implemented it in the following way.

type Spy struct {
    CallCount map[string]int
    Args      map[string][][]any
}

func (s *Spy) Init() {
    s.CallCount = make(map[string]int)
    s.Args = make(map[string][][]any)
}

func (s *Spy) Register(funcName string, args ...any) {
    val := s.CallCount[funcName]
    val++

    s.CallCount[funcName] = val

    values := s.Args[funcName]
    values = append(values, args)
    s.Args[funcName] = values
}

We have multiple functions. Therefore, we need to store the info separately by using map. The last three lines can be written on a single line if you want.

s.Args[funcName] = append(s.Args[funcName], args)

Define the mock functions

We defined FileReaderMock struct. We can access the functions but we still have to define the functions’ behavior.

Define the default behavior in the initializer. The default definition is used when we don’t assign a function to Fakexxxx variable.

func NewFileReaderMock() *FileReaderMock {
    instance := new(FileReaderMock)
    instance.spy.Init()

    instance.FakeOpen = func() error { return nil }
    instance.FakeClose = func() {}
    instance.FakeRead = func(size int) (string, error) {
        return "", errors.New("define the behavior")
    }

    return instance
}

Then, define the remaining functions. What they do is basically the same. It just calls Fakexxxx function in it.

const (
    openKey  = "Open"
    closeKey = "Close"
    readKey  = "Read"
)

func (f FileReaderMock) Open() error {
    f.spy.Register(openKey)
    return f.FakeOpen()
}

func (f FileReaderMock) Close() {
    f.spy.Register(closeKey)
    f.FakeClose()
}

func (f FileReaderMock) Read(size int) (string, error) {
    f.spy.Register(readKey, size)
    return f.FakeRead(size)
}

Okay. The behavior can be defined in each test in this way.

How to use self mock object

Let’s use self mock object here. The test file looks like the following.

package selfmock_test

import (
    "errors"
    "play-with-go-lang/test/selfmock"

    . "github.com/onsi/ginkgo/v2"
    . "github.com/onsi/gomega"
)

var _ = Describe("Provider", func() {
    var readerMock *FileReaderMock

    BeforeEach(func() {
        readerMock = NewFileReaderMock()
    })

    Describe("ProvideData", func() {
        // tests are here...
    })
})

How to define a fake behavior for a function

It is easy to set a fake behavior. Define a function and assign it to Fakexxxx.

It("should return data", func() {
    instance := selfmock.NewProvider(readerMock)
    readerMock.FakeRead = func(size int) (string, error) {
        return "inject fake data", nil
    }

    data, err := instance.ProvideData()
    Expect(err).ShouldNot(HaveOccurred())
    Expect(data).Should(Equal("inject fake data"))
})

We’ve defined the default behavior that returns an error but this test succeeds because the default behavior is overwritten.

How to check the call count and the arguments

Then, next is to get function call info from spy.

It("should return data (call the function twice)", func() {
    instance := selfmock.NewProvider(readerMock)
    readerMock.FakeRead = func(size int) (string, error) {
        return "inject fake data", nil
    }

    instance.ProvideData()
    instance.ProvideData()
    Expect(readerMock.spy.CallCount[readKey]).Should(Equal(2))
    Expect(readerMock.spy.Args[readKey][0][0]).Should(Equal(99))
    Expect(readerMock.spy.Args[readKey][1][0]).Should(Equal(99))
})

The function name needs to be specified in the brackets. Then, you can access the necessary info. For the args, the same function can be called several times and we need to store them for each call.

To get the args for the second call, index 1 needs to be specified. If you need to get the third parameter of the function for the second call, the representation looks like this.

readerMock.spy.Args[readKey][1][2]

Comments

Copied title and URL