Writing testable React components with hooks.

Charles Stover
Jan 27 · 7 min read

One of the most important aspects of clean code — code that will survive the long haul and thrive in large organizations and teams — is being testable. Testable code is confident code. It can be refactored without regressive behavior. It can be extended without introducing bugs. Testable code is a staple of a healthy application, and when it comes to React apps, testing is often unclear.

With React hooks being such a new introduction to the library, not too many teams have established testable code design patterns. Extending my previous article on Optimal file structure for React applications, I am aiming to give this topic the focus and attention it deserves. If you have not given said article a read, it is recommended, as this article will assume your file structure matches.

A React component should have one hook. 🎣

A React component’s hook can be comprised of multiple hooks.

Despite the move from class components to hooks, React components still thrive under the model of container and view. Before, a “smart” container managed and manipulated the state as needed before passing it as props to a “dumb” view component. This division of labor works for a reason. When data flow is unidirectional, it is difficult to manifest spaghetti code.

This pattern was simplified with hooks. A component dedicated to the view no longer needs state values passed to it as props. Even better, a component is no longer needed to manage state. The concept of a component being responsible for state and a component being responsible for view conflates the role of a component. The introduction of hooks changes this concept radically. I do not believe developers will turn back.

// Before:
// The handleClick event listener and total state were generated by
// a "smart" component and passed to the view component.
class StatefulComponent {
state = {
total: 0,
};
handleClick = () => {
this.setState(state => ({
total: state.total + 1,
}));
};
render() {
return (
<ViewComponent
onClick={this.handleClick}
total={this.state.total}
/>
);
}
}
function ViewComponent({ onClick, total }) [
return <span onClick={onClick}>{total}</span>;
}
// After:
// The handleClick event listener and and total state are generated
// by a hook and used by the component.
function useComponentName() {
const [total, setTotal] = useState();
const handleClick = useCallback(() => {
setTotal(total => total + 1);
}, []);
return {
handleClick,
total,
};
}
function ComponentName() {
const { handleClick, total } = useComponentName();
return <span onClick={handleClick}>{total}</span>;
}

Given the practices established in the Optimal file structure article, the “after” file structure should look something like this:

src/
- components/
- component-name/
- hooks/
- index.js
- use-component-name.js
- component-name.js
- index.js
- index.js

As you want to break your more complicated states down, you can expand your useComponentName hook into re-usable or single-responsibility sibling hooks and files, allowing each to be tested in a vacuum.

The important take-away here is the component has a single point of entry to the state it needs for its view: useComponentName. This single point of entry makes testing easy, because you test the view in a vacuum without any knowledge of changing hooks. If your component used 4 hooks one day and 5 the next, its existing test on the view would be incomplete yet still passing. At least with TypeScript, a changing state in a component with a single entrypoint will cause a failing test. Your mocks for that state are now missing, adding, or otherwise erroneously typing state values. This keeps confidence that your tests are up-to-date and you aren’t committing untested code.

Mock state to test views. 👓

In the days of yore, it was easy to test view components. You simply passed the state as props and tested that the view responded accordingly. Thanks to the easy mock-ability of barreled directories, it’s not much different to do the same with hooks!

// Before
const CLICK_HANDLER = jest.fn();
const { container } = render(
<ViewComponent
onClick={CLICK_HANDLER}
total={5}
/>
);
expect('5').toBeInTheDocument();
act(() => {
container.click();
});
expect(CLICK_HANDLER).toHaveBeenCalled();

This worked great. Tests were simple. State goes in, view comes out. Unidirectional data flow. Implementation details were not a concern. This patterns seems to have been obscured for some by hooks, and I posit that a single hook entry point that lives in a barrel directory resolves this.

// After
import * as hooks from './hooks';
const STATE_SPY = jest.spyOn(hooks, 'useComponentName');
const CLICK_HANDLER = jest.fn();
STATE_SPY.mockReturnValue({
handleClick: CLICK_HANDLER,
total: 5,
});
const { container } = render(<ComponentName />);
expect('5').toBeInTheDocument();
act(() => {
container.click();
});
expect(CLICK_HANDLER).toHaveBeenCalled();

Most of the test looks exactly the same. Given, when, then. Assign, act, assert. The only additional code is really that we import the barrel so that we may spy on the single entry point to state.

