First, why do we need tests?
Let’s suppose you are a developer. Now let’s suppose you are a front-end developer. Imagine that you have been recently hired to perform this role in a company that has a large, complex React web application. You see that managing such a complex application is a struggle. You don’t understand the actual flow of information, you see that there’s some inexplicable logic that you are afraid to touch, there’s a deep component nesting architecture, and so on.
If someone told you to make some changes in this codebase, this would probably be your reaction: 😱.
But you recall hearing from some wise white-haired man that there’s a way to manage such complexity. That man used a specific word to address this problem: refactoring! This is typically done when you want to rewrite a piece of code in a simpler way, breaking the logic into smaller chunks to reduce complexity.
Before you proceed, you remember that this is a very complex application. How can you be sure that you’re not breaking anything? You recall something else from the wise old man: testing!
Our choice for front end
At Cloud Academy, we have our large complex application built on a stack composed of React/Styled Components/Redux/Redux saga. The sagas are responsible for making the Rest API calls and updating the global state, while components will receive that state, updating the UI accordingly.
So, given our React stack, our choice was:
- jest as test runner, mocking and assertion library
- @testing-library/react or enzyme for unit testing React components (depending on the test purpose, or the project)
- cypress for end-to-end testing (which will not be covered in this article)
Testing Library vs. Enzyme
Even though these two libraries both result in testing React components, they differ significantly in how the test is performed.
-
Enzyme
focuses testing on implementation details, since you need to know the internal structure of the components and how they interact with each other. This gives you more control over the test execution, enabling deep testing on state and property changes. However, it makes the tests brittle, since almost every modification to the implementation needs a test update. Generally speaking it can be useful when testing simple components in which you have some logic that you don’t want to be altered, or with less component aggregation and interaction and more focused on content rendering. We use Enzyme in our design systemBonsai
library. -
Testing Library for React
focuses on how the components behave from a user standpoint. To perform the test, you don’t need to know the implementation details, but rather how it renders and how the user should interact with it. This enables testing of very complex components. Since you don’t need to be concerned about the internals, you only need to give meaningful props, mock dependencies where needed (independent from the chosen framework), and test the output, checking what you expect to be rendered (content, labels, etc.) or interacting as a user would. We use Testing Library for React in our main application project, with which you can test whole pages without worrying too much about their complex structure.
Let’s dig into how we perform tests our main React codebase leveraging Testing Library
.
What should you test?
We can see our application structured in different layers:
- Global state and API calls located in Redux layers (actions, reducers, sagas, selectors)
- Business logic and state management in containers (or for more recent components in hooks)
- Presentation logic in lower level components
- Common utilities
Each of these layers deserves a thorough tour about how they should be tested, but this article is focused on how we test React layers, so we’ve identified four main categories of tests:
- Containers: the most complex components, where you usually test behaviors and you need heavy use of dependency stubs and fixtures
- Components: since they should be “dumb,” testing here should be simpler
- Hooks: see them like “containers” as functions, so you have the same needs and same containers approach
- Generic functions: not really bound to Testing Library, but still needed when you use them in components
Containers
This is usually the page entry point, where all the magic happens by provisioning the actual data to the underlying components and controlling their actions with callbacks.
A complete test usually needs:
- Dependencies properly mocked (leveraging jest mocks and dependency injection)
- Fixtures to mock data and make assertions (expectations on content can be done with them)
- Stub actions or callbacks in order to assert the behavior of the interactive parts using spies, etc. (e.g., error notifications, browser events, API callbacks)
- Manage or simulate asynchronicity
If the test is well prepared, assertions are very easy, as you probably just need to inject the fixture data and check if it’s rendering like you expect. This ensures fairly wide coverage of the internal components, without taking into account the implementation details.
One suggested practice is to test at least the “happy path” and the “error” situations. Testing all the other possible code branches is also highly recommended, but only after the most common paths have been covered.
beforeEach(() => { ... mockHistory = createMemoryHistory(); initialState = { .... //some content here course: { description: '.....' nextStep: { title: '....' } } ... }; }); test('should render the course step content from the state', () => { connectedRender( <Router history={mockHistory}> <ContainerWithSomeContent /> </Router>, { initialState, reducer, }, ); expect(screen.getByText(initialState.course.description)).toBeInTheDocument(); expect(screen.getByText(`Next: ${initialState.course.nextStep.title}`)); });
Connected components
Let’s take a small detour through how to test connected components. As you see above, the test is using the connectedRender
API. This is a utility function to enable testing on containers that are connected to Redux store without having to set up its boilerplate code in every test.
In order to test those kind of components, you simply need to pass these items to this utility function: the jsx, the reducer and the initial state that will be used to construct the store that will forward the state using the redux Provider.
The following is the implementation of that utility.
import React from 'react'; import { render as rtlRender } from '@testing-library/react'; import { createStore } from 'redux'; import { Provider } from 'react-redux'; function render( ui, { initialState, reducer, store = createStore(reducer, initialState), ...renderOptions } = {}, ) { function Wrapper({ children }) { return <Provider store={store}>{children}</Provider>; } return rtlRender(ui, { wrapper: Wrapper, ...renderOptions }); } // re-export everything export * from '@testing-library/react'; // override render method export { render as connectedRender };
Components
Given that the “happy path” is being tested on the container, there’s the possibility that you don’t need to perform tests on the internal components, that should be dumb as much as possible. If some kind of logic is present here (e.g., displaying different labels depending on props values), it’s also good to create tests to cover these specific situations, handling them here instead of containers.
If you have many possible combinations, a suggested practice is to write tabular tests.
Hooks can be tested without being forced to render them inside components using renderHook
from @testing-library/react-hooks
library. Testing hooks is somewhat similar to testing functions and components at the same time.
The hook returns a result
object on which you can make assertions by accessing its current
property.
You should avoid immediate destructuring here if you are planning to call a function on the result that changes the hook state. In fact, this triggers the library to re-render the result, assigning a new reference to the result.current
property.
//render the hook const { result } = renderHook(() => useSomeSuffWithChangeableState()) //read its result value const { changeState, value } = result.current // if you expect to change the state here and have an updated value act(() => { changeState() }) // this assertion will fail, since the "value" will still be the old one expect(value).toEqual(..) // to assert the new value you need to re-read it expect(result.current.value)
Generic functions
Generic functions usually don’t need anything from react testing library, since no jsx rendering should happen inside of them. In this case, you can simply assert using standard matchers, again with tabular testing if needed.