How to write useful unit tests and why do they matter?

What are the unit tests? 

Unit tests are small, independent pieces of code that describe some small part of a system. Each test can specify how a function should transform data or how components should behave by making assumptions about input data, interactions, and output data. In that way we can look at unit tests as documentation: “This function filters B from [A, B, A, B] by returning [A, A]” or “This component should call update action after clicking the button with the label ‘Delete’”. They also ensure that implementation of the function will not break after refactoring or adding new features in the feature - tests will fail if existing assumptions didn’t pass.

Is writing unit tests the core of Test-driven development? Read about the common misunderstandings and uncommon benefits of TDD.

The filter function is of course only one simple example of the unit which could be tested. There are many more complex cases, specific for frontend (like UI components) and there are a few caveats for junior developers. In this article, I’ll describe, how we approach writing useful unit tests in Merixstudio and why they are so important, not only for engineers.

How does a single unit test look like? 

As described previously, tests are a bunch of independent pieces of code run by a test runner to ensure that the application works as described. One of the most popular test runners for javascript files is Jest.js. Almost every test runner could be, for example, configured with a pre-commit hook (to run all tests before each commit), started before deploying the new version to production, or run only a subset of tests on-demand during development. With Jest, we could write tests without any unnecessary boilerplate. Here is an example of a filtering function test described in the introduction:

// filterUtil.test.js
import filterUtil from './filterUtil';

it('filters B from [A, B, A, B] by returning [A, A]', () => {
const inputData = ['A', 'B', 'A', 'B']
const filteredData = filterUtil(inputData)

expect(filteredData).toBe(['A', 'A']);
});

We are using only it() and expect() from Jest. Remember that we could run all tests at once or pick only one of them - each test has to be independent. You have to explicitly write input and output conditions and avoid introducing any extra logic to test handlers.

Testing interactive units

The example above describes a simple case of filtering function. In a similar way, we are testing more complex pieces, like Redux reducers or selectors. It is straightforward to test pure javascript functions. Writing unit tests could become a little bit tricky if we are testing interactive user interface components like buttons, forms, modals, etc which could change its internal state in the life cycle. Thankfully there are many solutions that help developers to render UI components in a test runner environment. One of them is @testing-library family which solves that issue for most of the modern JavaScript frameworks. With React Testing Library we could write UI-based test really hassle-free:

import { render } from '@testing-library/react'

describe('PageHeader', () => {
it('render passed header message', () => {
const customTitle = 'Hello world'
const { getByText } = render(<PageHeader title={customTitle} />)
expect(getByText(customTitle)).toBeInTheDocument()
})
}

As you can see, @testing-library/react provides a powerful render() method which returns a single object with selectors and other useful objects (getByText, geyByRole, and many more). One of the most important advantages of RTL is that it enables writing tests without diving into component-specific implementation. With that library, developers can avoid referring to classNames or any other implementation-specific attributes. Thanks to that, if the implementation of the component will change in the future, the tests themselves will be still valid.

Learn more about good practices in test-driven development in React applications

With RTL we could not only statically render components but we could also interact with them by firing events and tracking mocked function calls (with jest.fn()):

import { render, fireEvent } from '@testing-library/react'

describe('Page header', () => {
it('call logout action on logout button click', () => {
// declarations
const spy = jest.fn()
const { getByText } = render(<PageHeader handleLogout={spy} />)
// interaction
fireEvent.click(getByText('Logout'))
// assertions
expect(spy).toHaveBeenCalled();
})
})

As you can see in the example above, it’s a good practice to structurize more complex test cases into 3 parts:

  • Declarations (where we could define mocks, mount components, and define any other useful constants)
  • Interaction (where we can fire Events and change component’s state)
  • Assertions (where we can make assumptions about the current application state)

In the beginning, unit tests might look like the redundancy of code and developers might wonder what’s the reason to describe obvious parts of the codebase. If you look closely at the Section Header example above, you could imagine that in the future layout of the header might change or the logout button might get updated. In the worst case, a node with Logout text could disappear from the Page header or handleLogout the prop will not be passed to the updated version of a button. Even such a simple test file, ensure that the tested section will work as described in the future.

Code Coverage

Is there any way to check if all of the units in our project are tested? Let’s assume that our filter function definition looks like this:

// filterUtil.js
🟨 export default (inputArray, maxLength = false) => {
🟩 const filteredArray = inputArray.filter(el => el !== 'B')

🟥 if (maxLength > 1) {
🟥 return filteredArray.slice(maxLength - 1
🟥 }
🟩 return filteredArray
🟨 }

There are many tools for static code analysis that can detect which line of the code was executed during the test. Jest.js has a built-in tool for analyzing code coverage. Remember our filter function test described at the beginning of the article? We were calling filterUtil only with the first argument. Our util has some hidden power and could also trim arrays to the value specified in maxLength argument. If we will run an analyzer for that file, you probably will see that code coverage is less than 50%! If you open a report generated by Jest, you will be able to inspect each line of the code. It will look similar to the example above where you can see red blocks in the if statement. It means that those lines weren’t executed during the tests (yellow blocks means that the function exported by default was only partially covered). The best solution for improving the test is to add another scenario to check if the array is trimmed (and set maxLength to some numeric value).

Caveats

Higher coverage is better? Not always. That’s one of the unit tests pitfalls. Forcing code coverage ratio to be close to 100% is not always worth it. Developers shouldn’t write and maintain tests for functions that, by definition, will always be trivial. 

A lot of Redux selectors return simple objects without any complex logic. A bunch of that kind of dummy selectors could rapidly decrease code coverage. 

// example of selector w/o any complex logic
const getLoggedUserEmail => state => state.user.email

Sometimes developers fall into a snapshot trap. Almost every library which helps to render components in a test environment provides a method that returns rendered component markdown. You could save that in a separate snapshot file and compare the current markdown with the saved one on each test run.

// Jest Snapshot v1
exports[`Button should render as expected`] = `
<Box
as="button"
className="btn btnsuccess"
type="button"
/>
`;

It’s potentially a great tool for tracking changes in component markdown and it also rapidly increases code coverage. However, that kind of test didn’t bring a lot of value. They are not as descriptive as classic unit tests. If tests are failing because of snapshots it’s harder to debug code because error messages are not so informative. Developers will get only information that some node or classname is broken. It’s always better to make a bunch of assumptions about important features (rendering key labels, binding event handlers, etc.) instead of making a full snapshot. The best way to track component visual changes is to use end to end tests like cypress. In that way, we will be able to see visual diffs on screenshots! Comparing visual screenshots is always easier than comparing snapshot code (i.e. in pull requests).

Another issue is that developers tend to test complex components that are built on top of smaller pieces. Test files for big containers are often messy and hard to maintain. There is a lot of data that has to be mocked before each scenario so this is a great place where you can introduce an end-to-end test environment (like cypress). It is also worth remembering that if children are well tested there is absolutely no need to test their logic in a bigger component. For example, if the rendered button has a test for setting the correct color and label there is no need to check for those properties on a higher level. Sometimes even for experienced developers, it is not always clear to define what is the sufficient level of test code coverage.

Useful unit tests in 4 steps 

So what can developers do to write useful unit tests? Based on our experience in Merixstudio, we can write down the following tips:

  1. Understand and test the main features and core values of the component logic (do not test already tested children)
  2. Avoid snapshot testing - write assumptions for most important nodes instead (labels, binded action handlers, etc.)
  3. Try to write tests in the most naive way without diving into component implementation (React Testing Library really helps with that in React-based projects). Those kinds of tests are also more useful as documentation for non-technical team members.
  4. Track code coverage but don’t force coverage to be 100% especially for code pieces that will always be trivial

Unit tests - yes or no?

If unit tests are so useful, are there any disadvantages of writing them? For inexperienced developers, it’s quite easy to fall into a code coverage trap that causes test suites to be not as powerful as they could be. It’s also tricky for beginners to decide which components and features should be tested. Also, it might look that writing tests is quite time-consuming. However, we should remember that tests and documentation are a great way of keeping a high-quality clean code in the future. By spending ~10-20% of time writing tests we could avoid situations in the future when introducing a new feature to a non-tested codebase or fixing critical bugs could take an extraordinary amount of time. 

Creating unit tests is just one of a whole plethora of practices we implement to ensure high-quality code. See others

With all of the caveats in mind, writing meaningful tests could be hassle-free and it will provide two crucial benefits to the project. All of the test suites will ensure that every component works as expected in the future and every complex component will have documentation out of the box which could be valuable even for non-technical team members. Keep in mind that unit tests, by definition, test each component in separation - it’s great to support the QA process by any kind of integration tests.
 

Navigate the changing IT landscape

Some highlighted content that we want to draw attention to to link to our other resources. It usually contains a link .