Tested React: Build and Test a Form using React Context
Previous Issue: https://medium.com/front-end-weekly/tested-react-build-and-test-modal-using-react-features-and-dom-events-39b7246a3a6f
“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 Form that stores all input data in context; so that we can get the data during submission. The goal of this post is to test a component that works with Context.
Specification: Form
To keep this post simple, our Form consists of the following items:
- Form Context
- Form Component that holds Context Provider
- TextInput Component that writes to context and reads from context
- Submit Button component (gets disabled during submission)
Before we move to implementation, some things need to be clarified:
Why are we using Context and other components?
The structure of a Form can different from page to page. For example, some form items can be separated by sections (e.g Basic Information, Credentials) or inputs can be aligned horizontally in a Row. As a result, form inputs can be nested inside other tags or even components. On the other hand, it is a good practice to access the Form state in React (not DOM) during submission. That’s why Context works perfectly for this scenario because we can “connect” inputs to the Form context by wrapping each input element in a component and accessing the context for getting input values, errors etc.
Why the Submit Button?
Even though it is not necessary for Submit Button to be connected to the context, due to the fact that the context has all the information about the entire process of the Form (including submission), connecting the button to the form allows for displaying state of the Form during submission (e.g disable the button and show a loading indicator when form is being submitted)
Test: Form with Text Input
Because Context is being used for the Form, there is no point to test the input without Form component. So, let’s start with a simple test and bootstrap our code:
// src/Form/Form.test.jsimport React from 'react';
import { mount } from 'enzyme';import Form from './Form';it('calls onSubmit prop function when form is submitted', () => {
const onSubmitFn = jest.fn();
const wrapper = mount(<Form onSubmit={onSubmitFn}/>); const form = wrapper.find('form');
form.simulate('submit');
expect(onSubmitFn).toHaveBeenCalledTimes(1);
});
We can create our component and context and test to see if the form is submitted:
// src/Form/FormContext.jsimport { createContext } from 'react';export default createContext({
getInputValue: (name, defaultValue = '') => null,
inputChange: name => e => {}
});// We are going to talk about those values in a bit
By default the context is empty with a function that does nothing when input change event is being triggered. Now, onto creating the Form component with state that does nothing at the moment:
// src/Form/Form.jsimport React from 'react';import FormContext from './FormContext';export default class Form extends React.Component {
state = { data: {} }; onSubmit = e => {
e.preventDefault();
this.props.onSubmit();
} render() {
return (
<FormContext.Provider value={null}>
<form method="post" onSubmit={this.onSubmit}>
{this.props.children}
</form>
</FormContext.Provider>
);
}
}
A barebone Form component is ready.
Let’s create TextInput and SubmitButton components that are connected to Context. As usual, let’s start with tests:
// src/Form/TextInput.test.jsimport React from 'react';
import { mount } from 'enzyme';import TextInput from './TextInput';it('renders text input with label (default type)', () => {
const wrapper = mount(<TextInput name="first_name" label="First Name" />);
const label = wrapper.find('label');
expect(label).toHaveLength(1);
expect(label.prop('htmlFor')).toEqual('first_name');
expect(label.text()).toEqual('First Name'); const input = wrapper.find('input');
expect(input).toHaveLength(1);
expect(input.prop('type')).toEqual('text');
expect(input.prop('name')).toEqual('first_name');
expect(input.prop('id')).toEqual('first_name');
});it('renders email input with label given the type', () => {
const wrapper = mount(<TextInput type="email" name="email" label="Email" />);
const label = wrapper.find('label');
expect(label).toHaveLength(1);
expect(label.prop('htmlFor')).toEqual('email');
expect(label.text()).toEqual('Email'); const input = wrapper.find('input');
expect(input).toHaveLength(1);
expect(input.prop('type')).toEqual('email');
expect(input.prop('name')).toEqual('email');
expect(input.prop('id')).toEqual('email');
});
Implementation:
// src/Form/TextInput.jsimport React from 'react';import FormContext from './FormContext';export default class TextInput extends React.Component {
static contextType = FormContext; render() {
const { name, label, type } = this.props; return (
<div className="input-row">
<label htmlFor={name}>{label}</label>
<input
type={type}
name={name}
id={name}
/>
</div>
);
}
}TextInput.defaultProps = {
type: 'text'
};
Notice that default prop for type is being tested even though it is a React feature. This is because, testing for default prop makes it clear for the tester what the default prop is without needing to dive into code to see it. This is a detail that is completely dependent on the developer to include or not. I personally, like including it as it clarifies the code using tests better.
Now, let’s test and implement Submit Button:
// src/Form/SubmitButton.test.jsimport React from 'react';
import { mount } from 'enzyme';import SubmitButton from './SubmitButton';it('renders submit button with custom text', () => {
const wrapper = mount(<SubmitButton>Click here</SubmitButton>);
const button = wrapper.find('button');
expect(button).toHaveLength(1);
expect(button.prop('type')).toEqual('submit');
expect(button.text()).toEqual('Click here');
});
Implementation:
// src/Form/SubmitButton.js
import React from 'react';import FormContext from './FormContext';export default class SubmitButton extends React.Component {
static contextType = FormContext; render() {
return <button type="submit">{this.props.children}</button>;
}
}
Features that can be specified in isolation have been tested and implemented individually. However, Form and Input components must be integrated to each other in order to function properly. Easiest way to test the integration is to test them together. Let’s start with text input first. As mentioned in previous issue, working with events in enzyme that does anything complex (for simply checking a function call is fine) is very convoluted. As a result, we are going to use real DOM and React DOM TestUtils to simulate the change event:
// src/Form/TextInput.test.jsimport React from 'react';
import ReactDOM from 'react-dom';
import TestUtils from 'react-dom/test-utils';
import { mount } from 'enzyme';import TextInput from './TextInput';
import Form from './Form.js';// Previous tests...it('reads and sets input value when using context to store the data', () => {
const wrapper = document.createElement('div'); ReactDOM.render(
<Form>
<TextInput name="first_name" label="First Name" />;
</Form>,
wrapper
); const input = wrapper.querySelector('input'); TestUtils.Simulate.change(input, { target: { value: 'Peter Parker' } }); expect(input.value).toEqual('Peter Parker');
});
We are testing a change event with the event object parameters similar to what a browser typically sends. Then, we test whether the changed value becomes the new value. Now, onto the implementation:
// src/Form/Form.js// ...imports hereexport default class Form extends React.Component {
state = { data: {} }; onSubmit = e => {
e.preventDefault();
this.props.onSubmit();
} getInputValue = (name, defaultValue = '') => {
return this.state.data[name] || defaultValue;
} inputChange = name => e => {
const targetValue = e.target.value; this.setState(prevState => ({
data: {
...prevState.data,
[name]: targetValue
}
});
} render() {
return (
<FormContext.Provider value={{ getInputValue: this.getInputValue, inputChange: this.inputChange }}>
<form method="post" onSubmit={this.onSubmit}>
{this.props.children}
</form>
</FormContext.Provider>
);
}
}
- getInputValue function returns the value in data if it exists, otherwise it returns the provided default value
- inputChange function sets the state based on the name provided in outer function and the event target (e.g input element) value provided in the inner function. The use of two functions makes it easier to use the function right inside onChange event, alleviating unnecessary onChange handlers that call this function in separation.
The two functions are then used inside TextInput (or any other input that is connected to the Context):
// src/Form/TextInput.js// ...importsexport default class TextInput extends React.Component {
static contextType = FormContext;render() {
const { name, label, type } = this.props;return (
<div className="input-row">
<label htmlFor={name}>{label}</label>
<input
type={type}
name={name}
id={name}
onChange={this.context.inputChange(name)}
value={this.context.getInputValue(name)}
/>
</div>
);
}
}// ...other code
As you can see, inputChange
returns a callback/listener for onChange event and that function performs the needed state update. The reason this is possible is because Javascript functions can access the scope of the “parent” function (this is an answer to a popular interview question for Frontend/Javascript 😃).
Now the TextInput is fully connected to the Form Context, getting and setting values for their respective forms based on name. There are couple more things that need to be implemented. Firstly, the submit function must receive the entire form state; so that something can be performed using the state (e.g an API call):
// src/Form/Form.test.jsimport React from 'react';
import ReactDOM from 'react-dom';
import TestUtils from 'react-dom/test-utils';import { mount } from 'enzyme'; // REMOVE THIS LINEimport Form from './Form';
import TextInput from './TextInput';// REMOVE THIS TEST AS THIS IS UNNECESSARY
it('calls onSubmit prop function when form is submitted', () => {
const onSubmitFn = jest.fn();
const wrapper = mount(<Form onSubmit={onSubmitFn}/>); const form = wrapper.find('form');
form.simulate('submit');
expect(onSubmitFn).toHaveBeenCalledTimes(1);
});
// REMOVE THIS TEST AS THIS IS UNNECESSARYit('gets the form state from onSubmit function', () => {
const wrapper = document.createElement('div'); // just return the data
const onSubmitFn = jest.fn(data => data); ReactDOM.render(
<Form onSubmit={onSubmitFn}>
<TextInput name="first_name" label="First Name" />;
</Form>,
wrapper
); const input = wrapper.querySelector('input');
const form = wrapper.querySelector('form'); TestUtils.Simulate.change(input, { target: { value: 'Peter Parker' } }); TestUtils.Simulate.submit(form); expect(onSubmitFn).toHaveBeenCalledTimes(1);
expect(onSubmitFn.mock.results[0].value).toEqual({ 'first_name': 'Peter Parker' });
});
Jest Mock functions allow function implementations; so that, we can access the results of a function call and compare the values. Because of this new test, there is no point in having the previous test. So, you can remove the test. Tests can change. They can be replaced with a test that is more meaningful or it can be completely deleted and that is fine. It is part of of the process.
One more thing is left to implement. When form is in submission state, submit button must be disabled and once the submission is done, the button will be enabled again. This requires using async inside tests, which may sound a bit illogical but because of async/await, it is possible to wait for an operation to finish before calling the test.
Firstly, because simulating events and testing for context does not work in Enzyme, we are going to get back to using ReactDOM and TestUtils. Secondly, the submit function and the test case must be async functions:
// src/Form/SubmitButton.test.jsimport React from 'react';
import { mount } from 'enzyme';import TestUtils from 'react-dom/test-utils';
import ReactDOM from 'react-dom';import SubmitButton from './SubmitButton';
import Form from './Form';it('renders submit button with custom text', () => {
const wrapper = mount(<SubmitButton>Click here</SubmitButton>);
const button = wrapper.find('button');
expect(button).toHaveLength(1);
expect(button.prop('type')).toEqual('submit');
expect(button.text()).toEqual('Click here');
});// New test case
it('disables the submit button during submission and enables it when done', async () => {
const onSubmitFn = jest.fn(async () => { await new Promise(resolve => resolve()); }); const wrapper = document.createElement('div');
ReactDOM.render(
<Form onSubmit={onSubmitFn}>
<SubmitButton>Click here</SubmitButton>
</Form>,
wrapper
); const button = wrapper.querySelector('button');
const form = wrapper.querySelector('form');
TestUtils.Simulate.submit(form); expect(button.disabled).toBeTruthy(); // This line makes sure the submit function is finished before executing next instructions
await onSubmitFn.mock.results[0].value; expect(button.disabled).toBeFalsy();
});
Now we can implement submitting state in Form and SubmitButton. First, the submitting state should be available in Context:
// src/Form/FormContext.jsimport { createContext } from 'react';export default createContext({
getInputValue: (name, defaultValue = '') => null,
inputChange: name => e => {},
isSubmitting: false
});
Submit Button must be disable if isSubmitting
is true:
// src/Form/SubmitButton.js
import React from 'react';import FormContext from './FormContext';export default class SubmitButton extends React.Component {
static contextType = FormContext; render() {
return <button type="submit" disabled={this.context.isSubmitting}>{this.props.children}</button>;
}
}
Form context will store submitting as state and update it before and after onSubmit property is being called:
// src/Form/Form.jsexport default class Form extends React.Component {
state = { data: {}, isSubmitting: false }; onSubmit = async e => {
e.preventDefault();
this.setState({ isSubmitting: true });
await this.props.onSubmit();
this.setState({ isSubmitting: false });
} // ...rest of the code render() {
return (
<FormContext.Provider value={{ getInputValue: this.getInputValue, inputChange: this.inputChange, isSubmitting: this.state.isSubmitting }}>
<form method="post" onSubmit={this.onSubmit}>
{this.props.children}
</form>
</FormContext.Provider>
);
}
As you can see, onSubmit handler must also be async because it has to wait for onSubmit to finish changing the state.
Voila! We have a tested Form component with text input and submit button. Following the same principles with context, it is going to be easy to add other input elements.
If you noticed, our tests never once mentioned existence of Context. This is because Context is a UI implementation detail and if it is replaced with something different (e.g Redux) and the resulting DOM stays the same, there is no need to change the tests. I think this is typically a good rule of thumb for UI testing in general — always test how the native view (DOM, UIKit etc) changes instead of testing how to get to the result. Because if you compare it to testing logic (e.g functions), we always test the result of the function — return values, exceptions etc. The result of a UI action is the change in native UI, not the change in intermediary medium (e.g React Shadow DOM). This kind of thinking makes testing in React much more intuitive and less scary.
Conclusion
The goal of this post was to test React components in combination. This is the last post in “Tested React” series. Testing is always scary when it is unclear what to test. I hope these posts shed some light on how to approach testing components in React.
Resources
- React Test Utilities: https://reactjs.org/docs/test-utils.html
- React Context: https://reactjs.org/docs/context.html (class contextType: https://reactjs.org/docs/context.html#classcontexttype)