Cover
JavaScript

Seven examples to test your React components with Jest and @testing-library

Introduction
Seven examples to test your React components with Jest and @testing-library

I have started using @testing-library to test my components and I love it for several reasons. I love the principle of testing behaviour, it just makes a lot of sense to me.

Testing Library · Simple and complete testing utilities that encourage good testing practices
Simple and complete testing utilities that encourage good testing practices

Sure it was already clear that you should not test internal implementation of components, but @testing-library encourages you to not test implementation at all. I always loved testing components, it gave a me a feeling of safety and being a good programmer, but it always felt like little repetitive.

You create a new component, you add props to it, you test if the component is doing the right things if specific props are passed. You fire events, you test if specific callbacks are fired. You change data and see if your state responds to it. Even if you only test the externals of your components it's still an implementation detail. The user doesn't care about props, methods or callbacks. The user clicks buttons and wants to see specific elements, the user wants to know if there is an error or if there he can read a specific text.

Testing implementation details is a recipe for disaster. - Kent C. Dodds

But one of the biggest advantages over other frameworks like Enzyme is, if you ask me,  that @testing-library is framework agnostic. You can use it with React, but you can also use it with Vue or Svelte, which is awesome. That means you learn this framework once and you can test any of them.

The biggest downside of it is the name... it's so weird to always say "@testing-library", but if I just say "testing library" people might just ask "which testing library are you talking about?"

How does it work?

I won't go into every detail of the framework, because this would be outdated very soon and you are better off reading their official documentation.

Installation

First you will just add @testing-library to your Jest setup. This is basically a very straightforward task but I will show you my setup when I created the first approach of Baretheme.

import '@testing-library/jest-dom/extend-expect';
import { matchers } from 'jest-emotion';
import matchMediaPolyfill from 'mq-polyfill';

require('intersection-observer');

matchMediaPolyfill(window);
window.matchMedia('(min-width: 920px)');
window.resizeTo = function resizeTo(width, height) {
  Object.assign(this, {
    innerWidth: width,
    innerHeight: height,
    outerWidth: width,
    outerHeight: height,
  }).dispatchEvent(new this.Event('resize'));
};

expect.extend(matchers);

// Throw error on wrong propTypes validation
const originalConsoleError = global.console.error;
beforeEach(() => {
  global.console.error = (...args) => {
    const propTypeFailures = [/Failed prop type/, /Warning: Received/];

    if (propTypeFailures.some((p) => p.test(args[0]))) {
      throw new Error(args[0]);
    }

    originalConsoleError(...args);
  };
});

// Tippy.js needs this
global.document.createRange = () => ({
  setStart: () => {},
  setEnd: () => {},
  commonAncestorContainer: {
    nodeName: 'BODY',
    ownerDocument: document,
  },
});
jest.setup.js

So first I just import the tools provided by @testing-library. But in the following lines I setup things like

  • jest-emotion to test my CSS-in-JS styles
  • a match media polyfill to test responsive components and a resizeTo helper method on window
  • I force my testing setup to throw errors when prop validation is failing, otherwise they will only throw warnings
  • Tippy.js is using the createRange API of document so you will have to mock this, otherwise it will just crash when testing

Adding helpers for theming and render errors

In many of my tests I am importing this at the very beginning:

import { render, expectRenderError } from '../helpers';

I am than going to replace the render method of @testing-library with my own render method, which internally uses the render method of the framework. My render method however adds a wrapper to apply my themes.

The expectRenderError method is here to catch render errors. Otherwise your log would be full of error messages created by React itself.

Implementation looks like this:

import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import { render } from '@testing-library/react';
import { themes, ViewportProvider } from '@baretheme/ui';
import { ThemeProvider } from 'emotion-theming';

const Wrapper = ({ children }) => {
  const theme = themes[0];
  return (
    <ThemeProvider theme={theme}>
      <ViewportProvider>
        {children}
      </ViewportProvider>
    </ThemeProvider>
  );
};

Wrapper.propTypes = {
  children: PropTypes.node.isRequired,
};

const customRender = (ui, options) => render(ui, { wrapper: Wrapper, ...options });

