Unit tests are the first line of defense against bugs costing businesses money, users and reputation.
They sit at the foundation of the test pyramid as the most common, granular and quickest to run automated tests.
Not writing them well opens you up to an increasing risk of errors and liability as your codebase grows in complexity. Avoiding them upfront often becomes a burden to compensate for at a later date especially when the original context is lost.
Great unit tests give you peace-of-mind about what you’ve implemented, insight into whether you’ve structured your units of code well, and unlock the ability to make future refactors or code migrations with confidence.
They should not be difficult to write.
These are some practical tips I wanted to share to help junior engineers level up faster.
1. Frame tests with the given-when-then structure
This is a convention that I personally like when structuring my unit tests.
Each part describes a linear journey which is easy to follow for yourself and the reader.
- Given represents preconditions (i.e. things to set up)
- When represents actions taken (i.e. the operation to test being executed)
- Then represents expected outcomes (i.e. the result to check)
This structure is shown in the following example.
@Testpublic void givenSufficientBalance_whenWithdraw_thenReturnSuccess() { // given // ...
// when // ...
// then // ...}
2. Focus on state coverage over code coverage
Code coverage measures how many lines of code were executed by your tests. This is often reported as a metric and is useful as a general guide during code reviews.
However, it’s best to frame your thinking in terms of state coverage instead.
This measures how much you’ve tested your system’s behaviour across different inputs and conditions. Thus, forcing you to think more precisely about different ways your system might break.
3. Test state transitions
Test operations which depend on each other together, as well as different sequences of them which result in different outcomes.
Let’s take the example of a class which has separate methods to open a file, do some processing and close the file. Other than the obvious open-process-close sequence, we should also consider what happens if:
- You try to process or close a file before opening it
- You try to process a file >1 times after it has already been opened
4. Add a test every time you fix a bug
This gives you confidence that the bug won’t happen again.
5. Treat them like documentation
Tests are vital because they document your code’s expected behaviour to yourself and others into the future. Trust me, you’ll thank yourself later.
6. Keep them tidy
Test files are just as prone to code rot as any other code.
Here are some actions to consider:
- Parameterize your tests where applicable. Each unit test should cover one specific scenario, and parameterization allows you to execute the same unit test with different parameters without duplicating it.
- Group related tests together. It makes it easier to scan the file.
- Structure your code consistently. For example, always putting helper functions at the bottom of the test file.
- Use shared test fixtures or helper functions to reduce duplication.