DEV Community

Cover image for Creating a React component with TDD
Matti Bar-Zeev
Matti Bar-Zeev

Posted on

Creating a React component with TDD

Join me in this post as I create a React component using Test Driven Development (TDD) approach.

I am going to create a confirmation component, which has the following features:

  • A static title
  • A confirmation question - and this can be any question the app would like to confirm
  • A button for confirming, supporting an external handler
  • A button for canceling, supporting an external handler

Both buttons are not aware of what happens when they are clicked, since it is out of the component’s responsibilities, but the component should enable other components/containers which use it to give it a callback for these buttons.
Here how it should look:

Image description

So with that let’s get started.
The process of TDD is a cycle of writing a test => watch it fail => write the minimum code for it to pass => watch it succeed => refactor (if needed) => repeat, and this is what I’m going to practice here. It may, at some point, appear to you as tedious or perhaps impractical, but I insist on doing this by-the-book and leave it to you to decide whether it serves your purposes well, or you’d like to cut some corners on the way.

Off we go with the test file first. I got my Jest testing env running on watch mode and created the component’s directory named “Confirmation” and an “index.test.js” file residing in it.
The first test is pretty abstract. I want to check that rendering the component renders something (anything) to make sure my component exists. In practice I will render my (still not existing) component to see if I can find it on the document by its “dialog” role:

import React from 'react';
import {render} from '@testing-library/react';

describe('Confirmation component', () => {
   it('should render', () => {
       const {getByRole} = render(<Confirmation />);
       expect(getByRole('dialog')).toBeInTheDocument();
   });
});
Enter fullscreen mode Exit fullscreen mode

Well, you guessed it - Jest does not know what “Confirmation” is, and it is right. Let’s create that component just enough to satisfy this test:

import React from 'react';

const Confirmation = () => {
   return <div role="dialog"></div>;
};

export default Confirmation;
Enter fullscreen mode Exit fullscreen mode

I imported this Component to my test and it passed now. Great.

Next we would like to have a title for this component. For the purpose of this walkthrough the title is static and should say “Confirmation”. Let’s create a test for it:

it('should have a title saying "Confirmation"', () => {
       const {getByText} = render(<Confirmation />);
       expect(getByText('Confirmation')).toBeInTheDocument();
   });
Enter fullscreen mode Exit fullscreen mode

The test fails, now we write the code to make it pass:

import React from 'react';

const Confirmation = () => {
   return (
       <div role="dialog">
           <h1>Confirmation</h1>
       </div>
   );
};

export default Confirmation;
Enter fullscreen mode Exit fullscreen mode

Moving to the next feature we want to make sure that there is a confirmation question in this component. I want this question to be dynamic so it can be given from outside the component and I think that having the question as the “children” of the Confirmation component is the right way to go about it, so here’s how the test for that looks like:

it('should have a dynamic confirmation question', () => {
       const question = 'Do you confirm?';
       const {getByText} = render(<Confirmation>{question}</Confirmation>);
       expect(getByText(question)).toBeInTheDocument();
   });
Enter fullscreen mode Exit fullscreen mode

Again the test fails so I write the code to make it pass:

import React from 'react';

const Confirmation = ({children}) => {
   return (
       <div role="dialog">
           <h1>Confirmation</h1>
           <div>{children}</div>
       </div>
   );
};

export default Confirmation;
Enter fullscreen mode Exit fullscreen mode

On for the buttons. I will start with the confirm button. We first want to check that there is a button on the component which says “OK”. From now on I will write the test first and the code which satisfy it after:

Test:

it('should have an "OK" button', () => {
       const {getByRole} = render(<Confirmation />);
       expect(getByRole('button', {name: 'OK'})).toBeInTheDocument();
   });
Enter fullscreen mode Exit fullscreen mode

I’m using the “name” option here since I know there will be at least one more button in this component and I need to be more specific about which I’d like to assert

Component:

import React from 'react';

const Confirmation = ({children}) => {
   return (
       <div role="dialog">
           <h1>Confirmation</h1>
           <div>{children}</div>
           <button>OK</button>
       </div>
   );
};

export default Confirmation;
Enter fullscreen mode Exit fullscreen mode

Let’s do the same thing for the “Cancel” button:

Test:

it('should have an "Cancel" button', () => {
       const {getByRole} = render(<Confirmation />);
       expect(getByRole('button', {name: 'Cancel'})).toBeInTheDocument();
   });
Enter fullscreen mode Exit fullscreen mode

Component:

import React from 'react';

const Confirmation = ({children}) => {
   return (
       <div role="dialog">
           <h1>Confirmation</h1>
           <div>{children}</div>
           <button>OK</button>
           <button>Cancel</button>
       </div>
   );
};

