Disclaimer
I cannot affirm or deny that this post is a continuation of my previous post: Testing Your First React Component with Jest and Enzyme, but if this is your first read on testing react components, I do politely suggest you see that one first.
Introduction
Testing your react components is a thrilling exercise (in my experience), however, it can take a swift turn if your components are large
and rippled with state
. Consequently, it is considered a good (perhaps best?) practice to split components into smaller independent components...preferably pure components. Using pure components prevents unnecessary side effects that can occur in the component lifecycle methods. In this post, we will walk through splitting a component into smaller pure components and writing tests for those components.
Let's get started.
Our Component
Observations
In this component, we can see that we have a box for each program. This is a testable unit and should be its own component. We also have a 'Programs' text and a 'Create New' button in the subheader, this can also be moved into its own component. Keeping in mind this possible splits, let's see what the initial implementation can look like. We will ONLY view the render
method.
return (
const { loading, message, programs } = this.state;
<div loading={loading} message={message} programs={programs} className="container jumbo-header">
<div className="jumbo-box">
<div className="jumbo">
<p id="title" className="ml-3 text">Programs</p>
</div>
</div>
{/* show message if there is a status mesage */}
{message && <div className='text-center'>
<h5 id="message" className='text-info'> {message} </h5>
</div>}
{/* If fetching programs, show loading spinner */}
{loading && <Spinner animation="grow" variant="info" />}
<Container className="mt-3">
<div className="admin-button" style={{height:'3rem'}}>
<Link id="new-link" to='/programs/new'>
<Button id='new-button' className="create float-right">Create New</Button>
</Link>
</div>
{/* return all programs as Card items if they exist */}
{ programs && programs.map((data, i) =>
<Card data={data} key={data.id} className="pro-box ml-5 shadow p-2 mt-4 rounded float-left" border="light" style={{width: '30rem'}}>
<h4 id="title" className="text-center mt-2">{data.title}</h4>
<div className="pro-text d-flex pt-5 text-center">
<p id="length" className="ml-5 text-center">Duration: {data.length}</p>
<p id="instructor" className="ml-5">Instructor: {data.instructor}</p>
</div>
<p className="pro-anchor text-center pt-4">VIEW</p>
</Card>
)}
</Container>
</div>
)
Here, we have a CORRECT but large single implementation of the UI we were given. However, this implementation makes testing the programs Card
, for instance, a tidbit more difficult. If you can somehow circumvent that difficulty, testing the component as it is will result in unnecessary side effects, as I earlier mentioned.
Following our initial observation, let us split this render method into simpler pure components.
Main Component
Our main component above will be refactored to return a secondary component as shown:
render() {
//Programs component is more easily testable as a pure function
const { programs, message, loading } = this.state;
return(
<ProgramsComponent programs={programs} message={message} loading={loading} />
)
}
Moving on...
Programs Component
Our programs component will render the subheader, the spinner, and a message if any. It will also attempt to render a separate Item
component that represents a program for every available program.
const ProgramsComponent = ({ programs, message, loading }) => (
<div loading={loading} message={message} programs={programs} className="container jumbo-header">
<div className="jumbo-box">
<div className="jumbo">
<p id="title" className="ml-3 text">Programs</p>
</div>
</div>
{message && <div className='text-center'><h5 id="message" className='text-info'> {message} </h5></div>}
{loading && <Spinner animation="grow" variant="info" />}
<Container className="mt-3">
<div className="admin-button" style={{height:'3rem'}}>
<Link id="new-link" to='/programs/new'>
<Button id='new-button' className="create float-right">Create New</Button>
</Link>
</div>
{/* Move program details to another component */}
{ programs && programs.map((data, i) =>
<Item key={data._id} data={data} />
)}
</Container>
</div>
);
Moving on to our final component...
Item Component
Our item component will only be responsible for rendering a program. This enables us to test this component as a unit (re: unit testing). Did I just explain unit testing
as a side effect of this post? Interesting!
Here is our Item component.
const Item = ({ data }) => (
<Card data={data} key={data.id} className="pro-box ml-5 shadow p-2 mt-4 rounded float-left" border="light" style={{width: '30rem'}}>
<h4 id="title" className="text-center mt-2">{data.title}</h4>
<div className="pro-text d-flex pt-5 text-center">
<p id="length" className="ml-5 text-center">Duration: {data.length}</p>
<p id="instructor" className="ml-5">Instructor: {data.instructor}</p>
</div>
<p className="pro-anchor text-center pt-4">VIEW</p>
</Card>
);
We have successfully divided out large component into two smaller pure components that can be tested individually. For the sake of brevity (this is already getting too long), we will be drastically limiting our test coverage in this post.
Testing Our Components
Our unit tests can be divided into at least three stages.
When the component is fetching programs. Loading stage.
When the component has finished loading but has no content. Empty stage.
When the component has finished loading, has no message, but has content. This can be further split to testing scenarios of one item or multiple items.
Tests for our Item component.
Yeah, I know, this may already sound like so much work. Doh. However, we did agree to keep it short and simple so below are the tests for the different stages.
Stage 1 and 2: Loadin and Empty Content
describe('tests general requirements and an loading component', () => {
//Start with an empty loading component
const wrapper = shallow(<ProgramsComponent loading={true} message={null} programs={[]} />);
describe('tests general component requirements', () => {
it('should have page title', ()=> {
expect(wrapper.find('#title')).toHaveLength(1);
expect(wrapper.find('#title').text()).toEqual('Programs');
});
//...More tests for button and Link
});
describe('tests empty program', () => {
it('should be loading', () => {
expect(wrapper.props().loading).toEqual(true);
});
it('should have a spinner', () => {
expect(wrapper.find('Spinner')).toHaveLength(1);
});
it('should not have Item', () => {
expect(wrapper.props().programs.length).toEqual(0);
expect(wrapper.find('Item')).toHaveLength(0);
});
//...Test for no message
});
});
Stage 3: Available Content
describe('tests component with multiple programs', () => {
const programs=[
{
_id:1,
title: 'Web Development',
length: '3 Months',
instructor: 'Richard Igbiriki'
},
{
_id:2,
title: 'Mobile Development',
length: '3 Months',
instructor: 'Richard Igbiriki'
},
{
_id:3,
title: 'Software Development',
length: '3 Months',
instructor: 'Richard Igbiriki'
}
];
const wrapper = shallow(<ProgramsComponent loading={false} message={null} programs={programs} />);
it('should have three Items', () => {
expect(wrapper.find('Item')).toHaveLength(3);
});
it('should update items on props update', () => {
//remove one item
const i = programs.pop();
wrapper.setProps({ programs });
expect(wrapper.find('Item')).toHaveLength(2);
//add item
programs.push(i);
wrapper.setProps({ programs });
expect(wrapper.find('Item')).toHaveLength(3);
});
//...More tests
});
Stage 4: Item Component
describe('Tests Item component', () => {
const data = {
_id:1,
title: 'Web Development',
length: '3 Months',
instructor: 'Richard Igbiriki'
}
const wrapper = shallow(<Item data={data} />);
it('should have data props', () => {
expect(wrapper.props().data).toBeDefined();
});
it('should have a title', () => {
expect(wrapper.find('#title')).toHaveLength(1);
expect(wrapper.find('#title').text()).toEqual(data.title);
});
it('should have a length', () => {
expect(wrapper.find('#length')).toHaveLength(1);
expect(wrapper.find('#length').text()).toEqual('Duration: '+data.length);
});
it('should have an instructor', () => {
expect(wrapper.find('#instructor')).toHaveLength(1);
expect(wrapper.find('#instructor').text()).toEqual('Instructor: '+data.instructor);
});
});
Explanation: Testing Matchers
In all our tests, we used between 3 to 5 Matchers and 2 to 3 methods on expect for comparison.
Matchers
.find: takes a selector and finds matching nodes.
.props gets the props set on the node.
.setProps updates the props on the node.
.text returns the text on the current node.
Explanation: expect
methods
.toHaveLength(n) expects the returned value to have an element of length or size n.
.toEqual(variable) expects the returned value to be equal to variable.
Conclusion
Yay!!! We are done.
This was longer than I anticipated but once again, I hope it was as fun for you reading and trying as it was for me writing it.
For those that do not follow me on Twitter, these posts contain active projects that I and my team are currently working on that is why I do not have links to any github repo. I will continue to write as the need arises.
Thank you.
Top comments (0)