How to Write a Great Unit Test with Confidence
Your tests failed. Again. There was no bug. You changed an implementation detail and your app still works but your test is broken. Is unit test worthless?
Software engineers hate writing unit tests.
Despite how annoying unit tests are, we all know that unit tests are good for creating a robust, well-functioning system. It is one of the most valuable types of automated testing.
By definition, a unit test is an automated testing method that tests the behavior of your function.
A unit test should be independent of other test results. They should be able to run on any platform or machine. You should be able to take your unit test executable and run it on your mother's computer when it isn't even connected to the internet.
If you are a software engineer, chances are you will touch a unit-tested system. Even when you get a green check mark after running test suites, the test suites need to be properly created, or it can result in fake positives.
Many teams need to learn how to conduct proper unit tests; some don't see the value of creating them. For instance, many engineers only test the happy path of the function but need to account for the unhappy or complicated test cases.
In this article, we will discuss best practices and gotcha’s for writing a good unit test so that your team can reap all the benefits of unit testing.
Write Your Test Suite Based on Behavior Instead of Implementation
Test maintenance is one of the main obstacles for teams trying to adopt unit testing.
Engineers are often annoyed; development becomes very slow when test suites are too fragile and fail when they slightly change or refactor the codebase.
Do breaking test suites caused by a slight change in the codebase or refactoring represent fragile or robust test suites?
The answer is it depends.
Testing based on implementation makes test suites very hard to maintain. Moreover, you will get false negatives just by trying to change the implementation details.
Or even worse, you generate false positives, which may not fail when you break the application code.
When it comes to unit testing, you should prevent them from becoming too coupled with the internals of the code you’re testing. That way, the tests are more resilient to change, allowing developers to adjust the internal implementation and refactor when needed while providing valuable feedback and a safety net.
By testing behavior rather than implementation, you can refactor code to make improvements and instantly verify whether you've purposely or accidentally changed the way it behaves by running your tests.
Supposed that you create a random service that has a `getNext` function that returns the next random string:
trait Random { def getNext(): String}
Then, you will have an RandomImpl
class that has an external Random or State that implements the interface:
class RandomImpl(state: State) extends Random {
override def getNext(): string = state.getNext(100) // get the next random in the state class with 100 as the initial value
}
I often see someone did this in the unit test for getNext
:
"getNext" should {
"returns the correct string" in {
val mockState = mock[State]
val implementation = new RandomImpl(mockState)
val next = implementation.getNext()
(mockState.getNext(100)).returns("hello")
next should equal("hello")
verify(mockState.getNext(100), times(1))
}
}
The last portion of verify is testing the implementation rather than the behavior. Do we care if mockState
gets called three times if the return value is “hello”?
We are exposing our unit test to the implementation of getNext
instead of the (behavior) the result of getNext
.
We could get rid of the verify
at the end and assert the result of the method.
When I was developing the payment application system at Disney Streaming Service, our team demanded a unit test for every function I wrote. I realized that one of the engineers would write 300+ test suites for an implementation. Although it looks like we cover many branches, each refactors or small change within the implementation will break a couple of the test suites. Each new feature change and hotfix becomes a nightmare for developers. Those tests provide little value because each codebase change will also require changing the existing test suites.
Another method to prevent implementation testing is to adopt test-driven development.
Test-driven development is the notion of writing the test first before writing your implementation. The process includes the following:
Writing tests first (based on the behavior of your system). This test won’t work nor compile at first.
Write your implementation, then ensure that your test becomes green.
Refactor any duplication in your code without touching the test again - merely getting your test to work.
The test suite should not look inside the method to see what it is doing. It would help if you refrained from directly testing the private methods being called. If you are interested in discovering whether your private code is being tested, use a code coverage tool. However, you will see why good code coverage doesn't necessarily mean a robust application at later points.
Think about the behavior your method is supposed to provide and the branches it can follow. Ideally, you should test that all implementation routes demonstrate the expected behavior.
You know that your unit test is written based on the behavior instead of implementation when a PM can understand what your test suite is doing.
This general rule is to see if your behavioral coverage is good enough.
Tests Shouldn't Duplicate Implementation Logic
A common mistake is when the same method we want to test is also used inside the assertion.
You should not use the method itself (or any of the internal code it uses) to generate the expected result dynamically. The expected result should be hardcoded into your test case to stay the same even when the implementation changes.
For instance, testing the method split
that splits a sentence into an array of strings by comma-separated values, a test case that I often see will be something like this:
object Util {
def split(str: String): List[String] = str.split(",")
}
"testing split method" in {
Util.split("ABC, def,") should equal to ("ABC, def," .split(","))
}
The code above is problematic because the test code is almost a carbon copy of the implementation code. If the same person wrote both the test and the implementation, they might make the same error in both spots. But since the test mirrors the implementation, the test might still pass, which puts you in a terrible situation: the implementation is wrong, but the test fools you into thinking otherwise.
Instead, you should hardcode the expected result in the test suite so that when someone changes the behavior of the split
function, you will also get a failure in the test suite.
In addition, you should strive for high-value, low, effort tests. That means you abstract test functions to be an easy one-liner, creating a useful generic test that we can reuse repeatedly.
For example, when we test the JSON encoder and decoder in Scala, we can create a generic method that calls encode and decode to see if that model is the same as the original model we passed in.
trait Test {
def encodeDecode[A:Encoder:Decoder](model:A) = {
decode[A](model.asJson).map{_ should equal(model)}
}
}
A key benefit is that you reduce the amount of duplicate code in your test suite. Because frequently, when we need to change certain behaviors in the test, we may miss one of them if we don't centralize those test suite behavior into one.
Don't Focus Too Much on Code Coverage
Coverage can be very useful because it lets developers know what percentage of their code is covered by the tests. It is also handy to check which parts of their code aren’t covered with unit tests. However, having good coverage doesn't mean that your tests are exhaustible.
Code coverage is like a hygiene factor. A hygiene factor is an element that, if missing, will lower your confidence as a developer.
You should look at code coverage from a "glass half-empty" perspective.
Use coverage to find parts of your system that you still need to test. Then, identify if you should take action to cover the missed test suite.
Think of line-by-line coverage as "necessary but not sufficient." Imagine trying to be as lazy as possible while still achieving full line-by-line coverage, and you'll see how easy it is to write an inadequate set of tests.
85% coverage is good to shoot for. Above 85% starts to have a diminishing return.
Why? Because reach 100% coverage requires the developer to introduce some complexity - by creating abstraction, such as an interface, into the code.
In addition, code coverage causes developers to write their unit tests in terms of implementation rather than behavior. You will see that many unit tests contain too many redundant assertions because they want the code coverage to be 100%.
Instead of adding more unit tests that will bloat your entire test suite, you can introduce integration tests to ensure that all components and pieces are wired correctly.
Unit tests only test one code unit simultaneously, but the production code interacts with many other coding components. Thus, combining your unit test with another automated test is always a good idea to ensure that bugs won't be introduced to the production environment.
Conclusion
Everyone can write a unit test. However, writing a good unit test is complicated. It requires patience and a holistic understanding of what and why you want to create such a test suite.
As a general rule of thumb, you should test the behavior of your function and system instead of the implementation. Treat your function as a black box. Create a stub and focus on the output of your function.
In addition, keep it DRY when writing your unit tests, and keep business logic consistent in your test suite. Resist the urge to make your tests fancy. Keep them dead simple, and your testing suite will be the best.
Lastly, focus less on code coverage. It is a great tool to inform you which methods you should have accounted for. However, striving for 100% code coverage can create a slower development process and make your codebase unnecessarily more complex.
What methods, best practices, and principles have you found useful for writing unit tests? Comment them down below!