Tested React: Build and Test Modal using React features and DOM events

Gasim Gasimzada
Frontend Weekly
Published in
10 min readDec 14, 2018

Previous Issue: https://medium.com/front-end-hacking/tested-react-lets-build-a-data-table-a76aa100d23f

Photo by Alexej Алексей Simonenko Симоненко on Unsplash

“Tested React” series of guides to get people accustomed to testing components in React ecosystem. This series IS NOT about setting up testing environments for React — The goal is to help you build intuition on what to test in your React apps.

In this issue, we are going to build a Modal (or Dialog) component to get familiar with testing components that rely on Portals, Refs, and DOM events (Modal requires all these features!).

Specification: Modal

There are specific things that a Modal component must do:

  1. Render the modal in document body (using Portals)
  2. Render an Overlay over the whole page and create a content box for its children.
  3. Trigger (this is important) modal close if user presses ESC key or if user clicks the overlay.

The modal component must not be responsible for closing itself because the logic to close the modal can differ in implementation (via state, props, redux etc) and the way a modal is triggered can also differ (via button click, React Router, DOM events etc). Separating this concern from Modal’s responsibility will significantly simplify the component; thus, simplify the tests.

However, the modal component stores all the events necessary to close itself; so, there needs to be a way to trigger close outside the component. The easiest way to do this is to pass a close function as a prop and call the function to close itself.

Implemention: Basic Rendering

Let’s start with tests. We know for a fact that the modal there are two DOM elements required — one for overlay and another one for the actual modal window. So, let’s test our elements and children:

// src/modal/Modal.test.jsimport React from 'react';
import { shallow } from 'enzyme';
import Modal from './Modal';it('renders Modal component given the props', () => {
// what is this?
const closeFn = jest.fn();
const container = shallow(
<Modal closeFn={closeFn}>
Hello world
</Modal>
);
const overlay = container.find('.modal-overlay');
expect(overlay).toHaveLength(1);
const modal = overlay.find('.modal');
expect(overlay).toHaveLength(1);
expect(modal.text()).toEqual('Hello World');
});

You might ask the question: Why did we not test whether there is a Portal? The reality is that we do not care whether the component is rendered into the root div or somewhere in the body. Portal is a React feature and we can assume that it is working; so, there is no need to test it.

Now that the test is ready, it is time to write the component:

// src/modal/Modal.jsimport React from 'react';
import ReactDOM from 'react-dom';
export default class Modal extends React.Component {
render() {
return (
<div className="modal-overlay">
<div className="modal-container">
<div className="modal">{this.props.children}</div>
</div>
</div>
);
}
}

Hey, wait a second. Why did we not test whether modal-container exists? In order to explain this, we need to look at the styles of modal:

.modal-overlay {
position: fixed;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.25);
}
.modal {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
background: white;
}

Because the overlay’s position is fixed, the modal’s positioning will not be relative to the overlay — it will be based on body. So, in order to make the position relative to parent, a “container” div is needed:

.modal-container {
position: relative;
width: 100%;
height: 100%;
}

In terms of styles, this makes sense but here is the catch. What if sometime, you decided to center the modal using flexbox (or any future CSS spec); so, there was no need for modal container anymore. If you test the existence of modal container, your tests will fail. Specifically, your tests will fail because of something that does not contribute to the logical structure of the modal component. The component was there to ease the styling, nothing more. This is an implementation detail. Tests with implementation details are prone to test fails during refactoring (even the smallest ones).

It is usually recommended to avoid implementation details during tests. However, do not refrain from asserting because it might be an implementation detail. It might not always be clear whether we are testing an implementation detail and it is fine. Because sooner or later, it will come out and you will fix it and move on.

Implementation: Keyboard Events

First part of the spec is complete, the Modal is rendered into DOM using portals and the contents are printed. Now, onto testing the keyboard event.

To test for events, we are going to avoid using Enzyme as its drawbacks for simulating events are not worth the trouble. Easiest way to simulate DOM events is to use the actual DOM to perform the events. For that, we need will mount our component into DOM using ReactDOM:

// src/modal/Modal.test.jsimport React from 'react';
import ReactDOM from 'react-dom';
import { shallow } from 'enzyme';
import Modal from './Modal';it('renders Modal component given the props', ...);it('closes the Modal when ESC key is pressed', () => {
// we are using it again =D
const closeFn = jest.fn();
const root = document.createElement('div');
ReactDOM.render(
<Modal closeFn={closeFn}>
Hello World
</Modal>,
root
);
const evt = new KeyboardEvent('keydown', { keyCode: 27 });
// 27 == Escape Key
document.dispatchEvent(evt);
expect(closeFn).toHaveBeenCalledTimes(1);
});

