Look at unit testing in Golang through the eyes of a front-end engineer
In this article I'm gonna share my learning regarding unit testing in Golang, and how it is different from JavaScript.
The code I made in my previous article works, but, unfortunately, it is not testable. In JavaScript one can just mock any kind of internal dependency just by mocking an entire module declaration with jest.mock('modulename'). This is possible due to the fact that JavaScript is an interpreted language. By its nature it allows code monkey-patching at runtime. However, this is not the case with Golang.
So, in Golang everything about unit testing rests on three main pillars: code split, dependency injection + inversion of control patterns and mocks.
In order to properly make things eligible for testing, we need to split one big chunk of code into logically isolated parts. This will lead us to implementing the DI pattern:
🎓 Dependency injection assumes that every unit may have other units as dependencies that are passed as arguments.
It is plain to see, that we have at least two separate logical units here:
- an service or client that is in charge of downloading files - that would be an HTTP Client,
- an service or client responsible for uploading the files to a bucket - a GCP Storage Client.
Let's move the corresponding functions and compose them under the packages described below.
Let's go ahead and define the logic of the HTTP client:
package httpimport ("copyfiletestable/internal""errors""fmt""net/http""net/url""strings")type Client struct{}func NewClient() *Client {return &Client{}}func (c *Client) ExtractFileName(fileURL string) (string, error) {parsedURL, err := url.Parse(fileURL)if err != nil {return "", err}splitPath := strings.Split(parsedURL.Path, "/")if len(splitPath) == 0 {return "", nil}return splitPath[len(splitPath)-1], nil}func (c *Client) DownloadFile(url string) (handle *internal.DocumentReadHandle, err error) {req, err := http.NewRequest("GET", url, nil)if err != nil {return nil, err}client := &http.Client{Transport: &http.Transport{},}resp, err := client.Do(req)if err != nil {return nil, err}if resp.StatusCode != http.StatusOK {resp.Body.Close()return nil, errors.New("could not load the file " + fmt.Sprintf("%d", resp.StatusCode))}return &internal.DocumentReadHandle{Reader: resp.Body,}, nil}
Then we define the logic of the GCP Storage client:
package gcpimport ("cloud.google.com/go/storage""context""copyfiletestable/internal""github.com/pborman/uuid""io""time")type StorageClient struct{}func NewStorageClient() *StorageClient {return &StorageClient{}}func (c *StorageClient) UploadFile(handle *internal.DocumentReadHandle, bucketName string, objectPath string) error {uploaderCtx := context.Background()uploaderCtx, cancel := context.WithTimeout(uploaderCtx, time.Second*50)defer cancel()client, err := storage.NewClient(uploaderCtx)if err != nil {return err}object := client.Bucket(bucketName).Object(objectPath)objectWriter := object.NewWriter(uploaderCtx)if _, err := io.Copy(objectWriter, handle.Reader); err != nil {return err}if err := objectWriter.Close(); err != nil {return err}return nil}func (c *StorageClient) PrepareObjectPath(objectPathPrefix, fileName string) string {return objectPathPrefix + "/" + uuid.New() + "-" + fileName}
Before we begin with the FileCopier service, let me remake the main() function like the following:
package mainimport ("copyfiletestable/internal/clients/gcp""copyfiletestable/internal/clients/http""copyfiletestable/internal/services/filecopier""log""os")func main() {fileURL := os.Getenv("FILE_URL")bucketName := os.Getenv("BUCKET_NAME")objectPath := os.Getenv("OBJECT_PATH")storageClient := gcp.NewStorageClient()httpClient := http.NewClient()fileCopier := filecopier.New(&filecopier.ServiceDependencies{StorageClient: storageClient,HttpClient: httpClient,})err := fileCopier.CopyFile(fileURL, bucketName, objectPath)if err != nil {panic(err)}log.Println("Done!")}
Now, if we just directly import these dependencies into the filecopier.go file, it won't do us any good, because then it won't allow injecting just any dependency, but only of one certain type. Clearly this is not how the IoC pattern is implemented.
🎓 Inversion of control demands that a unit only provides a main flow, a framework, but it does not know anything on how its dependencies are implemented or created.
This is where Go's implicit interfaces come into play.
Implicit interfaces in Golang is a way of achieving loose coupling by enabling "Duck Typing". An interface only states what kind of methods a structure should have, and then during the build time the compiler (and your IDE by doing static analysis) will make sure all loose ends are properly attached.
This works kind of similar to what happens in TypeScript, but from the other perspective - not quite, really, no.
package filecopierimport "copyfiletestable/internal"type gcpStorageClient interface {UploadFile(handle *internal.DocumentReadHandle, bucketName string, objectPath string) errorPrepareObjectPath(objectPathPrefix, fileName string) string}type httpClient interface {ExtractFileName(fileURL string) (string, error)DownloadFile(url string) (handle *internal.DocumentReadHandle, err error)}type ServiceDependencies struct {StorageClient gcpStorageClientHttpClient httpClient}type Service struct {storageClient gcpStorageClienthttpClient httpClient}func New(deps *ServiceDependencies) *Service {return &Service{storageClient: deps.StorageClient,httpClient: deps.HttpClient,}}func (s *Service) CopyFile(fileURL, bucketName, objectPath string) (err error) {fileName, err := s.httpClient.ExtractFileName(fileURL)if err != nil {return err}documentHandle, err := s.httpClient.DownloadFile(fileURL)if err != nil {return err}defer func() {err := documentHandle.Reader.Close()if err != nil {}}()targetObjectPath := s.storageClient.PrepareObjectPath(objectPath, fileName)err = s.storageClient.UploadFile(documentHandle, bucketName, targetObjectPath)if err != nil {return err}return}
See? We define local interfaces and use them as types. We directly import neither HTTP Client nor GCP Storage Client, but only state there what kind of methods the provided dependencies must offer.
I've decided to move the DocumentReadHandle type out into a separate file, to prevent it from being "owned" by one or another entity, so it remains neutral and re-usable.
package internalimport "io"type DocumentReadHandle struct {Reader io.ReadCloser}
io.ReadCloser is already an interface, so any object that has both Read() and Close() methods of a certain signatue will fit there.
So, assume now we wish to test the CopyFile() method. As the Service now has two dependencies, they must be properly mocked.
Generally speaking, when having interfaces in place, any kind of object can become a mock. But, just like in JS we have jest, in order to drastically improve experience with mocking, there is a library for Golang, called Testify.
So let's go ahead and create mocks for both HTTP Client and GCP Storage Client.
Let's create a new file called storage_mock.go and put it next to the client itself:
package gcpimport ("copyfiletestable/internal""github.com/stretchr/testify/mock")type StorageClientMock struct {mock.Mock}func NewStorageClientMock() *StorageClientMock {return &StorageClientMock{}}func (m *StorageClientMock) UploadFile(handle *internal.DocumentReadHandle, bucketName string, objectPath string) error {args := m.Called(handle, bucketName, objectPath)return args.Error(0)}func (m *StorageClientMock) PrepareObjectPath(objectPathPrefix, fileName string) string {args := m.Called(objectPathPrefix, fileName)return args.Get(0).(string)}
What is going on here? Few things:
- We define a mock StorageClientMock and mix it with the mock.Mock trait.
- We mock every method of the original entity:
- m.Called informs Testify that the method was called with such and such arguments,
- args.Get(), args.Error() return the pre-defined result back to the calling code.
Same goes for the HTTP Client:
package httpimport ("copyfiletestable/internal""github.com/stretchr/testify/mock")type ClientMock struct {mock.Mock}func NewClientMock() *ClientMock {return &ClientMock{}}func (m *ClientMock) ExtractFileName(fileURL string) (string, error) {args := m.Called(fileURL)return args.Get(0).(string), args.Error(1)}func (m *ClientMock) DownloadFile(url string) (handle *internal.DocumentReadHandle, err error) {args := m.Called(url)var arg1 *internal.DocumentReadHandleif args.Get(0) != nil {arg1 = args.Get(0).(*internal.DocumentReadHandle)}return arg1, args.Error(1)}
For the DownloadFile method the code is a bit more tricky, because we need to make sure the type cast won't fail on meeting nil.
We also need to implement the ReaderMock to match the io.ReadCloser interface and emulate file reads.
package ioimport "github.com/stretchr/testify/mock"type ReaderMock struct {mock.Mock}func NewReaderMock() *ReaderMock {return &ReaderMock{}}func (m *ReaderMock) Read(p []byte) (n int, err error) {args := m.Called(p)return args.Get(0).(int), args.Error(0)}func (m *ReaderMock) Close() (err error) {args := m.Called()return args.Error(0)}
The time has come to write our first test. The structure is somewhat similar to what we have in jest:
- We describe the test by prefixing the method we test with the Test prefix: TestCopyFile.
- Define test cases and run them one by one. This is important if we rely on some external resource like a real database.
We have mocks now, but we need to define the behaviour (so-called "expectations"). To do that, the mock.On() method exists:
httpClientMock.On("ExtractFileName", mock.AnythingOfType("string")).Return("", nil)
This declaration says "Whenever the ExtractFileName() method is called on the mock, with a string argument, return an empty string and a nil error".
If we compare Golang and TypeScript, mock.AnythingOfType("string") matches string type of TS. mock.Anything is the same as any of TS.
The paradigm behind the expectations is to match a set of static parameters into a static result. However, there is also a way to define the result as a "function of arguments" (or, spoken on the Jest language, "create a temporary mocked implementation of a method").
To enable this behaviour, tweak the mocked method in the following way:
func (m *ClientMock) ExtractFileName(fileURL string) (string, error) {args := m.Called(fileURL)var arg0 stringvar arg1 errorif mi, ok := args.Get(0).(func(string) (string, error)); ok {arg0, arg1 = mi(fileURL)} else {if args.Get(0) != nil {arg0 = args.Get(0).(string)}arg1 = args.Error(1)}return arg0, arg1}
This code upgrade unlocks the following syntax:
httpClientMock.On("ExtractFileName", mock.Anything).Return(func(fileURL string) (string, error) {return fileURL+"abracadabra", nil})
As for assertions, those are pretty straightforward to use since they resemble the corresponding ones from Jest: assert.* methods perform assertions, and to check if a mock was called, there is a mock.AssertCalled() function.
All right. As we have all our mocks in place, the behaviour of FileCopier.CopyFile() can be tested right away:
package filecopier_testimport ("io""testing""copyfiletestable/internal""copyfiletestable/internal/clients/gcp""copyfiletestable/internal/clients/http"ioMock "copyfiletestable/internal/io""copyfiletestable/internal/services/filecopier""github.com/stretchr/testify/assert""github.com/stretchr/testify/mock")type TestFlow func(t *testing.T)func TestCopyFile(t *testing.T) {testCases := map[string]TestFlow {"Should download a file": func(t *testing.T) {// Create mocks and define expectations. This code can be moved out to a helper to avoid code duplication for every test.storageClientMock := gcp.NewStorageClientMock()storageClientMock.On("UploadFile", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(nil)storageClientMock.On("PrepareObjectPath", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return("")readerMock := ioMock.NewReaderMock()readerMock.On("Read", mock.Anything).Return(0, io.EOF)readerMock.On("Close").Return(nil)httpClientMock := http.NewClientMock()httpClientMock.On("ExtractFileName", mock.AnythingOfType("string")).Return("", nil)httpClientMock.On("DownloadFile", mock.AnythingOfType("string")).Return(&internal.DocumentReadHandle{Reader: readerMock,}, nil)// Create the client and provide the dependencies.fileCopier := filecopier.New(&filecopier.ServiceDependencies{HttpClient: httpClientMock,StorageClient: storageClientMock,})// Execute the method we test here.err := fileCopier.CopyFile("http://path.to/file.pdf", "my-bucket", "my-object")// Check the outcome.assert.NoError(t, err)httpClientMock.AssertCalled(t, "DownloadFile", mock.Anything)storageClientMock.AssertCalled(t, "UploadFile", mock.Anything, mock.Anything, mock.Anything)readerMock.AssertCalled(t, "Close")},}// Run the test cases, one by one.for name, tc := range testCases {t.Run(name, testFlow)}}
If I look at the previously created unit test, I won't help to notice that the code is a bit muddy: it is unclear where the preparations end, and the checking start. To structure the code a bit better, there is a pattern called setup-verify, involving two functions to be put in place. Let's revrite the existing code in that fashion.
package filecopier_testimport ("io""testing""copyfiletestable/internal""copyfiletestable/internal/clients/gcp""copyfiletestable/internal/clients/http"ioMock "copyfiletestable/internal/io""copyfiletestable/internal/services/filecopier""github.com/stretchr/testify/assert""github.com/stretchr/testify/mock")func TestCopyFile(t *testing.T) {createMocks := func() (*gcp.StorageClientMock, *ioMock.ReaderMock, *http.ClientMock) {storageMock := gcp.NewStorageClientMock()storageMock.On("UploadFile", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(nil)storageMock.On("PrepareObjectPath", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return("")readerMock := ioMock.NewReaderMock()readerMock.On("Read", mock.Anything).Return(0, io.EOF)readerMock.On("Close").Return(nil)httpClient := http.NewClientMock()httpClient.On("ExtractFileName", mock.AnythingOfType("string")).Return("", nil)httpClient.On("DownloadFile", mock.AnythingOfType("string")).Return(&internal.DocumentReadHandle{Reader: readerMock,}, nil)return storageMock, readerMock, httpClient}type setup struct {FileURL stringBucketName stringObjectName stringStorageMock *gcp.StorageClientMockReaderMock *ioMock.ReaderMockClientMock *http.ClientMock}type setupFunc func(t *testing.T) *setuptype verifyFunc func(t *testing.T, setup *setup, err error)testCases := map[string]struct {setupFunc setupFuncverifyFunc verifyFunc}{"Should download a file": {setupFunc: func(t *testing.T) *setup {storageMock, readerMock, httpClient := createMocks()return &setup{FileURL: "http://path.to/file.pdf",BucketName: "my-bucket",ObjectName: "my-object",StorageMock: storageMock,ReaderMock: readerMock,ClientMock: httpClient,}},verifyFunc: func(t *testing.T, setup *setup, err error) {assert.NoError(t, err)setup.ClientMock.AssertCalled(t, "DownloadFile", mock.Anything)setup.StorageMock.AssertCalled(t, "UploadFile", mock.Anything, mock.Anything, mock.Anything)setup.ReaderMock.AssertCalled(t, "Close")},},}for name, tc := range testCases {t.Run(name, func(t *testing.T) {setup := tc.setupFunc(t)fileCopier := filecopier.New(&filecopier.ServiceDependencies{HttpClient: setup.ClientMock,StorageClient: setup.StorageMock,})err := fileCopier.CopyFile(setup.FileURL, setup.BucketName, setup.ObjectName)tc.verifyFunc(t, setup, err)})}}
The method induce more boilerplate, however, it is clearly seen what the input data is, and what to expect as a result.
Golang has a built-in test runner, that can be invoked via the go test CLI command. While it's useful as part of a CI/CD pipeline, for local execution it's recommended to use richgo, because it can provide colored output.
go install github.com/kyoh86/richgo@latest
Extend the Makefile by adding a new action:
test:PROJECT_DIR=${PWD} godotenv -f ./env.local richgo test ./... -v -p=1 -count=1 | tee test.output
So, by running make test we hopefully can see the following picture:
START| CopyFileSTART| CopyFile/Should_download_a_filePASS | CopyFile (0.00s)PASS | CopyFile/Should_download_a_file (0.00s)PASS | copyfiletestable/internal/services/filecopier
Right. Now we know how testing works in Golang. Hope this article was helpful! The code can be found here. Enjoy!
Sergei Gannochenko
Golang, React, TypeScript, Docker, AWS, Jamstack.
19+ years in dev.