Writing good unit tests for SOLID Go
In this blog post, we are going to focus on some tips on how to write unit tests for that beautiful SOLID code.
The primary objectives for our test builds are to:
- Increase the confidence in our code. Otherwise, the test code is just dead weight.
- Be fast. Who likes waiting 15 minutes for the tests to pass?
- Be stable. Tests should never fail randomly, and a small change should never break unrelated tests.
- Be short. It must be the shortest possible checks required to increase our confidence in the code.
Most of us have had to suffer test suites which fell short of achieving these objectives. Objects with dependencies are, in many cases, the most problematic.
Starting with an example
When we implement a SOLID design, as a rule of thumb our structs will depend on interfaces instead of structs.
Let’s look at a simplified example of Go code that would use interfaces for its dependencies.
We are building a struct to manage users in our application. We will call it
As we can see,
UserManager is dependent on the following two interfaces:
Following the SOLID principles, we will create a function to initialise the
UserManager injecting the dependencies:
Once we have everything wired, the implementation of the UserManager methods could be something like this:
What should we test?
When testing an object, you can think of it as sending and receiving messages:
- Incoming messages refer to calls to methods on the tested object.
- Outgoing messages refers to calls from the tested object on its dependencies.
Following with our example, if we were to test the
SignUp would be the incoming message while the calls to
um.notifier.RequestActivation would be outgoing messages.
Furthermore, a message can be a Query or a Command:
- Query messages return data without changing anything. e.g.:
UserStore.Find(id string) (*User, error)would return a user without making any changes in the store.
- Command messages modify data without returning any data. e.g.:
UserStore.Update(User) errorwould make changes in the store without returning any new data.
- Some commands might return some data, but we should be careful and cautious of messages that return data and make modifications. We must ensure the changes are never hidden side-effects but required business logic. e.g.:
UserStore.Create(User) (*User, error)will add a user to the store and must return information so we can get the ID of the user.
This classification will help us guide what we need to test based on the types of messages affected:
- Incoming queries: send the message and assert the response.
- Incoming commands: send the message and assert the public changes. e.g.: call
UserStore.Deleteon an existing user ID.
- Outgoing queries: nothing to assert.
- Outgoing commands: assert the message sent.
So, how does this apply to our example?
A bad test
Many people would opt to go straight into developing an integration test for
Integration tests provide you end to end checks. But are much more expensive than unit tests:
- Running them is slower since you have to call external services. e.g.: in this case, we have to create/clear our test database for each test.
- You might not be able to run tests in parallel. e.g.: if you were clearing the same database for each test, running them in parallel could result in false failures due to race conditions preparing the database.
- With outgoing queries, you need to provision test data before each test. e.g.: if we were to test a
UserManager.Findmethod; we would need to add an entry to the
- With outgoing commands, you must assert the side effects on your dependency instead of the outgoing commands. e.g.: if you wanted to test
um.notifier.RequestActivation, you would have to read the queue to make sure
u.IDgot queued up.
- If your method depends on external services such as third party APIs, they can take seconds to respond and could have downtime. Which would make your suite slow and unstable. Furthermore, there are many services you would want to avoid calling every time you run your tests such as APIs that would have a cost per request.
Luckily, when since we follow a SOLID design, our dependencies are defined as interfaces, so we can implement a unit test.
Developing a unit test
Since our dependencies are defined as interfaces, we can use mocks to assert outgoing messages.
- Tests only focus on specifying dependencies and assertions, no need to fiddle creating/clearing databases.
- It will be fast, since everything will be done in the same process.
- Tests can run in parallel, since we have removed the race conditions.
- It is easy to mock any dependency, including calls to third party APIs.
- We are using github.com/ernesto-jimenez/goautomock to automatically generate the mocks based on the interface using
go generate. Zero boilerplate required.
As you can see, the benefits are many, especially for dependencies that are harder than databases to setup/teardown.
Should we still create integration tests?
Definitely, but we just have to be aware of the testing pyramid:
- Start with a foundation of unit tests since they are the cheapest ones to create, run and maintain.
- Add other types of testing on top of the unit tests: integration, end to end, UI, manual… The most expensive a kind of test is, the higher up in the pyramid it should be.
Have any questions or feedback?
I would love to gather your thoughts and some ideas for future posts.
Do you have any questions or feedback? send me a line to firstname.lastname@example.org.