Level Up Your Unit Testing Skills with these Pro Tips

Hao profile photo
By Hao  • 

In this article, I'll be covering some tips to spice up your unit tests. Although the examples here are shown with JavaScript and Jest, the topics should be transferable to the language and tool of your choice.

These practices were researched and shared in our internal team's "Knowledge Sharing" session, which occurs bi-weekly!

Table of Contents

Adhere to Code Boundaries

the example function

ProductHelper.js

the base unit test

ProductHelper.test.js

Unit tests are the least expensive way of testing, but it won't be cheap if you're testing other modules.

module dependencies illustrated

By using live dependencies in a unit test, test cases become brittle. Breaking changes to a module's dependencies, and/or peer dependencies will cascade down to the unit test.

The rule of thumb for unit testing is to respect the code boundary (only test things within the module), and mock the dependencies. Tests will be more maintainable, focused and contain less duplication of test logic. A healthy assumption is that other dependencies come with their own set of unit tests.

code boundary applied test case

By mocking the dependencies, the code boundary is being respected.

mocked from ts-jest is a helpful wrapper that adds typing to your mocked object.

An integration test can be created to validate and improve confidence that this module can work with real dependencies and/or other modules.

Flat Test Cases

With every nested condition, cognitive complexity increases and the code becomes harder to maintain. This is also true for tests.

Let's refactor the above tests to have the least amount of nesting possible.

flat test applied

A modern test runner like Jest is able to redirect the user to the exact file and assertion that has failed in the console. For this reason, certain contextual information can be lifted to the file hierarchy.

Instead of using a single file to hold utility functions, the file can be refactored into a folder called "ProductHelper" to contain files, where each file is a helper function. As a result, the first two Describe blocks can be removed. When an assertion has failed, a user can find out about the context by looking at the file path. (../ProductHelper/RemovePricing.test.js:50:23)

  ProductHelper
    |-RemovePricing.js
    |-RemovePricing.test.js
    ...OtherUtilityFunctions

A Describe block containing only one test assertion can be simplified by merging the contextual info with the assertion statement.

    it("should remove pricing when retrieving an existing product", ()=>{})

Just like that, 3 levels of nesting were removed. The test files are smaller, more focused, and easier to reason with. +1 for maintainability.

Test-Case Array

test case array

When testing a variety of inputs, using a test case array structure will reduce duplication of boilerplate code and improve the maintainability of your tests.

enhanced test case array

An improved version to better categorize the test inputs.



Co-locating Tests

"Place code as close to where it's relevant as possible" - Kent C. Dodds

Non-co-located Test File

Example #1

  src
  |--product.ts
  tests
  |--product.test.ts

Example #2 (Slightly better...)

  src
  |--product.ts
  |--tests
      |--product.test.ts

Co-located Test File!

Example #3

  src
  |--product.ts
  |--product.test.ts

The principle of co-location can be applied to many places in software development. For testing, co-locating the test file to be in the same directory as the module it's testing will improve maintainability.

  • Easy to find. There is no need to navigate to a separate directory just to access a module's test file.
  • Simple import statement. ../../../../testfile vs ./testfile
  • Easy to refactor. Changing directory names won't break the test, and removing features become easier when related things are grouped together.

Mock Factory

When mocking dependencies, you will come across scenarios where a frequently used object needs be mocked. Often times, the mock requires slight change within each test case as seen below.

a mock factory example

Under the assumption that the object is also being mocked elsewhere, creating a mock factory can bring the following benefits:

  • Reduce code duplication. Duplication of mocks (especially large ones) will increase build time for CI/CD.
  • Flexibility and reusability. You can add new ways to configure your factory, and quickly spin up a mock for your tests.
  • Promotes a decoupled test architecture. By using a mock factory, the user does not have to worry about the details (arguments and dependencies) of an object construction.

a mock factory example

An example of a mock factory in action.

Dynamic Mocks

example dynamically generated mock data

Leveraging libraries like Faker.js to randomly generate data is helpful for catching potential edge cases and increasing confidence in your tests, because they mimic real data.

Scenarios where Faker could be useful:

  • Seed a database for testing REST API.
  • Develop UI components in parallel with the back-end implementation.
  • Showcase UI components in Storybook with real-life data.

If there is a need for data consistency, Faker can be configured with a seed to ensure it generates the same set of data each time.

Conclusion

  • Only test things within your module and mock dependencies.
  • Flatten your test cases to reduce cognitive load.
  • Use the test-case array technique to reduce boilerplate and duplicate code.
  • Co-locate the test file to the module you're testing to improve usability and ease of refactor.
  • Use mock factories to decrease build time and decouple test architecture.
  • Use dynamic mocks to mimic real-life data for testing and parallel development.

There you have it! In this article I showed you a few tips and techniques to make writing and maintaining unit tests more delightful.

Happy Testing 🚀

Sources:

Share: