Testing React.js Hooks And Components: The Missing Piece

François Zaninotto
François ZaninottoAugust 31, 2021
#react

React.js Testing manual describes the basis for unit and e2e testing of React components. But I found it to be lacking key advice concerning integration tests, and how to use Storybook to boost observability and debuggability.

Testing React: The Baseline

Even though it advocates the use of its own render method, the React testing manual clearly states that the good practice is to use React Testing Library, the de-facto standard library for testing React components. So I'm going to use Jest and @testing-library/react as the baseline for all React tests.

Here is what a unit test with Jest and React Testing Library looks like (example taken from the react-testing-library readme):

import * as React from 'react';
import {render, fireEvent, screen} from '@testing-library/react';
import HiddenMessage from '../hidden-message';

test('shows the children when the checkbox is checked', () => {
  const testMessage = 'Test Message'
  render(<HiddenMessage>{testMessage}</HiddenMessage>);

  // query* functions will return the element or null if it cannot be found
  // get* functions will return the element or throw an error if it cannot be found
  expect(screen.queryByText(testMessage)).toBeNull();

  // the queries can accept a regex to make your selectors more resilient to content tweaks and changes.
  fireEvent.click(screen.getByLabelText(/show/i));

  // .toBeInTheDocument() is an assertion that comes from jest-dom
  // otherwise you could use .toBeDefined()
  expect(screen.getByText(testMessage)).toBeInTheDocument();
});

By reading this test, I can almost guess the implementation of the <HiddenMessage> component. Here is it:

import * as React from 'react';

const HiddenMessage = ({ children }) => {
  const [showMessage, setShowMessage] = React.useState(false)
  return (
    <div>
      <label htmlFor="toggle">Show Message</label>
      <input
        id="toggle"
        type="checkbox"
        onChange={e => setShowMessage(e.target.checked)}
        checked={showMessage}
      />
      {showMessage ? children : null}
    </div>
  )
};

export default HiddenMessage

Tip: What about Enzyme? Once the king of React component testing, this library encourages testing implementation details, has low workforce and doesn't support React hooks and React 17. When starting a React project today, choosing Enzyme is a very risky bet.

The Problems With React Tests: Observability And Debuggability

I've always found it peculiar that to test a UI library (React is a UI library), we don't use Graphical User Interface. The React Testing Library example above has this notion of screen, but it's an abstract object with which we're expected to interact through code.

When I test React components this way, I feel blind. It's like checking that a cake recipe is good without the sense of taste.

This really becomes a problem when tests are a bit harder to write, or when they don't run the expected way.

For instance, I have written an integration test for useCanAccess, a Role-Based-Access-Control (RBAC) hook that I'm developing for react-admin. The idea of this hook is to check if the current user has access to a resource, based on an array of permissions that is stored in a React context.

Here is the integration test:

// in src/useCanAccess.spec.jsx
import * as React from 'react';
import { render } from '@testing-library/react';
import { useCanAccess } from './useCanAccess';
import { AuthContext } from './auth/AuthContext';

describe('useCanAccess', () => {
    
    // system under test

    const CanAccess = ({ action, resource }) => {
        const { loading, canAccess } = useCanAccess({ action, resource });
        return loading
          ? <span>Loading</span>
          : canAccess
            ? <span>Allowed</span>
            : <span>Restricted</span>;
    };

    const authProvider = {
        ...defaultAuthProvider
        // simulate that permissions come from an API, with a delay
        getPermissions: () => new Promise(resolve => {
          return setTimeout(
            () => resolve({
              permissions: [
                { action: 'read', resource: 'posts' },
                { action: 'read', resource: 'comments' }
              ],
            }),
            100
          );
        }),
    };

    const Basic = () => (
        <AuthContext.Provider value={authProvider}>
            <ul>
                <li>Read posts:    <CanAccess action="read"  resource="posts" /></li>
                <li>Write posts:   <CanAccess action="write" resource="posts" /></li>
                <li>Read comments: <CanAccess action="read"  resource="comments" /></li>
            </ul>
        </AuthContext.Provider>
    );

    it('returns loading on mount', () => {
        const { queryAllByText } = render(<Basic />);
        expect(queryAllByText('Loading')).toHaveLength(3);
        expect(queryAllByText('Allowed')).toHaveLength(0);
        expect(queryAllByText('Restricted')).toHaveLength(0);
    });

    it('returns whether when the user has permission for the resource and action once the permissions are loaded', async () => {
        const { queryAllByText } = render(<Basic />);
        // wait for the permissions to be loaded
        await act(async () => {
            await new Promise(resolve => setTimeout(resolve, 120));
        });
        expect(queryAllByText('Loading')).toHaveLength(0);
        expect(queryAllByText('Allowed')).toHaveLength(2);
        expect(queryAllByText('Restricted')).toHaveLength(1);
    });
});

