Testing React in a Right Way

Varik Matevosyan

Steadfast.tech/CTO

varik@steadfast.tech

github.com/var77

linkedin.com/in/varik-matevosyan-99200a108/

Overview

  • What we need to test in react
  • Why we need to have tests
  • Jest + Enzyme
  • Cypress

What we need to test in React

  • Test component to be rendered without errors.
  • Test component rendering based on passed props.
  • Test user interactions with component (click, inputs).
  • Test component UI (styles, dimensions, etc.).

What we don't need to test

  • Third party libraries
  • Outside interactions (API calls)
  • React lifecycle methods

Why we need to have tests

At first we can think that writing tests is a waste of time, especially for react when we can see the result visually. But with the experience when you're starting to work on a big projects in big teams you're starting to feel the need of tests when accidentally fixing a bug causes more bugs, or when you should wake up at 3AM to fix a production bug which is being shown in rare case.

Jest

Let's start writing test. 

For the first we need a test runner, the most popular ones are mocha and jest, mostly the last one is used with react as it is being developed by facebook team and it has lots of features especially for testing react applications.

Jest

I've already prepared a small react application to test, let's see what is it and what can we test there.

I've created the app with create-react-app so we already have Jest setted up in the project.

As we can see there're two components which we can test the Checkbox and the Input components, which are my wrapped instances. The Input component has some functionality but let's discuss it later, for the first let's make sure that our application is running and rendering without crash.

Enzyme

Let's set up enzyme and start writing our first test.

After setting up enzyme with react-16 adapter and enzyme-to-json snapshot serializer we can start writing our first test.

To check if our app renders without crashing we can just create a snapshot of the app and make sure if it matches the previous snapshot.

Enzyme

So create App.test.js file and add the following lines

import React from 'react';
import App from './App';

import { shallow } from 'enzyme';

describe('App component', () => {
    it('renders without crashing', () => {
        const wrapper = shallow(<App/>);
        expect(wrapper).toMatchSnapshot();
    });
});

As we can see we're importing enzyme's shallow method and expecting our shallowed component to match the snapshot, so let's see what's going on under the hood.

Enzyme

import React from 'react';
import App from './App';

import { shallow } from 'enzyme';

describe('App component', () => {
    it('renders without crashing', () => {
        const wrapper = shallow(<App/>);
        expect(wrapper).toMatchSnapshot();
    });
});

Shallow takes the component and returns the wrapped instance around it, with which we can interact to write unit tests for our components, it doesn't render the child components compared to mount wrapper, which uses jsdom to simulate the rendering of our components. 

Enzyme

import React from 'react';
import App from './App';

import { shallow } from 'enzyme';

describe('App component', () => {
    it('renders without crashing', () => {
        const wrapper = shallow(<App/>);
        expect(wrapper).toMatchSnapshot();
    });
});

And what does the toMatchSnapshot method, it creates the snapshot of our component, and then see if there's an existing snapshot for our component, it compares them and if there's a difference between them the test fails. You should commit your snapshots, that's why we have used enzyme-to-json serializer to have nicer output for our snapshot.

Enzyme

it ('should check the checkbox', () => {
    let checked = false, el = null;
    const onCheck = jest.fn(() => el.setProps({ checked: !el.prop('checked') }));
    el = shallow(<Checkbox onCheck={onCheck} checked={checked} />);
    expect(el.prop('checked')).toBe(false);
    el.simulate('change');
    expect(onCheck).toHaveBeenCalledTimes(1);
    expect(el.props().checked).toBe(true);
});

Let's move forward and write tests for our simple Checkbox component. The logic of the component is pretty simple it has checked prop and it should be triggered from the click event. Let's see how the test will look like.

So here we're creating fake callback function and passing it to onCheck method, then we're simulating change event on element, which should call our fake handler, and it should trigger the checked prop. So we're checking our callback to be called only once and the checked prop to be true

Enzyme

Now that we have the test for Checkbox component let's write tests for Input component too. Input component has more complex logic than the Checkbox. Firstly it should change its value when we're typing text if it is not disabled, then it has email prop, which indicates if the input should do email validation or not, so we should check if it validates the email properly, for this case it's better to test the email validator separately, but we'll try to test the component changes based on that.

