Testing React Applications

Objectives

  1. Solidify understanding of core testing concepts
  2. Learn how to test React components utilizing Jest + Enzyme
  3. Learn how to test with React Router

Review of Core Testing Concepts

Testing Pyramid

Unit

Integration

E2E

Unit Tests

  • Smallest module of code (a single function, a single component)
  • Ideal for pure functions (predictable, deterministic input-output)
  • Advantages:
    • find problems early
    • makes integration tests easier
    • documents your code!
  • Disadvantages:
    • you can't test everything (decision problem, halting problem)
    • combinatorial problem (must test true and false for each input)
    • sustainability (it requires a lot of discipline to keep updated)

Integration Tests

  • Test a group of modules (units) that work together
  • Advantages:
    • Tests begin to reflect user experience
    • Basic assessment of the validity of the system as a whole
  • Disadvantages:
    • Expensive to create and maintain (one change to a unit requires a re-calibration of all involved integration tests)
    • These tests can mutate their environment and can require extra measures to run and clean up after themselves
    • Fragile

End to End Tests

  • Also known as requirements testing, acceptance testing, etc.
  • Advantages:
    • Test the full user experience
    • Objective validation of requirements
    • Discover the most bugs / problem areas
  • Disadvantages:
    • Extremely expensive to create and maintain
    • Extremely fragile
    • Can be difficult to automate

Smoke Test

A smoke test is a type of basic check, for example "does the program run?" test.

It's an industry standard to run smoke tests before the full test suite.

Testing React with Jest

  • Jest ships with CRA (Create React App)

  • In any CRA project, just run "npm test" in the directory and jest will work

  • Jest looks for tests in two places:

    1. Any file with .test or .spec in the name (e.g. App.test.js or App.spec.js)

    2. Any file in src/__tests__ folder

Setting up Enzyme

  • Enzyme gives us the ability to "virtually render" components at whatever level we want

  • It includes jQuery-like selectors (more on that later)

  • Here's how you install it:

npm install --save-dev enzyme enzyme-adapter-react-16 \
                       react-test-renderer enzyme-to-json
  • Here's how you configure it:

import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

configure({ adapter: new Adapter() });

new file => src/setupTests.js

Meme Generator Tests

Let's add tests to a React Meme Generator!

https://github.com/rithmschool/react-meme-generator

First Test

import React from 'react'; // for JSX
import { shallow } from 'enzyme'; // how to mount the component
import Meme from '.'; // import the component itself

describe('<Meme />', () => {
  let wrapper;
  it('renders', () => {
    // smoke test!!!
    wrapper = shallow(
      <Meme
        url="https://i.ytimg.com/vi/I7jgu-8scIA/maxresdefault.jpg"
        topText="I can haz"
        bottomText="react tests"
      />
    );
  });
});

Let's test the easiest component, "Meme.js"

npm test

terminal ->

src/Meme/index.test.js

What does shallow do?

shallow(<Component />) for Shallow rendering is useful to constrain yourself to testing a component as a unit, and to ensure that your tests aren't indirectly asserting on behavior of child components.

In other words, shallow just renders the component itself and no nested components or lifecycle methods. Also, it doesn't even put it on the virtual DOM.

shallow Example: App

import React from 'react'; // for JSX
import { shallow } from 'enzyme'; // how to mount the component
import App from '.';

describe('<App />', () => {
  it('renders', () => {
    let wrapper = shallow(<App />);
    console.log(wrapper.debug());
  });
});
<div className="App">
  <NewMemeForm addMeme={[Function]} />
  <hr />
</div>
class App extends Component {
  render() {
    return (
      <div className="App">
        <NewMemeForm addMeme={this.addMeme} />
        <hr />
        {this.state.memes.map(m => (
          <Meme key={m.id} {...m} deleteMeme={this.deleteMeme} />
        ))}
      </div>
    );
  }
}
mount

mount(<Component />) for Full DOM rendering is ideal for use cases where you have components that may interact with DOM apis, or may require the full lifecycle in order to fully test the component (ie, componentDidMount etc.)

mount is much more expensive than shallow, but you need to do it any time you want to test lifecycle methods or components with routers, etc.

import React from 'react'; // for JSX
import { mount } from 'enzyme'; // how to mount the component
import App from '.';

