Liam Clarke brand
Back to the top
Why use a Unit Test?
When to use a Unit Test?
How to write a Unit Test?
Define the unit we want to abstract and test
Mock the units dependencies
Write the test cases
Next up, integration tests

Unit testing with Go

Test
04 December 2021 5 minute read
GoGolangUnit Test

Unit tests are the lowest level of tests, comprising a single unit of code being tested over many cases, returning results fast.

There are various definitions of what a unit is when talking about unit tests, but they all generally follow the above definition.

Why use a Unit Test?

  • Fast to implement
  • Fast to run
  • Cover low level use cases
  • Reduce scope of test results

When to use a Unit Test?

Unit tests can be used for any part of the application that does not have serializing or deserializing data.

Away from the serialization is typically where the business logic lives in code and this ties well with the unit tests' ability to test many cases very fast.

If you are serializing or deserializing any data within a unit test then it is likely the chosen test is not suitable or the application boundaries are leaking implementation detail in the domain logic, a narrow integration test would be more suitable which is spoken about in the next post to this series.

How to write a Unit Test?

The Unit test consists of 3 steps:

Define the unit we want to abstract and test

Here we will create a Create method that creates a User with the storage provider.

First the User, a basic User struct with validation tags.

// model.go
package unit

import "github.com/google/uuid"

// User is a domain representation of a User.
type User struct {
	ID    uuid.UUID `validate:"required"`
	Name  string    `validate:"required,gte=3,lte=50"`
	Email string    `validate:"required,email,lte=50"`
}

Then the Create method and its initialising function.

// controller.go
package unit

// ...

// StorageProvider provides the storage methods.
type StorageProvider interface {
	Insert(ctx context.Context, user User) (uuid.UUID, error)
}

// Controller is the communicator between
// the storage layer and domain.
type Controller struct {
	Storage   StorageProvider
	Validator *validator.Validate
}

// New initializes a new Controller.
func New(storage StorageProvider, validator *validator.Validate) Controller {
	return Controller{
		Storage:   storage,
		Validator: validator,
	}
}

// Create passes a new User to the storage layer.
func (c Controller) Create(ctx context.Context, user User) (uuid.UUID, error) {
	user.ID = uuid.New()

	if err := c.Validator.Struct(&user); err != nil {
		var validationErr validator.ValidationErrors
		if errors.As(err, &validationErr) {
			for _, err := range validationErr {
				return uuid.Nil, fmt.Errorf("%w: %v", ErrInvalidUser, err.Error())
			}
		}
	}

	id, err := c.Storage.Insert(ctx, user)
	if err != nil {
		if errors.Is(err, narrow.ErrUnableToExecuteQuery) {
			return uuid.Nil, ErrAlreadyExists
		}

		return uuid.Nil, ErrUnknown
	}

	return id, nil
}

Mock the units dependencies

We are only interested in testing the business logic of this unit, we do not need to actually store a user in the database, that will be covered in the storage layer so there is no need to spin up a database, we can mock the results we expect from the interface.

// controller_test.go
package unit_test

// ...

type mockStorage struct {
	GivenID    uuid.UUID
	GivenError error
}

func (m mockStorage) Insert(_ context.Context, _ unit.User) (uuid.UUID, error) {
	return m.GivenID, m.GivenError
}

Write the test cases

Now the unit of work is defined and our dependencies are mocked we can test all the use cases we need to for our business logic, we can pass in different use cases of a User and ensure the response is what we expect.

// controller_test.go
package unit_test

// ...

func TestController_Create(t *testing.T) {
	tests := []struct {
		name    string
		storage unit.StorageProvider
		user    unit.User
		want    uuid.UUID
		wantErr error
	}{
		{
			name: "expect success given a valid User",
			storage: mockStorage{
				GivenID: uuid.Must(uuid.Parse("a6acab82-2b2e-484c-8e2b-f3f7736a26ed")),
			},
			user: unit.User{
				Name:  "foo",
				Email: "foo@bar.com",
			},
			want:    uuid.Must(uuid.Parse("a6acab82-2b2e-484c-8e2b-f3f7736a26ed")),
			wantErr: nil,
		},
		{
			name: "expect validation error given name less than 3",
			storage: mockStorage{
				GivenError: narrow.ErrUnableToExecuteQuery,
			},
			user: unit.User{
				Name:  "fo",
				Email: "foo@bar.com",
			},
			want:    uuid.Nil,
			wantErr: unit.ErrInvalidUser,
		},
		{
			name: "expect validation error given name greater than 50",
			storage: mockStorage{
				GivenError: narrow.ErrUnableToExecuteQuery,
			},
			user: unit.User{
				Name:  "this is a very long name that should be caught by the validator",
				Email: "foo@bar.com",
			},
			want:    uuid.Nil,
			wantErr: unit.ErrInvalidUser,
		},
		{
			name: "expect validation error given invalid email",
			storage: mockStorage{
				GivenError: narrow.ErrUnableToExecuteQuery,
			},
			user: unit.User{
				Name:  "foo",
				Email: "notanemail@foo",
			},
			want:    uuid.Nil,
			wantErr: unit.ErrInvalidUser,
		},
		{
			name: "expect already exists given a storage insert error",
			storage: mockStorage{
				GivenError: narrow.ErrUnableToExecuteQuery,
			},
			user: unit.User{
				Name:  "foo",
				Email: "foo@bar.com",
			},
			want:    uuid.Nil,
			wantErr: unit.ErrAlreadyExists,
		},
		{
			name: "expect unknown error given an unhandled storage error",
			storage: mockStorage{
				GivenError: errors.New("foo"),
			},
			user: unit.User{
				Name:  "foo",
				Email: "foo@bar.com",
			},
			want:    uuid.Nil,
			wantErr: unit.ErrUnknown,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			c := unit.New(tt.storage, validator.New())

			got, err := c.Create(context.Background(), tt.user)

			if !cmp.Equal(err, tt.wantErr, cmpopts.EquateErrors()) {
				t.Error(cmp.Diff(err, tt.wantErr, cmpopts.EquateErrors()))
			}

			if !cmp.Equal(got, tt.want) {
				t.Error(cmp.Diff(got, tt.want))
			}
		})
	}
}

Next up, integration tests

Unit tests are very good at what they do, testing low level code over many variations fast but there are times when we need to test against something that cannot be mocked - a database, another service or a HTTP API handler to name a few.

For this we can leverage integration tests, they allow us to spin up local environments that provide a close replication of the software used in production to test that our Input and Output streams work as they are meant to.

Thank you for reading!