Check out the Resources section of this post to read about Keyboard Events and dispatchEvent.

Because RenderDOM fully renders the component into the DOM, the component will behave as if it is being run on the browser. So, it is certain that componentDidMount or other component lifecycle functions will be executed.

jest.fn() creates a function that stores necessary information about a function (e.g how many times called, what arguments passed in each call, what is being returned from each call etc). This is called a “Mock Function” in testing. Using the collected data from the function, it is very easy to assert function calls. In the test above, jest.fn() in order to know whether close function that is passed to Modal is being called when ESC is clicked.

Now that the test is being written, we can get into writing the component:

// src/modal/Modal.jsimport React from 'react';
import ReactDOM from 'react-dom';
export default class Modal extends React.Component { escapeClicked = e => {
if (e.keyCode === 27) {
this.props.closeFn();
}
}
componentDidMount() {
document.addEventListener('keydown', this.escapeClicked, false);
}
componentWillUnmount() {
document.removeEventListener('keydown', this.escapeClicked, false);
}
render() {
return (
<div className="modal-overlay">
<div className="modal-container">
<div className="modal">{this.props.children}</div>
</div>
</div>
);
}
}

Implemention: Close Modal on mouse clicking the document

For simplicity, there also needs to close a modal if a user clicks outside the modal. It is better to attach the event to document and dispatch click events to document itself. For the purposes of modal, the event also needs to be bubbled. In short terms, event bubbling allows an event to be sent from a child and captured/listened from ancestors (we will use it in a bit).

// src/modal/Modal.test.js// other testsit('closes modal if document is clicked', () => {
const closeFn = jest.fn();
const root = document.createElement('div');
ReactDOM.render(
<Modal closeFn={closeFn}>
Hello World
</Modal>,
root
);
const evt = new MouseEvent('click', { bubbles: true });
document.dispatchEvent(evt);
expect(closeFn).toHaveBeenCalledTimes(1);
});

Implementing code based on the test will look like this:

// src/modal/Modal.jsimport React from 'react';
import ReactDOM from 'react-dom';
export default class Modal extends React.Component { escapeClicked = e => {
if (e.keyCode === 27) {
this.props.closeFn();
}
}
documentClicked = e => {
this.props.closeFn();
}
componentDidMount() {
document.addEventListener('keydown', this.escapeClicked, false);
document.addEventListener('click', this.documentClicked, false);
}
componentWillUnmount() {
document.removeEventListener('keydown', this.escapeClicked, false);
document.removeEventListener('click', this.documentClicked, false);
}
render() {
return (
<div className="modal-overlay">
<div className="modal-container">
<div className="modal">{this.props.children}</div>
</div>
</div>
);
}
}

This closes the modal but there is one caveat. If a user clicks the modal contents, (e.g a form input inside modal), the modal will still close. Since this is part of the logic to close the modal, we are going to write a test for it:

it('does not close the modal if modal is clicked', () => {
const closeFn = jest.fn();
const root = document.createElement('div');
ReactDOM.render(
<Modal closeFn={closeFn}>
Hello World
</Modal>,
root
);
const modal = document.body.querySelector('.modal'); const evt = new MouseEvent('click', { bubbles: true });
modal.dispatchEvent(evt);
// close function should not be called, hence the ZERO
expect(closeFn).toHaveBeenCalledTimes(0);
});

Bubbling is necessary here because the event is being sent from modal while the document is the one listening to click events. I added it to both test cases for sake of clarity in this post and you can remove it from the other test as it is useless.

As you can see, we are looking for the modal from document component. Because we are using portals in Modal component, even if the component is being rendered to “root” element, the actual DOM of the Modal will be “body” because of Portal.

How can we actually know which element is being clicked and if the element is “modal” element in our component? In order to compare real DOM nodes, we will be using refs to access the real DOM of React element:

import React from 'react';
import ReactDOM from 'react-dom';
export default class Modal extends React.Component {
// other code...
modalRef = React.createRef(); documentClicked = e => {
if (e.target === this.modalRef.current || this.modalRef.current.contains(e.target)) return;
this.props.closeFn();
}
render() {
return (
<div className="modal-overlay">
<div className="modal-container">
<div className="modal" ref={this.modalRef}>{this.props.children}</div>
</div>
</div>
);
}
}

