3 Tips to Write Good Unit Tests for Your Team | by Edward Huang | Nov, 2022

Your tests failed. Again? There was no bug. You changed an implementation detail, and your app still works, but your test is broken. Are Unit Tests Useless?

photo by Battlecreek Coffee Roasters Feather unsplash

Software engineers hate writing unit tests.

Despite how annoying unit tests can be, we all know that unit tests are good for building 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’re a software engineer, chances are you’ll touch unit-tested systems. Even when you get a green check mark after running the test suite, the test suite needs to be built properly, or it may result in false positives.

Many teams need to learn how to do proper unit testing; Some don’t see the value of making them. For example, many engineers only test the happy path of a function, but need to account for unhappy or complex test cases.

In this article, we will discuss the best practices and gotchas for writing a good unit test so that your team can reap all the benefits of unit testing.

Test maintenance is one of the main hurdles for teams trying to adopt unit testing.

Engineers are often resentful; Development slows down a lot when test suites are too fragile and fail when they change or refactor the codebase a bit.

Does breaking a test suite due to minor changes to the codebase or refactoring represent a fragile or robust test suite?

The answer is, it depends,

Testing based on the implementation makes the test suite very difficult to maintain. Furthermore, trying to change an implementation detail will give you false negatives.

Or worse, you generate false positives that can’t fail if you break the application code.

When it comes to unit testing, you should prevent them from being coupled with the internals of the code you’re testing. In this way, tests are more resilient to change, allowing developers to adjust the internal implementation and refactor when necessary while providing valuable feedback and a safety net.

By testing behavior rather than implementation, you can refactor code to make improvements and quickly verify whether you’ve changed behavior intentionally or accidentally by running your tests.

Suppose you create a random service in which a getNextFunction that returns the next random string:

trait Random  def getNext(): String
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
"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))

We are exposing our unit test to the implementation getNext rather than the result of (behavior) getNext,

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 the implementation. Although it looks like we cover many branches, every refactor or small change within the implementation will break some test suite. Each new feature change and hotfix becomes a nightmare for developers. Those tests provide little value because every codebase change would also require changing the existing test suite.

Another way to prevent implementation testing is to adopt test-driven development.

Test-driven development is the notion of writing tests first before writing your implementation. The process includes the following:

  • Writing tests first (depending on the behavior of your system). This test will not work and neither will compile at first.
  • Write your implementation, then make sure your test turns green.
  • Refactor out any duplication in your code without ever touching the test again – just to make your test work.

The test suite shouldn’t have to look inside the method to see what it’s doing. It would help if you refrained from testing private methods directly. If you’re interested in knowing whether or not your private code is being tested, use a code coverage tool. However, you’ll 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 what branches it might have. Ideally, you should test that all implementation routes exhibit expected behavior.

You know your unit tests are written based on behavior rather than implementation when a PM can understand what your test suite is doing.

This is the general rule to see if your transactional coverage is good enough.

A common mistake is when the method we want to test is also used inside an assertion.

You should not use the method itself (or any internal code that is used by it) to dynamically generate the expected result. The expected result should be hardcoded in your test case so that it remains the same even if the implementation changes.

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 above code is problematic because the test code is almost a carbon copy of the implementation code. If the same person wrote both the tests and the implementation, they could make the same error in both places. But since the test mirrors the implementation, the test may still pass, which puts you in a terrible situation: The implementation is wrong, but the test fools you into thinking otherwise.

Also, you should try for high-value, low, effort tests. This means you abstract test functions to be an easy one-liner, creating a useful generic test that we can reuse over and over again.

For example, when we test the JSON encoder and decoder in Scala, we can create a generic method that encodes and decodes 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)

One major advantage is that you reduce the amount of duplicate code in your test suite. Because often, when we need to change some behaviors in test, we may miss one of them if we don’t centralize those test suite’s behavior in one.

Coverage can be very useful because it lets developers know what percentage of their code is covered by tests. It is also easy to check which parts of their code are not covered in unit tests. However, having good coverage does not mean that your tests are exhaustive.

Code coverage is like a cleanliness factor. A cleanliness factor is one element that, if missing, will lower your confidence as a developer.

You should look at code coverage with ” glass half empty” perspective.

Use the coverage to find the parts of your system you still need to check. Then, identify whether 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 getting 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. Above 85% starts giving diminishing returns.

Why? Because reaching 100% coverage requires the developer to introduce some complexity into the code by creating an abstraction, such as an interface.

Furthermore, code coverage causes developers to write their unit tests in terms of implementation rather than behavior. You’ll notice that many unit tests contain a lot of 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 test Check to make sure all components and pieces are wired correctly.

For example, I often see teams will try to test an entry point to a codebase or main flow and mock every other dependency because they want to reach 100% code coverage. Still, it would be advisable to avoid unit testing the main function or main flow and go with integration testing instead. Treat your controller and your service as a black box and run some integration tests to make sure all the dependencies are running properly.

Unit tests test only one code unit at a time, but production code interacts with many other coding components. Thus, it is always a good idea to combine your unit tests with some other automated tests to ensure that bugs will not be introduced into the production environment.

Everyone can write unit tests. However, writing a good unit test is complicated. It requires patience and an overall understanding of what and why you want to build such a test suite.

As a general rule of thumb, you should be testing the behavior of your function and system rather than the implementation. Think of your task as a black box. Create a stub and focus on the output of your function.

Additionally, keep it dry when writing your unit tests, and keep business logic consistent across your test suites. Resist the urge to make your tests fancy. Keep them absolutely simple, and your test suite will be the best it can be.

Finally, pay less attention to code coverage. This is a great tool to inform you what methods you should have accounted for. However, striving for 100% code coverage can create a slow development process and unnecessarily over-complicate your codebase.

What methods, best practices and principles have you found useful for writing unit tests? Comment them below!

Leave a Reply