Unit Tests: Writing for Strength, Not Coverage

David Faizulaev
Chegg

--

Today, writing unit tests is essentially a mandatory practice when writing code. Unit tests allow us to be confident when merging our branches to main, and they ensure that the existing behaviors still work and were not affected by the latest changes.

But, do your unit tests really provide that? We should consider:

  • If you only test the output you receive from a module, then you are likely missing some edge cases worth testing.
  • If you wrote tests only for the sake of coverage, and not for testing all possible scenarios, then your code will likely surprise you with some unexpected behavior when something untested or unexpected happens.

Let’s look at how you can improve your tests with stronger validations.

Please note that the following examples are using Jest and Typescript, but other testing frameworks, such as Mocha, Sinon, and Chai, also provide similar functionalities.

The emphasis is on how and what to test for, and how to do it properly. The manner of implementation can differ between frameworks, languages, and so on.

Testing for edge cases

Let’s look at the following module, which returns a user’s purchased items:

userPurchasesModule.ts

Now let’s look at our test file:

userPurchasesModule.spec.ts

We can see that the test file tests only two scenarios — one successful scenario where we only validate the return value, and another negative scenario where the httpClient throws an exception.

We can strengthen the tests by adding validations on our httpClient mock, for example:

Checking the number of calls to a mock (or mocks) and the arguments they were called with, ensures us that we had the desired flow — meaning all relevant function calls were made, and with the right arguments.

Notice that the mock calls validation is done outside of the try & catch. This is in the event that you mocked a function incorrectly and the call actually passed. Then, no error will be thrown, and the mock validations won’t be reached because we won’t reach the catch part.

This is a nice way to verify data integrity in our code.

But what about the scenarios which exist in our if statement? Let’s add tests for those too.

userPurchasesModule.spec.ts

Now we have 100% coverage of the module file.

But are we missing something? Yes! We are missing tests for 3 possible scenarios.

  1. What happens if httpClient.sendRequest returns any empty object {}?
  2. What happens if httpClient.sendRequest returns null?
  3. What happens if httpClient.sendRequest returns undefined?

These scenarios will cause an exception in our if statement, so let’s fix the code to handle them and add tests to verify.

userPurchasesModule.ts

userPurchasesModule.spec.ts

Lastly, let’s recall that we have a test for the error scenario. But what will happen if someone touches the module code and changes the error handling? For example:

In case of an exception, the error is no longer thrown by the module as we expect, but is instead being handled inside the module.

This means that our current test will become useless, as it will always pass. We need to modify it slightly:

Notice that the validations occur outside of the try & catch block, we save the thrown error in a separate variable, and we added the expect(httpClientMock).rejects.toThrow(‘some error’); validation. This validation allows us to verify that the httpClientMock indeed threw an error as expected.

Therefore, the best practice when testing for error scenarios is to add the toThrow validation. This way, we ensure any behavior changes that affect error handling will be reflected in our unit tests.

Applying what we’ve learned

To summarize, when writing unit tests, be sure to make them strong and check for any possible and testable scenarios and edge cases.

Make sure your tests not only cover the expected result, but also:

  • Write your code to be testable, as the smallest components can be independently verifiable.
  • Test if all the modules (mocks) along the way were called, and how many times.
  • Check if mocks were called with expected input (to validate that arguments are not modified along the way).
  • Test with different input and output values — for example, check what happens if a module mock returns null or an unexpected value type, like a number instead of a string.
  • Make sure to check all if/else paths.
  • Check for errors properly, and use toThrow to practice.
  • If you can’t test a behavior with unit tests, then try to test it using E2E tests, integration tests, or another test type — don’t leave code untested!

This approach will take more time, but in the long term, it has tremendous benefits in strengthening our code.

--

--