export { customRender as render };

export function expectRenderError(element, expectedError) {
  // Noop error boundary for testing.
  class TestBoundary extends React.Component {
    constructor(props) {
      super(props);
      this.state = { didError: false };
    }

    componentDidCatch() {
      this.setState({ didError: true });
    }

    render() {
      return this.state.didError ? null : this.props.children;
    }
  }

  TestBoundary.propTypes = {
    children: PropTypes.node.isRequired,
  };

  // Record all errors.
  const topLevelErrors = [];
  function handleTopLevelError(event) {
    topLevelErrors.push(event.error);
    // Prevent logging
    event.preventDefault();
  }

  const div = document.createElement('div');
  window.addEventListener('error', handleTopLevelError);
  try {
    ReactDOM.render(
      <TestBoundary>
        {element}
      </TestBoundary>,
      div,
    );
  } finally {
    window.removeEventListener('error', handleTopLevelError);
  }

  expect(topLevelErrors.length).toBe(1);
  expect(topLevelErrors[0].message).toContain(expectedError);
}
helper.js

What do I test?

1. Testing the basics of a component

I probably start most of my tests just with a basic smoke test. So I just test if the component actually renders or if it renders its children.

it('renders without crashing', () => {
    const text = 'Test';
    const { container } = render(<Bar>{text}</Bar>);
    expect(container.firstChild).toBeInTheDocument();
  });

  it('renders children', () => {
    const text = 'Test';
    const { getByText } = render(<Bar>{text}</Bar>);
    const node = getByText(text);
    expect(node).toBeInTheDocument();
  });

2. Testing proxy components or prop forwarding

Then most of my component just pass props straight down to their children or an HTML element, so I can test if that works.

it('passes props', () => {
    const text = 'Test';
    const { container } = render(<Bar data-test="test">{text}</Bar>);
    expect(container.firstChild).toHaveAttribute('data-test', 'test');
  });

3. Testing if styles are applied

Testing styling is always a nightmare because it's a visual thing, only human can tell if it really looks right. But you can at least check if the style rules are changing according to props that you are passing. I don't really like this testing because it's some kind of implementation detail but in this case there are no ideal cases. You could also do snapshot testing and compare the diffs but it has some other downsides as well.

This is how you test CSS-in-JS with jest-emotion:

it('adds correct styles with align', () => {
    const text = 'Test';
    const { container, rerender } = render(<Bar align="left">{text}</Bar>);
    expect(container.firstChild).toHaveStyleRule('justify-content', 'flex-start');

    rerender(<Bar align="center">{text}</Bar>);
    expect(container.firstChild).toHaveStyleRule('justify-content', 'center');

    rerender(<Bar align="right">{text}</Bar>);
    expect(container.firstChild).toHaveStyleRule('justify-content', 'flex-end');
  });

And this is how you test for the actual style attribute:

it('sets correct styles when closed', () => {
    const text = 'Test';
    const { getByText } = render(<Sheet position="bottom">{text}</Sheet>);
    const node = getByText(text);
    expect(node).toHaveStyle(`
      transform: translateX(0px) translateY(0px);
    `);
  });

Because this is very technical I am only testing a single style rule in this case. When this rule is applied, I can be sure that my whole logic of toggling the styles is working and I don't have to repeat every single style that I am applying in this case.

4. Testing render errors

Some components probably won't render at all under certain situations. I had this case when I was trying to test my Compound Components. They throw an error if they are not used within the correct parent element.

describe('Bar.Item compound component', () => {
    it('can not be used without Bar', () => {
      const text = 'Test';
      expectRenderError(<Bar.Item>{text}</Bar.Item>, 'Bar compound components cannot be rendered outside the Bar component');
    });
});

In this case I have to use the previously described expectRenderError helper.

5. Testing events

To test events you will have to import the fireEvent method from your specific framework adapter. Like so for react:

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

The fireEvent method then provides other methods to emulate browser events, like click, focus, change and so on. Here are some examples:

Using click:

it('fires the onToggle method on click', () => {
    const handleToggle = jest.fn();
    const { container } = render(<Burger onToggle={handleToggle} />);

    fireEvent.click(container.firstChild);
    expect(handleToggle).toHaveBeenCalled();
  });