export default Confirmation;
Enter fullscreen mode Exit fullscreen mode

Ok, nice.
So we got the component rendering what we want (not styled, but that’s another story) and now I would like to make sure I can pass handlers for the buttons of this component from outside, and make sure that they are being called when the buttons are clicked.
I will start from the test for the “OK” button:

it('should be able to receive a handler for the "OK" button and execute it upon click', () => {
       const onConfirmationHandler = jest.fn();
       const {getByRole} = render(<Confirmation onConfirmation={onConfirmationHandler} />);
       const okButton = getByRole('button', {name: 'OK'});

       fireEvent.click(okButton);

       expect(onConfirmationHandler).toHaveBeenCalled();
   });
Enter fullscreen mode Exit fullscreen mode

What I did was to create a spy function, give it to the component as the “onConfirmation” handler, simulate a click on the “OK” button, and assert that the spy has been called.
The test obviously fails, and here is the code to make it happy:

import React from 'react';

const Confirmation = ({children, onConfirmation}) => {
   return (
       <div role="dialog">
           <h1>Confirmation</h1>
           <div>{children}</div>
           <button onClick={onConfirmation}>
               OK
           </button>
           <button>Cancel</button>
       </div>
   );
};

export default Confirmation;
Enter fullscreen mode Exit fullscreen mode

Sweet, let’s do the same for the “Cancel” button:

Test:

it('should be able to receive a handler for the "Cancel" button and execute it upon click', () => {
       const onCancellationHandler = jest.fn();
       const {getByRole} = render(<Confirmation onCancellation={onCancellationHandler} />);
       const cancelButton = getByRole('button', {name: 'Cancel'});

       fireEvent.click(cancelButton);

       expect(onCancellationHandler).toHaveBeenCalled();
   });
Enter fullscreen mode Exit fullscreen mode

Component:

import React from 'react';

const Confirmation = ({children, onConfirmation, onCancellation}) => {
   return (
       <div role="dialog">
           <h1>Confirmation</h1>
           <div>{children}</div>
           <button onClick={onConfirmation}>
               OK
           </button>
           <button onClick={onCancellation}>
               Cancel
           </button>
       </div>
   );
};

export default Confirmation;
Enter fullscreen mode Exit fullscreen mode

And here is the full tests file:

import React from 'react';
import {render, fireEvent} from '@testing-library/react';
import Confirmation from '.';

describe('Confirmation component', () => {
   it('should render', () => {
       const {getByRole} = render(<Confirmation />);
       expect(getByRole('dialog')).toBeInTheDocument();
   });

   it('should have a title saying "Confirmation"', () => {
       const {getByText} = render(<Confirmation />);
       expect(getByText('Confirmation')).toBeInTheDocument();
   });

   it('should have a dynamic confirmation question', () => {
       const question = 'Do you confirm?';
       const {getByText} = render(<Confirmation>{question}</Confirmation>);
       expect(getByText(question)).toBeInTheDocument();
   });

   it('should have an "OK" button', () => {
       const {getByRole} = render(<Confirmation />);
       expect(getByRole('button', {name: 'OK'})).toBeInTheDocument();
   });

   it('should have an "Cancel" button', () => {
       const {getByRole} = render(<Confirmation />);
       expect(getByRole('button', {name: 'Cancel'})).toBeInTheDocument();
   });

   it('should be able to receive a handler for the "OK" button and execute it upon click', () => {
       const onConfirmationHandler = jest.fn();
       const {getByRole} = render(<Confirmation onConfirmation={onConfirmationHandler} />);
       const okButton = getByRole('button', {name: 'OK'});

       fireEvent.click(okButton);

       expect(onConfirmationHandler).toHaveBeenCalled();
   });

   it('should be able to receive a handler for the "Cancel" button and execute it upon click', () => {
       const onCancellationHandler = jest.fn();
       const {getByRole} = render(<Confirmation onCancellation={onCancellationHandler} />);
       const cancelButton = getByRole('button', {name: 'Cancel'});

       fireEvent.click(cancelButton);

       expect(onCancellationHandler).toHaveBeenCalled();
   });
});
Enter fullscreen mode Exit fullscreen mode

And I think that that’s it! We have all the building blocks and logic of our component implemented and fully tested:

Image description

Yes, I know, the style is off but this is something we can fix after we are certain our building blocks are intact and all works according to spec.

Aside from walking with me in creating this component using TDD, this post is clear evidence TDD can be applied, and rather easily, when developing UI components. TDD will guide you step by step through your component features spec and help you focus on what matters while supplying a safety net for future refactoring. This is really awesome!

As always, if you have any ideas on how to make this better or any other technique, be sure to share with the rest of us!

Cheers

