Skip to content

Latest commit

 

History

History
512 lines (354 loc) · 28 KB

testing.md

File metadata and controls

512 lines (354 loc) · 28 KB

Eventbrite React Testing Best Practices

Guidelines and best practices used by Eventbrite to provide consistency and prevent errors in testing React components. This does not cover testing utility/helper functions (including Redux reducers) that are used in conjunction with React as those follow general testing guidelines.

Table of Contents

  1. Testing environment
  2. Testing philosophy
  3. Writing a test case
  4. Finding nodes
  5. Finding components
  6. Testing existence
  7. Assertion helpers
  8. Types of renderers
  9. Testing render
  10. Testing events
  11. Testing state
  12. Testing updated props

Testing environment

Eventbrite uses Jest and enzyme for unit testing React components. We also leverage jest-enzyme assertion helpers. Enzyme wraps ReactTestUtils, which contains a bunch of primitives for testing components.

Don't use ReactTestUtils directly; use Enzyme!

⬆ back to top

Testing philosophy

Unit testing React components can be a little tricky compared to testing the input/output of traditional JavaScript functions. But it's still doable! Just like with "normal" unit testing, we want to test all of the logic within the component via its public interface. The public input to a component is its props. The public output of a component is the combination of the elements it specifically renders (see Testing render) as well as the callback handlers it invokes (see Testing events). The goal is to render components with various configurations of their props, so that we can assert that what is rendered and what callbacks are invoked is as expected.

⬆ back to top

Writing a test case

Use arrow functions to force functional test cases:

it('does what it is supposed to do', () => {

});

Using arrow functions prevents being able to use beforeEach & afterEach because this is now lexically scoped. In the past, data common to each test case was stored on this in beforeEach (and cleaned up in afterEach) so that each individual test case didn't have to generate the data itself. However, beforeEach devolved into a dumping ground for anything that may get used by more than one test case. As such way more data was generated than was needed, unnecessarily slowing down test execution.

Instead, factor out helper data generation functions and call them as needed in the test cases:

const generateComponent = (additionalProps={}) => (
    <Component {...additionalProps} />
);

it('does what it is supposed to do', () => {
    let wrapper = mount(generateComponent());
});

⬆ back to top

Finding nodes

Search for nodes within a component by adding data-spec attributes to them. In the past, Eventbrite used special js-* CSS classes for references to nodes in JavaScript code. These js-* classes were used when testing as well. Now with React testing, instead of using special CSS classes, refs, or attempting to traverse the DOM with Enzyme's find helper, we use data-spec attributes.

The data-spec attribute is specific to testing and not tied to presentation like CSS classes would be. If we decide to rename or remove a CSS class, the tests should not be impacted because there is no implicit link between styles and tests. We leverage a helper, getSpecWrapper, to find nodes with the data-spec attribute. Suppose we had the following (simplified) generated markup for a Notification component:

<div class="notification">
    <button class="notification__close" data-spec="notification-close">X</button>
    <p class="notification__message">
        You have successfully registered for this event!
    </p>
    <a href="https://www.eventbrite.com/d/" class="notification__more-link" data-spec="notification-more-link">Browse all events</a>
</div>

Tests using getSpecWrapper would look like:

// good
it('has more link pointing to browse URL when `type` is browse', () => {
    let onMoreAction = jest.fn();
    let wrapper = mount(<Notification type="browse" onMoreAction={onMoreAction} />);
    let moreLinkWrapper = getSpecWrapper(wrapper, 'notification-more-link');

    moreLinkWrapper.simulate('click');

    expect(onMoreAction).toHaveBeenCalled();
});

// bad (searches by tag name and CSS class)
it('has more link pointing to browse URL when `type` is browse', () => {
    let onMoreAction = jest.fn();
    let wrapper = mount(<Notification type="browse" onMoreAction={onMoreAction} />);
    let moreLinkWrapper = wrapper.find('a.notification__more-link');

    moreLinkWrapper.simulate('click');

    expect(onMoreAction).toHaveBeenCalled();
});

As a reference, here are the implementations for getSpecWrapper:

// utils/unitTest.js

export const DATA_SPEC_ATTRIBUTE_NAME = 'data-spec';