By checking whether the event target is the modal or an element inside the modal, allows us to know that the modal is not clicked. In a real browser, the target property of the event will be set automatically based on where the mouse is clicked. When writing the tests, the target must be set manually. That’s why we are dispatching events from modal element instead of document. It is essentially a way to simulate the real world behavior without relying on cursor position etc.

Also, it is important to mention that, we did not test existence of refs. Firstly, because refs are React features and we should not test for React features when writing tests. Secondly, the main logic here is that we want to test whether the modal closes if the modal itself is clicked. The way it is implemented does not matter to the test. A good example would be regarding React’s old Ref API. If we tested our code based on Refs, our tests would have failed because we have updated our Refs to the new API. This is also considered an implementation detail.

In terms of logic and in terms of test this makes sense. However, the last test will fail even if the specification is sound and application works properly. This is because when Jest runs its test, each consecutive test will store all the elements until a test suite is finished. As a result, created elements in the document, will be preserved until the end of the test suite. So, It is a good practice to clean up possible leaks from tests (e.g event listeners). We can easily unmount components at the end of each test but there is a more readable way to do this.

Using describe to group tests together

Describe is a function that can group tests together with and describe it when running tests. Let’s group our tests for this:

describe('close modals on certain actions', () => {    it('when ESC key is pressed', () => {
const closeFn = jest.fn();
const element = document.createElement('div');
ReactDOM.render(
<Modal closeFn={closeFn}>
Hello World
</Modal>,
element
);
// rest of the test...
});
it('when mouse is clicked outside the modal', () => {
const closeFn = jest.fn();
const element = document.createElement('div');
ReactDOM.render(
<Modal closeFn={closeFn}>
Hello World
</Modal>,
element
);
// rest of the test...
});
it('when mouse is clicked outside the modal', () => {
const closeFn = jest.fn();
const element = document.createElement('div');
ReactDOM.render(
<Modal closeFn={closeFn}>
Hello World
</Modal>,
element
);
// rest of the test...
});
});

Because the tests are grouped under one describe, we can use Jest’s global functions to perform some tests for that group. There are many global functions for Jest but the useful one for us is beforeEach and afterEach, which are functions performed before and after each test, respectively. Now, onto refactoring and cleaning up dangling references:

describe('close modal', () => {
const element = document.createElement('div');
const closeFn = jest.fn();
beforeEach(() => {
ReactDOM.render(
<Modal closeFn={closeFn}>Hello World</Modal>
, element);
});
afterEach(() => {
ReactDOM.unmountComponentAtNode(element);
closeFn.mockReset();
});
it('when ESC key is pressed', () => {
var evt = new KeyboardEvent('keydown', { keyCode: 27 });
document.dispatchEvent(evt);
expect(closeModal).toHaveBeenCalledTimes(1);
});
it('closes modal if document is clicked', () => {
const evt = new MouseEvent('click', { bubbles: true });
document.dispatchEvent(evt);
expect(closeFn).toHaveBeenCalledTimes(1);
});
it('does not close if modal is clicked', () => {
const evt = new MouseEvent('click', { bubbles: true });
const modal = document.body.querySelector('.modal');
modal.dispatchEvent(evt);
expect(closeFn).toHaveBeenCalledTimes(0);
});
});

Now, when describe is called, immediately the DOM node and a mock function is created. Before each test is performed, the Modal component is rendered into the DOM. After each test is performed, the React component in the DOM node is unmounted; so, all the components must be unmounted (and componentWillUnmount must be called). The mock function is also reset (mockReset function) after each test to ensure that it is empty for each test. We could have also stored element creation and mock function creation in other global functions (e.g beforeAll and afterAll) but let’s keep it simple for this post 😃. There is one more thing that we need to do to prevent leaks (it is not detrimental to the test but it is a good practice). We need to also remove unmount the enzyme component after the test:

it('renders Modal component given the props', () => {
const closeFn = jest.fn();
const container = shallow(
<Modal closeFn={closeFn}>
Hello world
</Modal>
);
const overlay = container.find('.modal-overlay');
expect(overlay).toHaveLength(1);
const modal = overlay.find('.modal');
expect(overlay).toHaveLength(1);
expect(modal.text()).toEqual('Hello World');
modal.unmount();
});

This will ensure that mouse and keyboard event listeners are all cleared.

That is it! 😊

Conclusion

The goal of this post was to provide intuition on how to perform tests in the landscape of React features (Portals, Refs), lifecycle, and DOM events.

Resources

--

--