DEV Community

Cover image for Refactoring React Components for Testability with Jest and Enzyme
Richard Igbiriki
Richard Igbiriki

Posted on

Refactoring React Components for Testability with Jest and Enzyme

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

Alt Text

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>

)

Enter fullscreen mode Exit fullscreen mode

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} />
        )
    }
Enter fullscreen mode Exit fullscreen mode

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>
);

Enter fullscreen mode Exit fullscreen mode

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>
);

Enter fullscreen mode Exit fullscreen mode

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.

  1. When the component is fetching programs. Loading stage.

  2. When the component has finished loading but has no content. Empty stage.

  3. 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.

  4. 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

            });

        });

Enter fullscreen mode Exit fullscreen mode

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
        });

Enter fullscreen mode Exit fullscreen mode

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);
        });
    });

Enter fullscreen mode Exit fullscreen mode

Explanation: Testing Matchers

In all our tests, we used between 3 to 5 Matchers and 2 to 3 methods on expect for comparison.

Matchers

  1. .find: takes a selector and finds matching nodes.

  2. .props gets the props set on the node.

  3. .setProps updates the props on the node.

  4. .text returns the text on the current node.

Explanation: expect methods

  1. .toHaveLength(n) expects the returned value to have an element of length or size n.

  2. .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)