Http://medium.com/acesmndr/t… Translation address: github.com/xiao-T/note… The copyright of this article belongs to the original author. Translation is for study only.
The React function component is essentially a simple function that returns a React Element. This is the most anticipated feature in React V16.8, as documented by Hooks that can be used to inject state and lifecycle methods into statless function components to make them stateful. The simple syntax and plug-and-play nature of writing function components make it fun, whereas writing class components is a bit of a hassle.
Take a look at the React function component implemented using Hooks.
import React from 'react';
export default function Login(props) {
const { email: propsEmail, password: propsPassword, dispatch } = props;
const [isLoginDisabled, setIsLoginDisabled] = React.useState(true);
const [email, setEmail] = React.useState(propsEmail || ' ');
const [password, setPassword] = React.useState(propsPassword || ' ');
React.useEffect((a)= > {
validateForm();
}, [email, password]);
const validateEmail = text= > /@/.test(text);
const validateForm = (a)= > {
setIsLoginDisabled(password.length < 8| |! validateEmail(email)); };const handleEmailBlur = evt= > {
const emailValue = evt.target.value.trim();
setEmail(emailValue);
};
const handlePasswordChange = evt= > {
const passwordValue = evt.target.value.trim();
setPassword(passwordValue);
};
const handleSubmit = (a)= > {
dispatch('submit(email, password)');
setIsLoginDisabled(true);
};
return( <form> <input type="email" placeholder="email" className="mx-auto my-2" onBlur={handleEmailBlur} /> <input type="password" className="my-2" onChange={handlePasswordChange} value={password} /> <input type="button" className="btn btn-primary" onClick={handleSubmit} disabled={isLoginDisabled} value="Submit" /> </form> ); }Copy the code
The form in the above function component consists of an uncontrolled email input and a controllable Password input, internally updated with state via useState and useEffect Hooks, and then the submit button triggers a commit action.
I had to cover a variety of scenarios for this component, so I had to write test cases for functional components.
Enzyme and Jest
I’m not going to go too far into how to install the Enzyme and Jest. For the sake of brevity, I’m assuming you already know something about it. In short, Jest is just a javascript testing framework that I use to write tests; Enzyme is a test tool library, used together to make it easier to write test cases. Here are resources for Jest and Enzyme related Settings:
- Set the Jest
- Set the Enzyme adapter
Shallow vs Mount
For the uninitiated, mount renders all nodes of a component, but shallow, as its name suggests, only renders the component itself, not its children.
I prefer Shallow to mount because it is more useful for unit testing components rather than asserting their internal behavior. This is useful if you use a UI component library such as Reactstrap. Because we don’t want to test the components in these libraries (because the components themselves are already tested). If we use mount, the associated nodes of the component library will also be rendered. Shallow does not render them, and we can use these components normally. Shallow also has a performance advantage over mount.
In the past, I have written class components that have been tested with Shallow and Jest. The test documentation for the class component in Enzyme is very complete. However, there is very little testing documentation for functional components, and at the time I used it, it was just released. The React team recommends using react-testing-library to test for Hooks.
Using Enzyme I can’t find a proper way to access and test internal methods that update component state. After googling and failing to find a suitable solution to pass the Enzyme shallow test function component, I tried various methods, such as asking questions on StackOverflow. Then I got a reply from Alex:
Because we don’t know if hooks are called by spy or by the component itself, we can determine the execution status of hooks by detecting updates to state, which seems to be the right way to test the function component.
Test the UI and Props of the component
So, to test the Login component, we render it with shallow. To test the full UI, we can test it with a snapshot. A snapshot is the complete HTML content of the component after rendering. It contains all the elements, and if any changes are made the new snapshot will fail if it doesn’t match the last one.
Then, to test the rendered component, we use the find selector to ensure that all elements are present and match the props to check the integrity of the props.
import React from 'react';
import { shallow } from 'enzyme';
import Login from '.. /index';
describe('<Login /> with no props', () = > {const container = shallow(<Login />);
it('should match the snapshot', () => {
expect(container.html()).toMatchSnapshot();
});
it('should have an email field', () => {
expect(container.find('input[type="email"]').length).toEqual(1);
});
it('should have proper props for email field', () => {
expect(container.find('input[type="email"]').props()).toEqual({
className: 'mx-auto my-2',
onBlur: expect.any(Function),
placeholder: 'email',
type: 'email',
});
});
it('should have a password field', () => { /* Similar to above */ });
it('should have proper props for password field', () => { /* Trimmed for less lines to read */ });
it('should have a submit button', () => { /* */ });
it('should have proper props for submit button', () => { /* */ });
});
Copy the code
Instead of testing all properties, we can test individual properties.
For example, to test the value property of the password box we can do this:
However, in order to make it easier to write test cases, I prefer to detect all attributes. You don’t have to worry about which attributes need to be tested and which ones are missing, which saves time and satisfies the requirements.
Now, let’s test the Login component with the pass properties. We use the same method above to check if the associated properties change when we pass initalProps.
describe('<Login /> with other props', () = > {const initialProps = {
email: '[email protected]'.password: 'notapassword'};const container = shallow(<Login {. initialProps} / >);
it('should have proper props for email field', () => {
expect(container.find('input[type="email"]').props()).toEqual({
className: 'mx-auto my-2',
onBlur: expect.any(Function),
placeholder: 'email',
type: 'email',
});
});
it('should have proper props for password field', () => {
expect(container.find('input[type="password"]').props()).toEqual({
className: 'my-2',
onChange: expect.any(Function),
type: 'password',
value: 'notapassword',
});
});
it('should have proper props for submit button', () => { /* */ });
});
Copy the code
Test the state update
Function components maintain state through useState. Because state hooks cannot be exported from within the component, we cannot test them with calls. To test whether state has been updated, simulate an event or call a component’s property method to verify that state has been updated and that the component is properly rendered.
In other words: We test for side effects.
React 16.8.5 supports useState, so we need the same or a later version of React.
it('should set the password value on change event with trim', () => {
container.find('input[type="password"]').simulate('change', {
target: {
value: 'somenewpassword ',}}); expect(container.find('input[type="password"]').prop('value')).toEqual(
'somenewpassword',); }); it('should call the dispatch function and disable the submit button on button click', () => {
container.find('input[type="button"]').simulate('click');
expect(
container.find('input[type="button"]').prop('disabled'),
).toBeTruthy();
expect(initialProps.dispatch).toHaveBeenCalledTimes(1);
});
Copy the code
Another alternative to SIMULATE is to call methods mounted on prop and pass in the necessary parameters.
This is useful when we need to test methods on custom components.
Here is how onDropdownClose is triggered:
Lifecycle hooks
When using the Shallow rendering component, useEffect life-cycle hooks like hooks are not yet supported (they are not called), so we need to use mount instead. Just like useState, we can model events or execute them as property methods, and then check for updates to the props to verify that the hooks are correct.
describe('<Login /> test effect hooks', () = > {const container = mount(<Login />);
it('should have the login disabled by default', () => {
expect(
container.find('input[type="button"]').prop('disabled'),
).toBeTruthy();
});
it('should have the login enabled with valid values', () => {
container.find('input[type="password"]').simulate('change', {
target: {
value: 'validpassword',
},
});
expect(container.find('input[type="button"]').prop('disabled')).toBeFalsy();
});
it('should have the login disabled with invalid values', () => {
container.find('input[type="email"]').simulate('blur', { /* */ });
expect(
container.find('input[type="button"]').prop('disabled'),
).toBeTruthy();
});
});
Copy the code
See here for details on the life cycle hooks for Enzyme support.
Methods that don’t update state
Methods that do not need to maintain state can be refactored out of the component and placed in a separate file, testing these functional functions separately rather than testing them inside the component. If this method is very specific to the component and cannot be extracted outside, we can also put them in the same file as the component, but not inside the component. To standardize functions, we can abstract them from a single method.
export const LoginMethods = (a)= > {
const isEmailValid = text= > /@/.test(text);
const isPasswordValid = password= > password.length >= 8;
const areFormFieldsValid = (email, password) = >
isEmailValid(email) && isPasswordValid(password);
return {
isEmailValid,
isPasswordValid,
areFormFieldsValid,
};
};
export default function Login(props) {
/* useState declarations unchanged */
React.useLayoutEffect((a)= >{ setIsLoginDisabled(! LoginMethods().areFormFieldsValid(email, password)); }, [email, password]);Copy the code
At this point, testing them is pretty straightforward.
describe('LoginMethods()', () => {
it('isEmailValid should return false if email is invalid', () => {
expect(LoginMethods().isEmailValid('notvalidemail')).toBeFalsy();
expect(LoginMethods().isEmailValid('notvalidemail.aswell')).toBeFalsy();
});
it('isEmailValid should return false if email is valid', () => {
expect(LoginMethods().isEmailValid('[email protected]')).toBeTruthy();
});
/* Similar for isPasswordValid and areFormFieldsValid */
});
Copy the code
Test uncontrollable components
But how do you test uncontrollable components? Since the Email input is uncontrolled, its state is not controlled internally by the component. If I set a value attribute to the component, I will get an error message that onChange is required, otherwise the component will become a read-only controllable component and cannot enter any values.
Therefore, to test the component without setting value, we will assign the state of the email to the data-value property.
After assigning value to data-value, we can test it by simulating events like a controllable component, and then check to see if the data-value is correct.
it('should set the email data value prop', () => {
container.find('input[type="email"]').simulate('blur', {
target: {
value: '[email protected]',}}); expect(container.find('input[type="email"]').prop('data-value')).toEqual(
'[email protected]',); });Copy the code
At this point, we should have 100% test coverage, which means that you have successfully tested the component with proper coverage.
Refactoring stateless components and custom hooks (optional)
To reduce the problems associated with uncontrollable components, one implementation recommendation (thanks to Rohit Dai) is to isolate state and life-cycle hooks from real components and test them as custom hooks.
The hook is separated into a single method and returns an object, which is then injected into the function component through a custom hook. With this implementation, the function component is split into a custom hook and a stateless component. Make stateless components stateful by injecting custom components.
import React from 'react';
export const LoginMethods = (a)= > { /* Same as before */ };
export const useLoginElements = props= > {
const { email: propsEmail, password: propsPassword, dispatch } = props;
const [isLoginDisabled, setIsLoginDisabled] = React.useState(true);
const [email, setEmail] = React.useState(propsEmail || ' ');
const [password, setPassword] = React.useState(propsPassword || ' ');
React.useEffect((a)= >{ setIsLoginDisabled(! LoginMethods().areFormFieldsValid(email, password)); }, [email, password]);const handleEmailBlur = evt= > {
const emailValue = evt.target.value.trim();
setEmail(emailValue);
};
const handlePasswordChange = evt= > {
const passwordValue = evt.target.value.trim();
setPassword(passwordValue);
};
const handleSubmit = (a)= > {
dispatch('submit(email, password)');
setIsLoginDisabled(true);
};
return {
emailField: {
onBlur: handleEmailBlur,
value: email,
},
passwordField: {
onChange: handlePasswordChange,
value: password,
},
submitBtn: {
onClick: handleSubmit,
disabled: isLoginDisabled,
},
};
};
export default function Login(props) {
const { emailField, passwordField, submitBtn } = useLoginElements(props);
return( <form> <input type="email" placeholder="email" className="mx-auto my-2" onBlur={emailField.onBlur} /> <input type="password" className="my-2" {... passwordField} /> <input type="button" className="btn btn-primary" value="Submit" {... submitBtn} /> </form> );Copy the code
This will solve the problem of uncontrollable components. We can even export the email state via the value attribute in the custom hook (in the emailField element). Then, in the component, we can discard the unnecessary attributes and use only the necessary attributes as in the example above. In the example above we used only the onBlur method from emailField. Now, we can expose all the methods as properties and test them without actually using them.
Test custom hooks
Now, to test a custom hook, we introduce it into a function component. If we do not do this, the hook will not be testable because hooks are designed to be used only in function components. Then, we expect custom hooks to work properly in function components.
describe('useLoginElements', () = > {const Elements = (a)= > {
const props = useLoginElements({});
return <div {. props} / >;
}; // since hooks can only be used inside a function component we wrap it inside one
const container = shallow(<Elements />);
it('should have proper props for email field', () => {
expect(container.prop('emailField')).toEqual({
value: '',
onBlur: expect.any(Function),
});
});
it('should set value on email onBlur event', () => {
container.prop('emailField').onBlur({
target: {
value: '[email protected]',
},
});
expect(container.prop('emailField').value).toEqual('[email protected]');
});
it('should have proper props for password field', () => { /* Check onChange and value props */ });
/* check other functional behavior of the component */
});
describe('<Login/>', () => {
const container = shallow(<Login />);
it('should match the snapshot', () => {
expect(container.html()).toMatchSnapshot();
});
/* Test for other ui aspects of the page and not the functional behavior of the component */
});
Copy the code
Finally, in the Login component, we just test the UI and don’t care about the methods and behavior. In this way, we can separate the behavior of the App from the UI.
conclusion
- Test the entire props object of a component, not a single prop
- You can reuse the same test specification with or without props
- Test hooks by simulating events and checking for side effects
- Test unsupported hooks using mount and check for side effects
- Logic that does not need to update component state is extracted outside the component
- Use data-Attributes to test uncontrollable components
These methods are not set in stone, but I use them all the time. I hope it was helpful. Use custom hooks to test function components correctly, or if you have a better way to test function components let me know in the comments.
Thanks for your support. 👏 😇