/**
* Finds all instances of components in the rendered `componentWrapper` that are DOM components
* with the `data-spec` attribute matching `name`.
* @param {ReactWrapper} componentWrapper - Rendered componentWrapper (result of mount, shallow, or render)
* @param {string} specName - Name of `data-spec` attribute value to find
* @param {string|Function} typeFilter - (Optional) Expected type of the wrappers (defaults to all HTML tags)
* @returns {ReactComponent[]} All matching DOM components
*/
export const getSpecWrapper = (componentWrapper, specName, typeFilter) => {
    let specWrappers;

    if (!typeFilter) {
        specWrappers = componentWrapper.find(`[${DATA_SPEC_ATTRIBUTE_NAME}="${specName}"]`);
    } else {
        specWrappers = componentWrapper.findWhere((wrapper) => (
            wrapper.prop(DATA_SPEC_ATTRIBUTE_NAME) === specName && wrapper.type() === typeFilter
        ));
    }

    return specWrappers;
};

⬆ back to top

Finding components

You can find a component simply by using Enzyme's find and passing the component class:

it('should render a checked checkbox if it is selected', () => {
    let wrapper = mount(<Component isSelected={true} />);
    let checkboxWrapper = wrapper.find(Checkbox);

    expect(checkboxWrapper).toHaveProp('isChecked', true);
});

This works as long as there's only one Checkbox rendered within Component. If there are multiple Checkbox components within Component, checkboxWrapper would have multiple elements in it. Instead you can add a data-spec attribute to the specific Checkbox and use getSpecWrapper:

// good
it('should render a checked checkbox if it is selected', () => {
    let wrapper = mount(<Component isSelected={true} />);

    // pass the component class as the third parameter to `getSpecWrapper`
    let selectAllCheckboxWrapper = getSpecWrapper(wrapper, 'component-selectAll', Checkbox);

    expect(selectAllCheckboxWrapper).toHaveProp('isChecked', true);
});

// bad (finds the appropriate Checkbox based on source order)
it('should render a checked checkbox if it is selected', () => {
    let wrapper = mount(<Component isSelected={true} />);
    let selectAllCheckboxWrapper = wrapper.find(Checkbox).at(2);

    expect(selectAllCheckboxWrapper).toHaveProp('isChecked', true);
});

The key in the "good" example is the third parameter passed to getSpecWrapper. By default getSpecWrapper will try to find a node with the specified data-spec. But if you specify the component class (Checkbox in this case), it'll return a reference to the component wrapper.

⬆ back to top

Testing existence

Testing node existence

To find nodes you use the getSpecWrapper helper and use the jest-enzyme .toBePresent and .toBeEmpty assertion matchers:

let wrapper = mount(<Spinner />);

// assert that node exists (doesn't throw an Error)
expect(wrapper).toBePresent();

// assert that node doesn't exist (throws an Error)
expect(wrapper).toBeEmpty();

⬆ back to top

Testing component existence

Typically, you'll find components by using Enzyme's find method which returns an an Enzyme ReactWrapper and the jest-enzyme .toBePresent and .toBeEmpty assertion matchers:

let wrapper = mount(<Select values={dummyValues} />);
let selectOptionWrappers = wrapper.find(SelectOption);

// assert that there are no found nodes
expect(selectOptionWrappers).toBeEmpty();

// assert that there are more than zero found nodes
expect(selectOptionWrappers).toBePresent();

// assert there to be a specific number of found nodes
expect(selectOptionWrappers).toHaveLength(dummyValues.length);

⬆ back to top

Assertion helpers

Whenever possible, use jest-enzyme assertion helpers in favor of the normal assertion helpers that just come with jest:

// good (leverages `.prop` from `jest-enzyme`)
it('should render a checked checkbox if it is selected', () => {
    let wrapper = mount(<Component isSelected={true} />);
    let checkboxWrapper = wrapper.find(Checkbox);

    expect(checkboxWrapper).toHaveProp('isChecked', true);
});

// bad (just uses `enzyme` with vanilla `jest`)
it('should render a checked checkbox if it is selected', () => {
    let wrapper = mount(<Component isSelected={true} />);
    let checkboxWrapper = wrapper.find(Checkbox);

    expect(checkboxWrapper.prop('isChecked')).toBe(true);
});

Functionally the "good" and "bad" assertions are the same. The assertions will both pass when the isChecked prop is true and both fail when it's false. The difference is in the reported error when they fail.

When the "good" assertion (using jest-enzyme's .toHaveProp helper) fails, you'll receive an error such as:

AssertionError: expected the node in <div /> to have a 'isChecked' prop with the value true, but the value was false

However, when the "bad" assertion fails, you'll receive a more cryptic (and less helpful) error such as:

AssertionError: expected false to equal true

The "good" example has significantly more context and should be significantly more helpful when looking through failed test logs.

⬆ back to top

Types of renderers

Enzyme provides three types of renderers for testing React components:

  • mount - for components that may interact with DOM APIs, or may require the full lifecycle in order to fully test the component (i.e., componentDidMount etc.)
  • shallow - performant renderer because it renders only single level of children (no descendants of those children) in order to ensure that tests aren't indirectly asserting on behavior of child components
  • render - renders the components to traversable static HTML markup

Eventbrite uses mount for rendering all components when testing.

For components that just render markup (atoms in atomic web design), rendering with mount makes the most sense because they are the most likely to access the DOM API. Shallow rendering (via shallow) would be of little to no use.

For components that are a mix of markup and small components (molecules in atomic web design), rendering with mount also makes the most sense because of all the markup that still exists. It's simpler to stay consistent without the test file and use mount for all tests.

For components that are basically a composite of other components (organisms in atomic web design), we would ideally render with shallow because you're basically just testing that that the child components are receiving the correct props. Furthermore, it's faster to just render one level than render the entire markup tree, especially when the component is big. But in practice we make heavy use of helper components in order to keep render() lean. As a result, what ends up being shallow rendered is not the actual child component, but an intermediary helper component. This means that if you wrote a test using shallow and then refactored the code to use helper components, your tests will break when the resultant render is actually still the same. Because of this nuance of when and where shallow can work, we've chosen to opt for mount because it always works. The trade-off is performance, which for now hasn't been a big enough issue.

⬆ back to top

Testing render

When testing what a React component is rendering, only test what the component itself renders. Therefore if a parent component renders child components, such as a TextFormField component rendering Label & TextInput components, only test that the parent renders those child components and passes the appropriate props to them. Do not test the markup rendered by the child components because the tests for that child component will cover that.

// good
it('displays label when `labelText` is specified', () => {
    let wrapper = mount(<TextFormField labelText="Name" />);
    let labelWrapper = wrapper.find(Label);

    // assert that when `labelText` is specified
    // the Label component is rendered
    expect(labelWrapper).toBePresent();

    // assuming that `labelText` gets passed like:
    // <Label>{labelText}</Label>
    // asserts that it's properly passed
    expect(labelWrapper).toHaveProp('children', 'Name');
});

// bad (assumes the markup the child component is rendering)
it('displays label when `labelText` is specified', () => {
    let wrapper = mount(<TextFormField labelText="Name" />);

    expect(labelWrapper).toBePresent();
    expect(labelWrapper).toHaveText('Name');
});

The "bad" example assumes that the Label component is rendering a <label> tag, but the TextFormField component shouldn't really know or care what Label renders. It treats Label as a black box in its implementation so the test should do the same. Imagine if the Label component changed to render a <div> instead of a <label>. All of the tests for components using a Label component would now unnecessarily fail. On the other hand, the "good" example tests that the TextFormField properly renders the <Label> component and that the labelText prop is passed as its content (the children prop).

The easiest way to test HTML elements and their attributes, is to use Jest snapshots:

// good
it('includes the disabled CSS class when `isDisabled` is `true`', () => {
    let wrapper = mount(<Spinner isDisabled={true} />);

    // assert that the current render matches the saved snapshot
    expect(wrapper).toMatchSnapshot();
});

While snapshot testing is very simple, that simplicity comes at a cost. The initial snapshot file is generated the first time the test is run, so you need to visually inspect that the generated snapshot is correct, otherwise you could be saving a bad test case. Furthermore, the snapshot does not convey the intent of the test so you need to have a very verbose/descriptive test case title (the it()).

Also because we use mount for rendering, the entire component tree is in the snapshot, including any helper components, higher-order components, etc. The larger the component, the larger a snapshot will be. For atoms, you can use snapshots liberally because atoms are exclusively markup and are small. Organisms are generally large components composed of several molecules and other smaller organisms; the component itself has very little markup making the snapshots bloated not very meaningful. As such, you should use snapshot testing sparingly and instead test that child components are rendered and get the appropriate props. Molecules are somewhere in between and you should use your best judgment as to when to use snapshot testing.

Lastly, since snapshot files are saved to disk, running the tests are slower than traditional means of unit testing.

⬆ back to top

Testing events

As mentioned in our Testing philosophy, part of the output of your component are the callback handlers it invokes. These event callbacks are functions passed as props to your component and need to be tested.

Test event callbacks by triggering the events that in turn will invoke the callback handler. The type of event triggered depends on whether the component contains HTML markup or child components.

Testing events triggered by DOM

If you are testing an event callback that is triggered by a DOM event (such as onClick of an <button> node), you will need to simulate that DOM event. You will also need to stub the event callback prop to assert that it is being called with the correct arguments.

Let's say that there is a TextInput component that wraps an <input type="text" /> DOM node. The TextInput has an onChange prop that gets called whenever the input field value changes. The onChange prop is also called with the current value that's in the input field. The test case would be set up like:

it('properly fires `onChange` when input changes', () => {
    let onChange = jest.fn();

    // pass mock function to component as `onChange` prop
    let wrapper = mount(<TextInput onChange={onChange} />);
    let inputWrapper = getSpecWrapper(wrapper, 'text-input');
    let inputValue = 'Here is a value';

    // Create a fake event with the properties needed by the component
    let mockEvent = {
        target: {
            value: inputValue
        }
    };

    // simulate onChange event on input DOM
    inputWrapper.simulate('change', mockEvent);

    // assert that the stubbed function was called with the
    // expected value
    expect(onChange).toHaveBeenCalledWith(inputValue);
});

The test case above uses jest.fn() to create a mock function. The mock is passed as the TextInput component's onChange prop so that we can make assertions on it at the end. After finding a reference to the input field, we simulate a fake onChange DOM event on the input field (using Enzyme's .simulate helper). Because the TextInput implementation expects to read e.target.value from an actual DOM event when it's running the browser, we have to mock that event with an object of the same structure. We don't need a full mock DOM event; we only need to mock what the code is actually calling.

Simulating the fake event on the input field will ultimately call our onChange with its current value. Therefore, our assertion is that onChange was not only called, but also called with the expected input value. This assertion leverages the .toHaveBeenCalledWith assertion helper from jest-enzyme.

⬆ back to top

Testing events triggered by child components

More than likely instead of your component adding event handlers directly to DOM nodes, it will be adding handlers to child components. Therefore instead of simulating a DOM event, simulate the child component's event handler being invoked.

Let's say you have an AutocompleteField component that has a child TextInput. The AutocompleteField has an onChange prop that is invoked whenever its child TextInput's onChange event is invoked. The AutocompleteField's onChange prop also passes the current input value. The test case would be set up like:

it('properly fires `onChange` when input changes', () => {
    let onChange = jest.fn();

    // pass stubbed function to component as `onChange` prop
    let wrapper = mount(<AutocompleteField suggestions={[]} onChange={onChange} />);
    let textInputWrapper = wrapper.find(TextInput);
    let inputValue = 'Here is a value';

    // We don't want to make any assumptions about the markup of `TextInput`. The
    // `AutocompleteField` component handles `onChange` of `TextInput`, so all we need to
    // do is call the prop directly like `TextInput` would and ensure we get the appropriate
    // value
    textInputWrapper.prop('onChange')(inputValue);

    // assert that the stubbed function was called with the
    // expected value
    expect(onChange).toHaveBeenCalledWith(inputValue);
});

The test case above uses jest.fn() to create a mock function. The mock is passed as the AutocompleteField component's onChange prop so that we can make assertions on it at the end. After finding a reference to the TextInput, we simulate how TextInput would invoke its onChange callback prop. We get a reference to the prop using Enzyme's .prop helper and call the function with the inputValue. This exactly how TextInput would call it when its DOM input field changes. However, because we don't want to make any assumptions about the markup of TextInput we simulate its onChange prop instead of digging into it in order to simulate its DOM.

Invoking the onChange prop will ultimately call our onChange with the value. Therefore, our assertion is that onChange was not only called, but also called with the expected input value. This assertion leverages the .toHaveBeenCalledWith assertion helper from jest-enzyme.

⬆ back to top

Testing state

Although jest-enzyme provides a .toHaveState() helper method for asserting component state, it shouldn't be used in tests because the component's state is internal (and shouldn't be tested). Based on our testing philosophy, we only want to test the public API of the component.

When a component's state changes, the component is re-rendered, resulting in a change in markup. By testing only the changed markup (part of the component's public output), instead of the component's internal state, we can refactor the component's internals and have all of our test cases still pass. In sum, our test cases are a little less fragile.

Let's say for instance we had a component that has a Checkbox child component that toggles the component between inactive and active states. The active state is publicly represented by an isActive class added to the root DOM node. The test case could look something like:

// good (tests internal state *indirectly* via re-rendered markup)
it('toggles active state when checkbox is toggled', () => {
    let wrapper = mount(<Component />);
    let checkboxWrapper = wrapper.find(Checkbox);

    // first assert that by default the active class is *not* present
    expect(wrapper).toMatchSnapshot();

    // simulate toggling the checkbox on by calling its
    // onChange callback handler passing `true` for
    // checked state
    checkboxWrapper.prop('onChange')(true);

    // now assert that the active class *is* present
    expect(wrapper).toMatchSnapshot();

    // simulate toggling the checkbox back off
    checkboxWrapper.prop('onChange')(false);

    // finally assert once again that active class is *not*
    // present
    expect(wrapper).toMatchSnapshot();
});

// bad (tests internal state directly)
it('toggles active state when checkbox is toggled', () => {
    let wrapper = mount(<Component />);
    let checkboxWrapper = wrapper.find(Checkbox);

    // assert that component's `isActive` internal state is
    // initially false
    expect(wrapper).toHaveState('isActive', false);

    // simulate toggling the checkbox on by calling its
    // onChange callback handler passing `true` for
    // checked state
    checkboxWrapper.prop('onChange')(true);

    // now assert that the `isActive` internal state is
    // true
    expect(wrapper).toHaveState('isActive', true);

    // simulate toggling the checkbox back off
    checkboxWrapper.prop('onChange')(false);

    // finally assert once again that `isActive` internal
    // state is false
    expect(wrapper).toHaveState('isActive', false);
});

Both the "good" and "bad" test cases are basically the same. The only difference is what is asserted. Ultimately, what we care about is that the root node has the appropriate CSS class; the changing of the internal isActive state just happens to be the mechanism that we accomplish it. This is what makes the "good" example better.

See Testing events triggered by child components for more on simulating child component events.

⬆ back to top

Testing updated props

Typically components are stateless, meaning that what is rendered by the component is 100% based upon the props that are based in. In these cases creating a component with initial props when testing render and testing events as explained above should suffice. There shouldn't be a need to test the re-render of a component receiving new props.

However, when a component leverages internal state and its props are changed, what will be rendered will be based on a combination of those updated props and the existing state. In this case, test that the new markup is as it should be, indirectly verifying that the updated prop(s) either have or have not overridden the existing state.

Let's say we have a TextInput component. It has initialValue & value props (among many others). The initialValue prop will initialize the TextInput component's underlying <input> node's value, but won't override the node if the prop is later updated. However, the value prop will both initialize the <input> as well as override its value.

To test the initialValue prop behavior:

it('does NOT allow `initialValue` to override existing <input> value', () => {
    let initialValue = 'react';
    let newValue = 'enzyme';
    let wrapper = mount(<TextInput initialValue={initialValue} />);

    // ensure that the `initialValue` is properly reflected
    // by checking the <input> node
    expect(wrapper).toMatchSnapshot();

    // update the TextInput's props
    wrapper.setProps({initialValue: newValue});

    // ensure that the <input> node's value hasn't changed
    expect(wrapper).toMatchSnapshot();
});

To test the value prop behavior:

it('DOES allow `value` to override existing <input> value', () => {
    let initialValue = 'react';
    let newValue = 'enzyme';
    let wrapper = mount(<TextInput initialValue={initialValue} />);

    // ensure that the `initialValue` is properly reflected
    // by checking the <input> node
    expect(wrapper).toMatchSnapshot();

    // update the TextInput's props
    wrapper.setProps({value: newValue});

    // ensure that the <input> node's value has changed
    expect(wrapper).toMatchSnapshot();
});

The key to passing new props to the existing TextInput component is the setProps helper method. It will cause a re-render, which will allow us to assert that the new markup is as it should be.

⬆ back to top