This is an integration test because React hooks can't be tested directly - they must be tested via a component. And this particular hook relies on a React context, so I need to build a relatively complex component tree to test it.

I find several problems in this test:

  • It is too long. By the time I write the second test, I have already forgotten what I should be looking for.
  • It encourages me to test implementation details. Since I have the code of the system under test close by, I'm tempted to test the presence of a dom element, instead of looking for strings in the rendered app, as a real user would do.
  • It's hard to debug because if my test fails, I can't see what's wrong visually.

The last problem often leads me to write throwaway React apps just to debug integration tests. And I often think that I should keep these mini-apps in the repository, to be able to debug my tests again in the future.

Using Storybook To Test Components Visually

Storybook is an open-source tool for building UI components and pages in isolation.

A good solution to the above problems is to move the System Under Test (SUT) to a separate component, and use Storybook to test it visually.

Here is a Story for the <Basic> component. I basically extracted the System Under Test into its own file, and added a default export compatible with the Storybook Component Story Format API:

// in stories/useCanAccess.stories.jsx
import React from 'react';
import { useCanAccess, AuthContext } from '../src';

const CanAccess = ({ action, resource }) => {
    const { loading, canAccess } = useCanAccess({ action, resource });
    return loading
      ? <span>Loading</span>
      : canAccess
        ? <span>Allowed</span>
        : <span>Restricted</span>;
};

const authProvider = {
    ...defaultAuthProvider
    // simulate that permissions come from an API, with a delay
    getPermissions: () => new Promise(resolve => {
      return setTimeout(
        () => resolve({
          permissions: [
            { action: 'read', resource: 'posts' },
            { action: 'read', resource: 'comments' }
          ],
        }),
        100
      );
    }),
};

export const Basic = () => (
    <AuthContext.Provider value={authProvider}>
        <ul>
            <li>Read posts:    <CanAccess action="read"  resource="posts" /></li>
            <li>Write posts:   <CanAccess action="write" resource="posts" /></li>
            <li>Read comments: <CanAccess action="read"  resource="comments" /></li>
        </ul>
    </AuthContext.Provider>
);

export default { title: 'ra-rbac/useCanAccess' };

Now I can run the yarn storybook command and visually test the <CanAccess> component in my browser:

useCanAccess story

So I can test my component visually, but I have a new problem: the testing code is duplicated between the integration test and the Storybook.

Using Stories In Integration Tests

Fortunately, as explained in the Storybook manual, you can reuse stories in unit and integration tests. Each "named export" in a story is renderable without depending on Storybook.

So I can rewrite my integration test to use the useCanAccess story:

// in src/useCanAccess.spec.jsx
import * as React from 'react';
import { render } from '@testing-library/react';
import { Basic } from '../stories/useCanAccess';

describe('useCanAccess', () => {
    it('returns loading on mount', () => {
        const { queryAllByText } = render(<Basic />);
        expect(queryAllByText('Loading')).toHaveLength(3);
        expect(queryAllByText('Allowed')).toHaveLength(0);
        expect(queryAllByText('Restricted')).toHaveLength(0);
    });

    it('returns whether when the user has permission for the resource and action once the permissions are loaded', async () => {
        const { queryAllByText } = render(<Basic />);
        // wait for the permissions to be loaded
        await act(async () => {
            await new Promise(resolve => setTimeout(resolve, 120));
        });
        expect(queryAllByText('Loading')).toHaveLength(0);
        expect(queryAllByText('Allowed')).toHaveLength(2);
        expect(queryAllByText('Restricted')).toHaveLength(1);
    });
});

I find this integration test much more concise and readable. And when I write new tests, I write expect calls based on what I see in the browser rather than the code of the SUT. This leads to tests that resemble the way our software is used.

"The more your tests resemble the way your software is used, the more confidence they can give you. " Kent C. Dodds

Tip: If you've included args/decorators in your stories, to allow a tester to modify the props of the component via the Storybook UI, you can get a configurable component in your unit tests by using the @storybookjs/testing-react package.

Conclusion

My current testing workflow is actually the opposite of what I've described above. first, I write a story to test the component visually (and adjust its look and feel), then I write a testing-library integration test to automate the visual test.

I know, it's not really Test-Driven Development, but I find that I need visual tests as soon as possible with React hooks and components - perhaps because it's a UI library.

I encourage you to adopt this Storybook-first approach and to publish the Storybook together with the library, as interactive proof that components are working as expected. This is what we do for react-admin Enterprise Edition, which has a public storybook where you can test every module. We also plan to do the same in React-admin Open-Source Edition. Stay tuned!

React-Admin Enterprise Edition Storybook

Did you like this article? Share it!