Hey! If you liked what you've just read check out @mattibarzeev on Twitter 🍻

Photo by Jo Szczepanska on Unsplash

Discussion (13)

Collapse
idanen profile image
Idan Entin

Great TDD walk through 👏
Once you added the tests for the buttons functionally, aren't the ones that test their existance redundant?

Collapse
mbarzeev profile image
Matti Bar-Zeev Author • Edited on

Cheers :)

Indeed, this is a common concern when practicing TDD, regardless of whether you test a UI component or a service. I mean, when TTD-ing a new service method you first wanna make sure the method is defined and can be accessed, while following tests use it to make sure it works as expected, so dose it make the initial test redundant? I does not IMO. Same when you test different usages of the method - some tests are bound to overlap.
The thing about TTD is that you build your service method or component one baby-step at a time, so first you wanna create the button and then you wanna attach some functionality to it. From my experience it has a lot to do with self-discipline and not jumping ahead of oneself. It is amazing how much frustration you save for yourself practicing it this way.
It can happen that multiple tests overlap and test the same code several times and that's ok. I think that it also makes it much more readable as a "spec" for someone who wants to create a component just by satisfying the tests:

  1. Confirmation component should render
  2. Confirmation component should have a title saying "Confirmation"
  3. Confirmation component should have a dynamic confirmation question
  4. Confirmation component should have an "OK" button
  5. Confirmation component should have an "Cancel" button
  6. Confirmation component should be able to receive a handler for the "OK" button and execute it upon click
  7. Confirmation component should be able to receive a handler for the "Cancel" button and execute it upon click
Collapse
idanen profile image
Idan Entin • Edited on

I'd argue that for service it's the same case. So when testing a service, do you first check if the method exist?
While I understand how TDD by small steps helps focusing while building the component, I also appreciate the refactor part of the TDD cycle, and use it to refactor the code as well as my tests, and for me, clicking a button, requires it to be in the DOM so the test that checks this is redundant and I will therefore remove it in the refactor stage

Thread Thread
mbarzeev profile image
Matti Bar-Zeev Author

At the end of the day, we do what helps us the most.
I like to think of the test I'm writing as a solid ground I blindly rely on and therefor I usually don't refactor them. I refactor the code it tests, knowing they protect me from my mistakes.
In my eyes tests should as simple as possible, with no implicit side effects. I see the the title of a failed test and I know right away what happened, no assuming or guessing, and yes - I write a test to make sure a service method is there, for I am willing to pay that price ;)

Thread Thread
idanen profile image
Idan Entin

Well TDD brings refactoring freesom, so you're missing out ;)
Regarding blindly relying on them, you can still do that, since the tests are also tested - by the code

Collapse
harmlessevil profile image
Alexander

I guess, it can help you in the situation when you created a button, but forgot to attach an event listener.

Consider some big refactoring of the component – it probably can happen

Collapse
idanen profile image
Idan Entin

In such case the functionality test would fail thanks to getByRole

Thread Thread
harmlessevil profile image
Alexander

In general you’re right, but if you won’t remove the first test, then both will fail. For some people it will be enough to understand a reason just by looking to the names of failed tests.

In your case developer will have to look in the message of failed test.

That’s how I understand the idea of writing tests with such granularity

Thread Thread
idanen profile image
Idan Entin

How many tests do you expect to fail if you make the button not available?
I think 2 failing tests are more confusing than one, even if I have to read the message (the title still helps me understand what failed - clicking the button)

Collapse
zaboco profile image
Bogdan Zaharia

One thing that might help is too replace the expect(foo).toBeInTheDocument() with text assertion on elements. For example:

expect(getByRole('heading')).toHaveTextContent('Confirmation')
Enter fullscreen mode Exit fullscreen mode

This way, when there is an error, a typo for example, the output is more focused:

Expected element to have text content:
  Confirmation
Received:
  Confimation
Enter fullscreen mode Exit fullscreen mode
Collapse
_ns2882 profile image
Nikhil Sengar

Would snapshot testing not be the best thing to test the structure of component like if have tile present or not ?

Collapse
mbarzeev profile image
Matti Bar-Zeev Author • Edited on

That's a great question actually, even more so because of the documentation for Jest snapshots claims that they aim to help in testing a rendered UI, but what I found out over the time using snapshot for testing UI components is that it is (1) very hard to maintain, (2) when something breaks you need to look carefully at a complex structure and figure out if you expected that or not, and (3) most importantly for this post subject - you really cannot conduct TTD with snapshots (you can, but it is super hard to do, with low ROI).
I use snapshots to check outputs of serializable objects like arrays or json, you know, cause then it is easier to read and understand where the test failed and why.

Collapse
_ns2882 profile image
Nikhil Sengar

got it , thanks for a detailed reply