Unit testing with Go
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!