Enzyme

Let's add our first test, to test the typing

it ('should change the input value', () => {
    let val = 'initial value';
    const changedVal = 'value changed';
    let el = null;
    const onChange = jest.fn((e) => el.setProps({ value: e.target.value }));
    el = shallow(<Input onChange={onChange} value={val} />);
    expect(el.props().value).toBe(val);
    el.simulate('change', { target: { value: changedVal } });
    expect(el.props().value).toBe(changedVal);
});

The only problem in this test is that we can not test the disabled state, as it's the native input's attribute and enzyme just simulates the DOM, so here the elements and events are simulated.

Enzyme

But before moving to cypress let's write a small integration test for our App, here we have an Input which disabled state is dependent to Checkbox's checked prop, so when we check a Checkbox our Input becomes disabled.

it('checkbox should disable input', () => {
    const wrapper = mount(<App />);
    const checkboxEl = wrapper.find(Checkbox).find('input');
    const inputEl = wrapper.find('#first-input').find('input');
    expect(inputEl.props().disabled).toBeFalsy();
    expect(checkboxEl.props().checked).toBeFalsy();
    checkboxEl.simulate('change');
    wrapper.setState({ inputDisabled: true });
    expect(wrapper.find('#first-input').find('input').props().disabled).toBe(true);
    expect(wrapper.find(Checkbox).find('input').props().checked).toBe(true);
});

Enzyme

But in general the most use cases for enzyme are unit tests, in which we check how our components are rendered in different states and based on their props, if the callbacks are called or if the props and state are changed properly. But for UI, integration and end to end tests the cypress is more powerful.

Cypress is a test running framework like CasperJS or Chrome's puppeteer, it takes the commands and directly runs them in the real browser comparing the expecting results with the actual ones.

Now let's try to write our tests with cypress.

I've already installed cypress in the project. It comes with bunch of examples, I've deleted them. So let's create a new test spec for our App. Let's make sure that our app runs correctly.

describe('App', () => {
    it ('Should render the app components correctly', () => {
        cy.visit('/');
        cy.contains('React Conf 2019').should('exist');
        cy.get('input[type=checkbox]').should('exist');
        cy.get('#first-input').should('exist');
        cy.get('#email-input').should('exist');
    });
});

huh, pretty easy right? Now let's run our test by typing "./node_modules/.bin/cypress run"

Now let's try to write our last Jest test with cypress, to check if the checkbox properly disables the input.

it ('Should disable the input if checkbox is checked', () => {
    cy.visit('/');
    cy.get('#first-input').type('Cypress is amazing!');
    cy.get('input[type=checkbox]').check();
    cy.get('#first-input').should('be.disabled');
});

that's it!

And now the test to check the email validation, from the UI perspective.

describe('Input', () => {
    it ('Should properly validate email', () => {
        const rightColor = 'rgb(102, 206, 102)';
        const wrongColor = 'rgb(255, 0, 0)';

        cy.visit('/');
        cy.get('#email-input').type('invalid email');
        cy.get('#email-input').should('have.css', 'border-color', wrongColor);
        cy.get('#email-input').clear();
        cy.get('#email-input').type('varik@steadfast.tech');
        cy.get('#email-input').should('have.css', 'border-color', rightColor);
    });
});

Setting up tests for CI/CD

Now we have our tests running in our development environment, but what if we want a continuous integration with github hooks, and we want our test to be run after each commit and in pull requests.

We can also use husky precommit hook to run our test before making any commits.

Setting up tests for CI/CD

Let's say we have our CI/CD setup with Jenkins, and it will run our "npm run test:ci" script, now let's see what we should write there.

{
    "test:ci": "CI=true node scripts/test.js && concurrently  'yarn cypress:run' -k -s first",
    "cypress:run": "BROWSER=none start-server-and-test start http://localhost:3000 'cypress:ci'",
    "cypress:ci": "cypress run"
}

Thank You!

Made with Slides.com