describe('<App />', () => {
  it('renders', () => {
    let wrapper = mount(<App />);
    console.log(wrapper.debug());
  });
});
<App>
  <div className="App">
    <NewMemeForm addMeme={[Function]}>
      <div>
        <h2>
          Make a new Meme
        </h2>
        <form onSubmit={[Function]}>
          <label htmlFor="url">
            Image URL
          </label>
          <input type="text" name="url" id="form_url" onChange={[Function]} value="" />
          <label htmlFor="topText">
            Top Text
          </label>
          <input type="text" name="topText" id="form_topText" onChange={[Function]} value="" />
          <label htmlFor="bottomText">
            Bottom Text
          </label>
          <input type="text" name="bottomText" id="form_bottomText" onChange={[Function]} value="" />
          <button type="submit" id="form_submit">
            Generate Meme!
          </button>
        </form>
      </div>
    </NewMemeForm>
    <hr />
  </div>
</App>

instead of shallow

Snapshot Testing

A snapshot test is a super easy, quick test to add that is a great improvement over a simple smoke test.

Jest compares the current snapshot with the saved snapshot, and throws a test error if they are different.

Basically, you save the configuration of a component in a snapshot, a file that gets placed in a  __snapshots__ folder.

Serializing Components

To make our snapshots super easy to read, we'll first use a package to convert our rendered component into JSON form.

import React from 'react'; // for JSX
import { shallow } from 'enzyme'; // how to mount the component
import toJson from 'enzyme-to-json';
import Meme from '.'; // import the component itself

describe('<Meme />', () => {
  let wrapper;
  it('renders', () => {
    // smoke test!!!
    wrapper = shallow(
      <Meme
        url="https://i.ytimg.com/vi/I7jgu-8scIA/maxresdefault.jpg"
        topText="I can haz"
        bottomText="react tests"
      />
    );
  });

  it('matches snapshot', () => {
    const serialized = toJson(wrapper);
  });
});

Snapshot Assertion

import React from 'react'; // for JSX
import { shallow } from 'enzyme'; // how to mount the component
import toJson from 'enzyme-to-json';
import Meme from '.'; // import the component itself

describe('<Meme />', () => {
  let wrapper;
  it('renders', () => {
    wrapper = shallow(
      <Meme
        url="https://i.ytimg.com/vi/I7jgu-8scIA/maxresdefault.jpg"
        topText="I can haz"
        bottomText="react tests"
      />
    );
  });
  it('matches snapshot', () => {
    const serialized = toJson(wrapper);
    expect(serialized).toMatchSnapshot();
  });
});

Finally, Jest has a specific assertion to take a snapshot:

Run Tests

npm test

terminal ->

Updating a Component Changes the Snapshot

import React from 'react';
import './style.css';

function Meme(props) {
  return (
    <div id="memez">
      <button onClick={() => props.deleteMeme(props.id)}>DELETE</button>
      <span>{props.topText}</span>
      <img src={props.url} alt="a meme" />
      <span>{props.bottomText}</span>
    </div>
  );
}

export default Meme;
src/Meme/index.js

Let's add an ID to the Meme div

Changing Components Fails Snapshot Tests

npm test

terminal ->

If you intended the change, you can press "u" to tell Jest to update the snapshot

npm test

terminal ->

u

Snapshots are a basic protection against unintended component changes

Takeaway:

When you need to test something business-critical, sometimes snapshots are not enough.

Testing Props with Enzyme

Let's do a basic example with the Meme component to use the mounted wrapper's .props() method to get at an actual value:

import React from 'react'; // for JSX
import { mount } from 'enzyme'; // how to mount the component
import toJson from 'enzyme-to-json'; // for snapshotting the component
import Meme from '.'; // import the component itself

describe('<Meme />', () => {
  it('has correct topText prop', () => {
    let wrapper = mount(
      <Meme
        url="https://i.ytimg.com/vi/I7jgu-8scIA/maxresdefault.jpg"
        topText="I can haz"
        bottomText="react tests"
      />
    );
    expect(wrapper.props().topText).toBe('I can haz');
  });
});

Even fancier are enzyme selectors, which act similar to jQuery.

(Advanced) Enzyme Selectors

describe('<Meme />', () => {
  it('applies url prop to image src', () => {
    let wrapper = mount(
      <Meme
        url="https://i.ytimg.com/vi/I7jgu-8scIA/maxresdefault.jpg"
        topText="I can haz"
        bottomText="react tests"
      />
    );
    let img = wrapper.find('img'); // select the img tag

    // assert that we are passing src to the img
    expect(img.prop('src')).toBe(
      'https://i.ytimg.com/vi/I7jgu-8scIA/maxresdefault.jpg'
    );
  });
});

When you need to assert state changes, you can simulate events. Let's test the NewMemeForm component!

(Advanced) Simulated Events

