Subject testing for JavaScript

Ruby has a history of strong testing tools. That strong ethos of testing also influenced us at bitcrowd. Loosely inspired by RSpec’s subject, we’ve now come to use a similar pattern for testing React components.

Let’s look at it with an example, testing a styled Button component:

import { shallow } from 'enzyme';
import React from 'react';
import Button from './Button';

describe('Button', () => {
  it('accepts additional classes', () => {
    const wrapper = shallow(
      <Button
        onClick={jest.fn()}
        className="t-margin-bottom--large"
      >
        CTA
      </Button> )
    expect(wrapper).toHaveClassName('t-margin-bottom--large')
  });

  it('is clickable', () => {
    const handleClick = jest.fn();
    const wrapper = shallow(<Button onClick={handleClick}>CTA</Button>);

    wrapper.find('button').simulate('click');

    expect(handleClick).toHaveBeenCalled();
  });
});

The Button has a mandatory prop onClick, applies project-specific styles and otherwise behaves much like the HTML Button element.

With the subject pattern, we add a setup function:

import { shallow } from 'enzyme';
import React from 'react';
import Button from './Button';

describe('Button', () => {
  function subject(overrideProps) {
    const defaultProps = {
      onClick: () => {},
      children: ‘Click me...’,
    };
    const props = { ...defaultProps, ...overrideProps };
    return shallow(<Button {...props} />);
}

it(...); });

This pattern allows us to write concise tests without mystery guests and little specific setup within each test case.

Let’s look at some examples.

Structural testsLink to section

In tests where the component changes DOM structure directly based on the props, this can lead to very concise formulations:

describe('Button', () => {
  function subject(overrideProps) { ... }

  it('applies additional css classes', () => {
    expect(
      subject({ className: 't-margin-bottom--large' }).find('button'),
    ).toHaveClassName('t-margin-bottom--large');
  });

  it('renders the provided children as button label', () => {
    const children = (
      <React.Fragment>
        Lorem <em>ipsum</em>
      </React.Fragment>
    );
    expect(
      subject({ children }).find('button')
    ).toContainReact(children);
  });
});

An important aspect here is, that all structures that are asserted on, come from the test case. While some props do get default values in order to create valid subjects, those props are irrelevant to the specific test scenario and its assertions. A test-case reader does not need to know about them. They play no role here, and therefore you don’t need to be aware of them when adjusting the test later on.

Interaction testsLink to section

In interaction tests, we try to follow the common three-part pattern: Arrange Act Assert.

describe('Button', () => {
  function subject(overrideProps) { ... }

  it('is clickable', () => {
    const handleClick = jest.fn();
    const wrapper = subject({ onClick: handleClick });

    wrapper.find('button').simulate('click');

    expect(handleClick).toHaveBeenCalled();
  });
});

That tends to keep them nice and small and, most importantly, comprehensible for anybody coming back to it later.

During setup, the test-relevant props are created. The interaction pokes at the rendered component. In line with e.g. react-testing-library philosophy, we try to align those interactions with how a user would interact with the component. We rely on artificial event objects though, where needed. Last, we assert that the relevant effect was triggered: Events were forwarded and the DOM changed as hoped. Those three parts tend to be separated by some whitespace, to make the pattern more obvious. Where more setup or assertions are required, those are not the only breaks though.

For more complex interaction tests, we repeat the interaction-validation-cycle multiple times. I’d count that as a code smell though – as an indicator that something could be improved. Usually that comes from complex internal states, or interactions with event-heavy libraries, such as file-uploading or media players. Sometimes this might be an indication for you to pull out a test-helper function for those repeated interactions.

Additional benefitsLink to section

Once there is a setup function, you get some additional benefits.

It allows for one place to add wrappers that are needed to render the component, like a router, or an I18n provider. Those can be configured by the tests as needed with additional arguments to the subject-function, usually with default values for structural tests.

Additional mandatory props do not make you touch every test case, keeping them poignant.

Use a setup function to keep your tests focused and concise.

We’re hiring

Work with our great team, apply for one of the open positions at bitcrowd.

© 2021 bitcrowd GmbH.