This is precisely why barrels are advocated in the Optimal file structure article. You can accomplish the same thing without barrels and instead use jest.mock. Unfortunately, you lose a lot of TypeScript and IDE power with mock. Since paths are string literals, they cannot be (or at least aren’t) validated. Since the path is not validated, its type cannot be inferred, and the return value of the mock cannot be type-checked. If I change the return type of my hook, my tests that use mock are none-the-wiser. With spyOn, TypeScript will catch this outdated test at compile time. One final love for spyOn is that it uses the return value directly. mock requires mocking and typing the entire module, and that’s a level of detail destined to be thrown under the any bus.

Testing hooks 🧪

For the crux of the pattern, how do you actually test the hook? I support the @testing-library/react-hooks package.

I deliberately chose the React hook in the introductory section because it includes both a state and an event handler that mutates the state. I will use it again here to showcase testing both types of stateful properties.

function useComponentName() {
const [total, setTotal] = useState();
const handleClick = useCallback(() => {
setTotal(total => total + 1);
}, []);
return {
handleClick,
total,
};
}

Given the above hook, we can establish a test template as follows:

import { renderHook } from '@testing-library/react-hooks';
import { useComponentName } from '.';
describe('useComponentName', () => {
describe('handleClick', () => {
it('should...', () => {
const { result } = renderHook(() => useComponentName());
expect(result.current.handleClick).toBe();
});
});
describe('total', () => {
it('should...', () => {
const { result } = renderHook(() => useComponentName());
expect(result.current.handleClick).toBe();
});
});
});

You can easily conceptualize a hook and its testing strategy with this format. The overall describe block covers the hook. Each property returned by the hook has a describe block. The event handlers can become large as their logic branches within the function. The primitive values are usually as simple as expecting the default value to be accurately set.

I’ll start by writing the primitives, because they are so easy to test.

describe('total', () => {
it('should default to 0', () => {
const { result } = renderHook(() => useComponentName());
expect(result.current.total).toBe(0);
});
});

That’s it!

Now let’s write the test for an event handler. These will be more in-depth, as they require acting on the component, typically mutate the state, and that mutated state needs to be tested for validity.

When writing the it blocks within a describe'd event handler, I start at the top of the event handler and walk down the code. Each time it branches (if), I add an it block for that branch.

As an example, take the following event handler:

if (loggedIn) {
window.alert('Yes');
} else {
window.alert('No');
}

A test suite for the above may look like so:

describe('handleSomething', () => {
it('should alert Yes when logged in', () => {
});
it('should alert No when logged out', () => {
});
});

This suite now covers all branches of this event handler.

With all branches defined, let’s actually write a test. Back to the original example for simplicity.

setTotal(total => total + 1);

I expect that when this event handler is executed, my total increments by 1.

describe('handleClick', () => {
it('should increment total by 1', () => {
});
});

Given: a state with no parameters

When: I fire the click event handler

Then: I expect total to increment by 1

describe('handleClick', () => {
it('should increment total by 1', () => {
// Given: a state with no parameters
const { result } = renderHook(() => useComponentName());
expect(result.current.total).toBe(0);
// When: I fire the click event handler
act(() => {
result.current.handleClick();
});
// Then: I expect total to increment by 1
expect(result.current.total).toBe(1);
});
});

And that is our test. A state and view with 100% coverage.

Summary 📝

  • Use a single hook for each component. This allows a single entry point for state to be mocked.
  • Test view components by mocking state. Given a particular state, I expect a particular view.
  • Test hooks by testing each returned state value. Use one describe block for each state property.
  • Test hook primitive state values by testing their defaults. Given hook parameters as input, I expect the default value to be…
  • Test hook event handlers by adding one it block for each branch in the logic. Given I trigger the branch in code, I expect the behavior to be…
  • Wrap your event handlers in act, which can be imported from @testing-library/react-hooks.

Conclusion 🔚

I truly hope this has been a helpful experience for you. If you have any questions or great hook testing commentary, please leave them in the comments below.

To read more of my columns, you may follow me on LinkedIn and Twitter, or check out my portfolio on CharlesStover.com.

Charles Stover

Written by

Senior Full Stack JavaScript Developer / charlesstover.com

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade