React Testing Examples

Enzyme
Open Source
Jump to

Setup

React requires a requestAnimationFrame polyfill to run in Jest. Enzyme needs to be configured with an adapter that matches your React version.

Configs get hairy in time, but this is the minimal setup required to get things running with React and Enzyme inside Jest. All tests examples below run using these exact config files.

jest.config.js
module.exports = {
  setupFiles: ['./jest.setup.js']
};
jest.setup.js
// https://reactjs.org/docs/javascript-environment-requirements.html
global.requestAnimationFrame = cb => setTimeout(cb, 0);

// The rAF shim must be loaded before the Enzyme setup
require('./enzyme.setup');
enzyme.setup.js
Enzyme.configure({ adapter: new React16Adapter() });

Callback fires on button click

The component receives a callback and renders a button. We test that the function prop is called upon clicking the button.

component.js
test.js
// Hoist vars to make them accessible in all test blocks
let wrapper;

beforeEach(() => {
  // Flush instances between tests to prevent leaking state
  wrapper = mount(<CustomButton onClick={jest.fn()} />);
});

it('calls "onClick" prop on button click', () => {
  wrapper.find('button').simulate('click');
  expect(wrapper.prop('onClick')).toHaveBeenCalled();
});

Text with prop value is rendered

The component renders variable text based on a string prop. We test that the component renders the value of the passed prop.

component.js
test.js
// Hoist vars to make them accessible in all test blocks
let name = 'Satoshi';
let wrapper;

beforeEach(() => {
  // Flush instances between tests to prevent leaking state
  wrapper = mount(<HelloMessage name={name} />);
});

it('renders personalized greeting', () => {
  expect(wrapper.text()).toContain(`Hello ${name}`);
});

Local component state

The component reads and updates a counter from its local state.

We test that the component renders the count state value. Then we click on the increment button, which updates the local state, and test that the component now renders the incremented value.

component.js
test.js
// Hoist vars to make them accessible in all test blocks
let count = 5;
let wrapper;

beforeEach(() => {
  // Flush instances between tests to prevent leaking state
  wrapper = mount(<StatefulCounter />);
  wrapper.setState({ count });
});

it('renders initial count', () => {
  expect(wrapper.text()).toContain(`Clicked ${count} times`);
});

it('renders incremented count', () => {
  wrapper.find('button').simulate('click');
  expect(wrapper.text()).toContain(`Clicked ${count + 1} times`);
});

Redux state and action

The component reads and updates a counter from the Redux store.

We test that the component renders the count state value. Then we click on the increment button, which updates the Redux state, and test that the component now renders the incremented value.

component.js
test.js
// Hoist vars to make them accessible in all test blocks
let count = 5;
let store;
let wrapper;

beforeEach(() => {
  // Flush instances between tests to prevent leaking state
  store = createStore(counterReducer, { count });
  wrapper = mount(
    <Provider store={store}>
      <ReduxCounter />
    </Provider>
  );
});

it('renders initial count', () => {
  expect(wrapper.text()).toContain(`Clicked ${count} times`);
});

it('renders incremented count', () => {
  wrapper.find('button').simulate('click');
  expect(wrapper.text()).toContain(`Clicked ${count + 1} times`);
});

React Router load and change URL

The component is connected to React Router. It renders a variable text containing a URL parameter, as well as a Link to another location.

First we make sure the component renders a param from the initial URL. Then we check that upon clicking on the Link element, the URL param from the new location is rendered, which proves that the page has successfully routed.

Alternatively, we could just test the to prop of the Link element. That's also fine, but this test is closer to how a user thinks. Eg. "Click on a link, the page behind that link is rendered." This type of thinking makes tests more resilient against implementation changes, like upgrading the router library to a new API.

component.js
test.js
let wrapper;

beforeEach(() => {
  // Flush instances between tests to prevent leaking state
  wrapper = mount(
    <MemoryRouter initialEntries={['/users/5']}>
      <Route path="/users/:userId">
        <UserWithRouter />
      </Route>
    </MemoryRouter>
  );
});

it('renders initial user id', () => {
  expect(wrapper.text()).toContain(`User #5`);
});

it('renders next user id', () => {
  wrapper
    .find('a')
    .find({ children: 'Next user' })
    // RR Link ignores clicks if event.button isn't 0 (eg. right click events)
    // https://github.com/airbnb/enzyme/issues/516
    .simulate('click', { button: 0 });

  expect(wrapper.text()).toContain(`User #6`);
});

XHR requests

The component reads and updates a counter from the server via HTTP requests. The requests are made using the XMLHttpRequest API.

We test that the component renders the count value from the mocked API response.

Then we click on the increment button, which makes a POST request to increment the counter, and test that the component now renders the incremented value.

This test is async, because server requests don't resolve immediately. We begin running data assertions as soon as the loading message is no longer rendered.

component.js
test.js
let count = 5;
let wrapper;

let notSyncing = () => {
  // Enzyme wrapper is not updated automatically since v3
  // https://github.com/airbnb/enzyme/issues/1163
  wrapper.update();
  return !wrapper.text().match('Syncing...');
};

let simulateIncrement = async () => {
  wrapper.find('button').simulate('click');
  await until(notSyncing);
};

beforeEach(() => {
  // Create fresh mocks for each test
  xhrMock.teardown();
  xhrMock.setup();
  xhrMock.get('/count', async (req, res) => {
    // Simulate 0.2s delay
    await delay(200);
    return res.status(200).body({ count });
  });
  xhrMock.post('/count', (req, res) =>
    res.status(200).body({ count: ++count })
  );

  // Flush instances between tests to prevent leaking state
  wrapper = mount(<ServerCounter />);
});

it('renders initial count', async () => {
  await until(notSyncing);
  expect(wrapper.text()).toContain(`Clicked 5 times`);
});

it('renders incremented count', async () => {
  await until(notSyncing);
  await simulateIncrement();
  await simulateIncrement();
  expect(wrapper.text()).toContain(`Clicked 7 times`);
});

Fetch requests

The component reads and updates a counter from the server via HTTP requests. The requests are made using the Fetch API.

We test that the component renders the count value from the mocked API response.

Then we click on the increment button, which makes a POST request to increment the counter, and test that the component now renders the incremented value.

This test is async, because server requests don't resolve immediately. We begin running data assertions as soon as the loading message is no longer rendered.

component.js
test.js
let count = 5;
let wrapper;

let notSyncing = () => {
  wrapper.update();
  return !wrapper.text().match('Syncing...');
};

let simulateIncrement = async () => {
  wrapper.find('button').simulate('click');
  await until(notSyncing);
};

beforeEach(() => {
  // Create fresh mocks for each test
  fetchMock.restore();
  fetchMock.mock({
    matcher: '/count',
    method: 'GET',
    response: { count }
  });
  fetchMock.mock({
    matcher: '/count',
    method: 'POST',
    response: () => ({ count: ++count })
  });

  // Flush instances between tests to prevent leaking state
  wrapper = mount(<ServerCounter />);
});

it('renders initial count', async () => {
  await until(notSyncing);
  expect(wrapper.text()).toContain(`Clicked 5 times`);
});

it('renders incremented count', async () => {
  await until(notSyncing);
  await simulateIncrement();
  await simulateIncrement();
  expect(wrapper.text()).toContain(`Clicked 7 times`);
});

LocalStorage read and write

The component reads and updates a value from LocalStorage. We test that the component renders the mocked name LocalStorage item.

Then we type a new name into an input, submit the form, and test that the submitted value has been updated in LocalStorage.

component.js
test.js
class LocalStorageMock {
  constructor(store = {}) {
    this.store = { ...store };
  }

  getItem(key) {
    return this.store[key] || null;
  }

  setItem(key, value) {
    this.store[key] = value.toString();
  }
}

let wrapper;

beforeEach(() => {
  // Create fresh mocks for each test
  global.localStorage = new LocalStorageMock({ name: 'Trent' });

  // Flush instances between tests to prevent leaking state
  wrapper = mount(<PersistentForm />);
});

it('renders cached name', async () => {
  expect(wrapper.text()).toContain(`Welcome, Trent`);
});

describe('on update', () => {
  beforeEach(() => {
    wrapper.find('input').instance().value = 'Trevor';
    wrapper.find('button').simulate('submit');
  });

  it('renders updated name', async () => {
    expect(wrapper.text()).toContain(`Welcome, Trevor`);
  });

  it('caches updated name', async () => {
    expect(localStorage.getItem('name')).toBe('Trevor');
  });
});

styled-components with theme

The component uses styled-components and builds its styles based on theme values. This means the component only works when wrapped inside the ThemeProvider context.

Note: Comparing classes between themes is not a recommended way to test styles! Whether or not styles should be tested in any way is debatable. This example is merely demonstrates how to set up ThemeProvider in a test.

component.js
test.js
function getClassName(theme) {
  let wrapper = mount(
    <ThemeProvider theme={theme}>
      <HelloMessage>Hello world!</HelloMessage>
    </ThemeProvider>
  );

  return wrapper
    .find(HelloMessage)
    .find('span')
    .prop('className');
}

it('changes styles with theme', () => {
  expect(getClassName(themeLight)).not.toEqual(getClassName(themeDark));
});