describe('<NewMemeForm />', () => {
  it('updates state with topText input', () => {
    // mount form and mock addMeme
    let wrapper = mount(<NewMemeForm addMeme={jest.fn()} />);

    // assert empty string default
    expect(wrapper.state().topText).toBe('');

    // select form input
    let form_topText = wrapper.find('#form_topText');

    // put 'xyz' as the value into the form input
    form_topText.instance().value = 'xyz';

    // send a "change" event
    form_topText.simulate('change');

    // assert that state has updated
    expect(wrapper.state().topText).toBe('xyz');
  });
});

Takeaways

  1. shallow mounting is great for unit tests, to test components in isolation of their children / nested components
  2. full mounting is sometimes necessary to get access to state, props, lifecycle methods, or to do an integration test (i.e. include child components in the test)
  3. Snapshot tests are considered sufficient as the bare minimum amount of testing for a lot of companies
  4. Use selector methods for components that are more important and require extra testing precision

Testing Components with React Router

Components with anything from react-router are harder to test than pure components.

This is because if you mount a component with a reference to React router (a <Route />, or <Link /> for instance), it requires the context of the original router in the test.

Vending Machine Tests

Let's add tests to Matt's awesome vending machine app:

https://github.com/mmmaaatttttt/vending-machine-demo

Sardines

Let's try to test <Sardines />, which has a <Link /> in it.

import React from 'react';
import { Link } from 'react-router-dom';
import Message from './Message';
import './Sardines.css';

const Sardines = props => (
  <div
    className="Sardines"
    style={{
      backgroundImage:
        'url(https://media.giphy.com/media/tVk4w6EZ7eGNq/giphy.gif)'
    }}
  >
    <Message>
      <h1>you don't eat the sardines. the sardines, they eat you!</h1>
      <h1>
        <Link to="/">go back</Link>
      </h1>
    </Message>
  </div>
);

export default Sardines;

Sardines Test - shallow

Let's try to test <Sardines />, which has a <Link /> in it.

import React from 'react'; // for JSX
import { shallow } from 'enzyme'; // how to mount the component
import toJson from 'enzyme-to-json'; // for snapshotting the component
import Sardines from '../Sardines'; // import the component itself

describe('<Sardines />', () => {
  let wrapper;
  it('renders', () => {
    wrapper = shallow(<Sardines />);
  });

  it('matches snapshot', () => {
    expect(toJson(wrapper)).toMatchSnapshot();
  });
});

Shallow mounting worked!

Sardines Test - full mount

import React from 'react'; // for JSX
import { mount } from 'enzyme'; // how to mount the component
import toJson from 'enzyme-to-json'; // for snapshotting the component
import Sardines from '../Sardines'; // import the component itself

describe('<Sardines />', () => {
  let wrapper;
  it('renders', () => {
    wrapper = mount(<Sardines />);
  });

  it('matches snapshot', () => {
    expect(toJson(wrapper)).toMatchSnapshot();
  });
});

Sardines Test - full mount

Uh oh...

MemoryRouter

A MemoryRouter is <Router> that keeps the history of your “URL” in memory (does not read or write to the address bar). Useful in tests and non-browser environments like React Native.

Unfortunately, if we want to fully mount Sardines, we need to wrap it in a MemoryRouter.

Using MemoryRouter

import React from 'react'; // for JSX
import { mount } from 'enzyme'; // how to mount the component
import { MemoryRouter } from 'react-router-dom';
import toJson from 'enzyme-to-json'; // for snapshotting the component
import Sardines from '../Sardines'; // import the component itself

describe('<Sardines />', () => {
  let wrapper;
  it('renders', () => {
    wrapper = mount(
      <MemoryRouter>
        <Sardines />
      </MemoryRouter>
    );
  });

  it('matches snapshot', () => {
    expect(toJson(wrapper)).toMatchSnapshot();
  });
});

Update snapshot and tests pass!!!

MemoryRouter Gotcha

Unfortunately, if you run the tests again, you get a snapshot fail.

MemoryRouter Fixed

describe('<Sardines />', () => {
  let wrapper;
  it('renders', () => {
    wrapper = mount(
      <MemoryRouter initialEntries={[{ key: 'testKey' }]}>
        <Sardines />
      </MemoryRouter>
    );
  });

  it('matches snapshot', () => {
    expect(toJson(wrapper)).toMatchSnapshot();
  });
});

Prevent the "key" from being randomly re-generated each time.

Takeaways

  1. Try to shallow mount to test components if possible
  2. If you need to write an integration test, you can use mount
  3. But if you use mount, be careful when the component contains React Router stuff. You need to use a MemoryRouter and configure it properly so the snapshots don't change each time.

Additional Resources

Made with Slides.com