Using focus and change:

it('hides the placeholder with a value and when focused', () => {
    const placeholderText = 'Placeholder-Text';
    const { getByTestId, getByText } = render(
      <Field placeholder={placeholderText} />,
    );
    const node = getByTestId('input');
    fireEvent.focus(node);
    fireEvent.change(node, {
      target: {
        value: 'Test',
      },
    });
    const placeholder = getByText(placeholderText);
    expect(placeholder).not.toBeVisible();
  });

Using keyUp:

it('calls onClose callback on ESC press', () => {
    const text = 'Test';
    const handleClose = jest.fn();
    const { container } = render(<Offscreen onClose={handleClose}>{text}</Offscreen>);
    fireEvent.keyUp(container.firstChild, { key: 'Escape', code: 27 });
    expect(handleClose).toHaveBeenCalled();
  });

Using mouseOver:

it('shows the tooltip on mouseOver', () => {
    const text = 'Test';
    const tooltipText = 'Tooltip';
    const tooltipContent = <strong>{tooltipText}</strong>;
    const { queryByText } = render(
      <Tooltip content={tooltipContent}>
        <div>{text}</div>
      </Tooltip>,
    );
    const node = queryByText(text);
    fireEvent.mouseOver(node);
    expect(instance.popper.querySelector('strong')).not.toBeNull();
  });

6. Testing DOM elements that you cannot query by text

In this case @testing-library encourages you to use data-testid. If your first reaction to this is "this will blow my pretty code" I have you covered. You can simply remove those attributes in production using Babel. Here is an example:

module.exports = {
  presets: ['@babel/preset-env', '@babel/preset-react'],
  plugins: ['@babel/plugin-proposal-class-properties'],
  env: {
    production: {
      plugins: [
        ['react-remove-properties', { properties: ['data-test'] }],
      ],
    },
  },
};

But why the hell do you need those attributes? Simply because it's better to query by a specific attribute that only exists for this scenario than to misuse any other attribute like class or id which serves a complete different use case. Chances are you will change a class or id because you want to change styling or anchor links and you won't update your tests. When you see data-testid in your component, you exactly know this is meant for testing and you will either have to change test when you touch it or make sure its functionality stays the same.

For example I have a Burger component that shows an = Icon when closed and a x icon when opened. I can't just query this by text because there is no text. So I do this instead:

const Burger = ({
  onToggle, isOpen, ...props
}) => (
  <BurgerIcon {...props} onClick={onToggle}>
    {isOpen ? (
      <Icon
        data-testid="close"
        path={mdiClose}
      />
    ) : (
      <Icon
        data-testid="open"
        path={mdiMenu}
      />
    )}
  </BurgerIcon>
);
burger.js

And my test for this is very simple:

it('displays the right icon if open', () => {
    const { getByTestId } = render(<Burger />);
    const open = getByTestId('open');
    expect(open).toBeInTheDocument();
  });

  it('displays the right icon if closed', () => {
    const { getByTestId } = render(<Burger isOpen />);
    const close = getByTestId('close');
    expect(close).toBeInTheDocument();
  });

7. Testing if your component renders with a specific HTML tag

The methods getByTestId or getByText only look for child elements in the tested component. Sometimes however you want to change the DOM element of your component itself. In my case I have a Paragraph component that may also be used as span or div or whatever. It test it like this:

it('renders as specified element', () => {
    const text = 'Test';
    const type = 'span';
    const { container } = render(<Paragraph as={type}>{text}</Paragraph>);
    expect(container).toContainElement(container.querySelector(type));
  });

Further reading

Testing Implementation Details
Testing implementation details is a recipe for disaster. Why is that? And what does it even mean?
Marc Mintel
Author

Marc Mintel

Marc Mintel is a self taught JavaScript and Frontend Developer with heavy focus on React and Vue.

View Comments
Next Post

TinaCMS: probably the best way to edit your Gatsby site in 2020

Previous Post

Let me introduce and walk you through Baretheme

Success! Your membership now is active.