Test-driven development, or TDD, is a programming paradigm in which you write your tests first and your source code second. TDD is perfect when you’re writing code that has clear inputs and outputs, like pure functions or API endpoints.
But what about when building user interfaces? Can TDD be done for UI development?
You’re about to find out! 😎
In this article we’ll explore a few questions while building a demo UI:
- Can we use TDD to build UIs?
- If so, how do we do it?
- And finally, should we use TDD to build UIs?
Background Motivation
When discussing test-driven development with frontend developers, the conversation usually goes something like this:
“Sure, TDD is great for simple functions or backend work, but it just doesn’t work for frontend work. When I’m building my UI, I don’t know what code I’ll end up writing. I have no idea if I’ll end up using a div
or a span
or a p
element here. TDD for UIs just isn’t feasible.”
However, I’d like to argue that using TDD to build UIs isn’t as hard as we may think.
Ideal Conditions for Test-Driven Development
Ideally, we’d use test-driven development to write our code when the following two conditions are true:
- We have clear project requirements
- We have clear inputs and outputs
If those two requirements are not met, it’s difficult or nearly impossible to use TDD. So let’s examine those two requirements in the context of frontend development.
Clear Project Requirements
When you’re developing a new feature, you’re typically given mockups by a UX designer. These mockups show you how the feature should look and how the feature should behave. For example, “when the user clicks this button, a dialog should appear on the screen.”
Good mockups will clarify various details such as how inputs will look when in a hover or focus state, how empty states will look when content is missing, and how the page layout will change for desktop, laptop, and mobile screen sizes.
As you may have already guessed, the mockups provide the project requirements! We know exactly how our UI should look and behave. If there’s anything unclear in the mockups, engineers should ask clarifying questions with their UX designer or product manager so that the requirements are absolutely clear.
Clear Inputs and Outputs
Now, what about clear inputs and outputs?
Most frontend engineers these days use a UI library or a framework like React or Angular. A UI library like React allows you to build reusable components to create small building blocks of functionality that you can piece together to make an app.
Now, what is a component? Well, in React, it’s a function! Components are simply functions of props and states that return a piece of UI. So we have clear inputs and outputs!
Given the same props and state, a component will always render the same thing. Components are deterministic, and as long as they don’t kick off side effects like making an API request, they are pure functions.
Practical Considerations
So, in theory, using TDD to build UIs should work. Both of our ideal conditions are met.
But what about the unknowns? As mentioned above, we still might not know a few things:
- Component props and state we’ll use
- Names we’ll give our methods and functions
- HTML elements we’ll use
But we do know how the UI should look and behave. I’d argue that the unknown implementation details actually don’t matter.
This outdated way of thinking about testing implementation details largely stems from Airbnb’s testing library Enzyme. Enzyme allowed you to dive into the internals of your React components, trigger class component methods, and manually update a component’s props and state.
However, none of those are things that a user can do. A user can only interact with your app through the interface that you provide. For example, the user might click on a button or fill out a form field.
React Testing Library’s core philosophy is that we should write our tests in such a way that we simulate user behavior. By testing what the user can actually do, our tests focus less on implementation details and more on the actual user interface, which leads to less brittle tests and a more reliable test suite.
The key here is that React Testing Library actually facilitates using TDD to build UIs by taking the focus away from the implementation details.
Remember: the unknown implementation details don’t matter. What matters is how the UI looks and behaves.
Demo Time: Building a Request Form
Alright, enough theory. I hope by now I’ve presented a clear case that it should be possible to build a user interface using test-driven development.
But how does this actually look in practice?
To illustrate these concepts in a concrete way, we’ll build a simple UI in React using TDD. Let’s imagine that we need to create a request form that allows visitors on our site to request a demo of our new product. Here are the mockups that we’ll pretend we’ve been given:
As you can see, the form displays three inputs for a user’s first name, last name, and email address. There is a submit button at the bottom of the form.
If a user tries to submit the form without filling out all three fields, an error message will be displayed next to each invalid input:
Finally, once the user properly fills out and submits the form, a confirmation screen is shown:
Getting Started
We’ll begin by bootstrapping a brand new app using create-react-app
. Once my app was generated, I deleted and re-arranged some of the initial code to get to a good starting point. As you can see, we’ll start with a bare-bones app that only returns a header element.
You can follow along here on the demo/start branch of my GitHub repo.
Test #1: Sanity Check
Let’s start by creating a RequestForm.test.js
file. We’ll write our first test that asserts that our RequestForm
component renders without crashing.
import React from 'react' import { render, screen } from '@testing-library/react' import { RequestForm } from './RequestForm' describe('RequestForm', () => { it('renders without crashing', () => { expect(() => render(<RequestForm />)).not.toThrow() }) })
As you might have guessed, this test fails because we don’t actually have a RequestForm.js
file or a RequestForm
component created yet. And that’s ok because this is how test-driven development works — we write our tests first and then our source code.
Let’s now write some code to get this test to pass. We’ll create a RequestForm.js
file that exports a RequestForm
component. The RequestForm
component will simply render a form
HTML element.
import React from 'react' export const RequestForm = () => <form />
Now when we run our tests, they pass!
Test #2: First Name Input
Let’s move on to our second test. Let’s assert that our RequestForm
component renders an input for the user to enter their first name.
it('renders a first name text input', () => { render(<RequestForm />) expect(screen.getByLabelText('First Name')).toBeInTheDocument() })
And now we’ll run our tests, and… this new test fails, as expected, because our RequestForm
component doesn’t render any inputs yet. It’s still just an empty form.
We can update our RequestForm
component to render an input
element and an accompanying label
element that allows the user to enter their first name.
import React from 'react' export const RequestForm = () => ( <form> <label htmlFor="firstName">First Name</label> <input id="firstName" /> </form> )
Now when we run our tests, they all pass!
Here’s what our current app’s UI looks like:
It’s not a very pretty app, but we’ll come back to style it up in a few minutes.
Test #3: Last Name Input
Now let’s write another test that looks just like our test for the “first name” input, but this time let’s assert that there is a “last name” input on the page.
it('renders a last name text input', () => { render(<RequestForm />) expect(screen.getByLabelText('Last Name')).toBeInTheDocument() })
We’ll run our tests, and, you guessed it, this new test fails. No surprises there.
So, just like before, let’s update our RequestForm
component to render another input
element and another label
element, but this time for the user to enter their last name.
import React from 'react' export const RequestForm = () => ( <form> <label htmlFor="firstName">First Name</label> <input id="firstName" /> <label htmlFor="lastName">Last Name</label> <input id="lastName" /> </form> )
We’ll run our tests again, and now they all pass. Nice!
Here’s what our app’s UI looks like now:
Not very pretty, is it? With all of our tests currently passing, this seems like a great time for a small refactor.
Refactor #1: Rendering Labels and Inputs on Individual Rows
Before we jump into our refactor, I’d like to call out a key principle here. When doing test-driven development, you follow what’s called a “red, green, refactor” cycle.
You start by writing a failing test, so you’re in the red. You then write the source code to make the test pass, which puts you in the green. Once you’re in the green, you can refactor your code all you want. At the end of your refactor, your existing tests must still pass, keeping you in the green.
When it comes to UI development, we can consider style changes a refactor.
With that context provided, let’s now style up our app a little. It’d be nice to have our label-input pairs on separate rows. We could also add some breathing room between the label and the input text box.
To do this, we’ll add a div
element with a formGroup
class wrapped around our label-input pairs. We’ll also create a RequestForm.css
file to include some CSS.
Here’s the RequestForm.js
file:
import React from 'react' import './RequestForm.css' export const RequestForm = () => ( <form> <div className="formGroup"> <label htmlFor="firstName">First Name</label> <input id="firstName" /> </div> <div className="formGroup"> <label htmlFor="lastName">Last Name</label> <input id="lastName" /> </div> </form> )
And here’s the accompanying RequestForm.css
file that we import:
.formGroup { margin-bottom: 1rem; } .formGroup label { padding-right: 1rem; }
With those changes in place, our UI now looks like this:
Much better! There is still more we can do to style our form to make it match our mockups, but we’ll call it good for now.
Now, remember what we had learned about the red, green, refactor cycle? Refactors should happen while we’re in the green, and after we’re done with our refactor we should still be in the green. Let’s run our tests to see how things are looking:
Sweet! Our tests are still passing after our style refactor.
Test #4: Email Address Input
Let’s move on to our next test. We have tests for the “first name” input and the “last name” input, so let’s now write one more test for the “email address” input.
it('renders an email address text input', () => { render(<RequestForm />) expect(screen.getByLabelText('Email')).toBeInTheDocument() })
This fails, as expected.
Just like we did with the “first name” and “last name” inputs, we can add another input
element and another label
element so that the user can enter their email address.
import React from 'react' import './RequestForm.css' export const RequestForm = () => ( <form> <div className="formGroup"> <label htmlFor="firstName">First Name</label> <input id="firstName" /> </div> <div className="formGroup"> <label htmlFor="lastName">Last Name</label> <input id="lastName" /> </div> <div className="formGroup"> <label htmlFor="email">Email</label> <input type="email" id="email" /> </div> </form> )
With that, our tests will all pass again.
Let’s check out the UI as it stands now:
Our inputs are still in rows, but the alignment on the “email” field could look better. It’d appear more uniform if all of the input boxes lined up nicely as if they were in a column. Let’s address this with a second refactor.
Refactor #2: Lining Up the Columns
To better align our inputs, we can add a couple simple CSS rules to our existing styles. We’ll add a width
and a display
property to our label
elements so that our full CSS file looks like this:
.formGroup { margin-bottom: 1rem; } .formGroup label { padding-right: 1rem; display: inline-block; width: 6rem; }
Now our UI looks just a little nicer:
And, the important part during any refactor: the tests all still pass.
Test #5: Submit Button
Let’s move on to our next test. This test will assert that a submit button with the text “Request Demo” appears in the form.
As I’m sure you can figure out by now, the test fails of course because we haven’t implemented the submit button yet.
We can easily add the submit button to our RequestForm
component like so:
import React from 'react' import './RequestForm.css' export const RequestForm = () => ( <form> <div className="formGroup"> <label htmlFor="firstName">First Name</label> <input id="firstName" /> </div> <div className="formGroup"> <label htmlFor="lastName">Last Name</label> <input id="lastName" /> </div> <div className="formGroup"> <label htmlFor="email">Email</label> <input type="email" id="email" /> </div> <button type="submit">Request Demo</button> </form> )
Now the tests pass again, putting us back in the green.
And the app’s UI now shows the “Request Demo” button too:
Refactor #3: Centering and Styling the Form
If we look back at our design mockups, we’ll see that our form should be centered on the page and should have a thin black border around it. Since all our tests are passing and the form contents are complete, now seems like a good time to do a third refactor with some more style updates.
In our RequestForm.js
file, we’ll add a requestForm
class to our form
element like so:
<form className="requestForm"> {/* the rest of the existing code here */} </form>
Then we’ll add some styles to that class in our RequestForm.css
file:
.requestForm { border: solid 0.0125rem #000; border-radius: 0.25rem; padding: 1rem; display: inline-block; text-align: left; }
And finally, in our main index.css
file that we haven’t touched at all during this exercise, we’ll add text-align: center
on the body
element. The resulting index.css
file looks like this:
html { margin: 0; padding: 0; font-size: 16px; } body { margin: 0; padding: 2rem; text-align: center; font-size: 100%; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
And with that, our form is nice and centered on the page with a simple border:
And, as always, we made sure that our refactors did not break any of our existing tests:
Test #6: Error Message for Invalid Inputs
Now that we have a nicely styled form on our page, it’s time to start focusing on the behavior of the form. As we can see in our second design mockup, we need error messages to display when the form is submitted if any of the input fields are not filled out.
Let’s first write a test to assert that error messages are shown when the form is submitted with invalid inputs.
At the top of our RequestForm.test.js
file we’ll need to import a new method from React Testing Library called fireEvent
so that we can click on the submit button as part of our test.
import { render, screen, fireEvent } from '@testing-library/react'
Then we can write our new test:
it('renders an error message for each of the inputs if none of them are filled out and the user submits the form', () => { render(<RequestForm />) fireEvent.click(screen.getByText('Request Demo')) expect(screen.getByText('First Name field is required')).toBeInTheDocument() expect(screen.getByText('Last Name field is required')).toBeInTheDocument() expect(screen.getByText('Email field is required')).toBeInTheDocument() })
As you can see, we render the form, click the submit button, and then assert that all three input fields have an accompanying error message shown.
When we run our tests, this new test of course fails.
To implement this functionality, we’ll make some fairly significant changes to our RequestForm
component. First, we’ll change our uncontrolled inputs to be controlled inputs. In other words, we’ll let React manage each input’s value
attribute and onChange
handler. Second, we’ll add some error messages that are conditionally rendered when the user submits the form.
These changes result in the RequestForm.js
file now looking like this:
import React, { useState } from 'react' import './RequestForm.css' export const RequestForm = () => { const [firstName, setFirstName] = useState('') const [lastName, setLastName] = useState('') const [email, setEmail] = useState('') const handleChange = e => { const { name, value } = e.target switch (name) { case 'firstName': setFirstName(value) break case 'lastName': setLastName(value) break case 'email': setEmail(value) break default: return } } const [firstNameError, setFirstNameError] = useState('') const [lastNameError, setLastNameError] = useState('') const [emailError, setEmailError] = useState('') const handleSubmit = e => { e.preventDefault() setFirstNameError(firstName ? '' : 'First Name field is required') setLastNameError(lastName ? '' : 'Last Name field is required') setEmailError(email ? '' : 'Email field is required') } return ( <form className="requestForm" onSubmit={handleSubmit}> <div className="formGroup"> <label htmlFor="firstName">First Name</label> <input name="firstName" id="firstName" data-testid="firstName" value={firstName} onChange={handleChange} /> </div> {firstNameError && <p>{firstNameError}</p>} <div className="formGroup"> <label htmlFor="lastName">Last Name</label> <input name="lastName" id="lastName" data-testid="lastName" value={lastName} onChange={handleChange} /> </div> {lastNameError && <p>{lastNameError}</p>} <div className="formGroup"> <label htmlFor="email">Email</label> <input type="email" name="email" id="email" data-testid="email" value={email} onChange={handleChange} /> </div> {emailError && <p>{emailError}</p>} <button type="submit">Request Demo</button> </form> ) }
That was a lot to take in! Feel free to pause and take a minute to look through the code above if you need to.
Let’s look at our tests now that we’ve implemented the error message functionality:
They all pass! Beautiful! And how about the app’s UI? Here’s what the app looks like now after the user clicks the submit button without filling out any of the form fields:
As you can see, while the form is functional, it could use some style updates. That means it’s time for another refactor!
Refactor #4: Styling the Error Messages
Looking back at our design mockups, we see that the error messages should be in red. The height of the form shouldn’t change when an error message is displayed either.
We can achieve this by adding a new class called errorMessage
to our error message content. We will also conditionally add an error
class to our formGroup
element when the input is in an invalid state.
The resulting RequestForm.js
file looks like this:
import React, { useState } from 'react' import './RequestForm.css' export const RequestForm = () => { const [firstName, setFirstName] = useState('') const [lastName, setLastName] = useState('') const [email, setEmail] = useState('') const handleChange = e => { const { name, value } = e.target switch (name) { case 'firstName': setFirstName(value) break case 'lastName': setLastName(value) break case 'email': setEmail(value) break default: return } } const [firstNameError, setFirstNameError] = useState('') const [lastNameError, setLastNameError] = useState('') const [emailError, setEmailError] = useState('') const handleSubmit = e => { e.preventDefault() setFirstNameError(firstName ? '' : 'First Name field is required') setLastNameError(lastName ? '' : 'Last Name field is required') setEmailError(email ? '' : 'Email field is required') } return ( <form className="requestForm" onSubmit={handleSubmit}> <div className={`formGroup${firstNameError ? ' error' : ''}`}> <label htmlFor="firstName">First Name</label> <input name="firstName" id="firstName" data-testid="firstName" value={firstName} onChange={handleChange} /> </div> {firstNameError && <p className="errorMessage">{firstNameError}</p>} <div className={`formGroup${lastNameError ? ' error' : ''}`}> <label htmlFor="lastName">Last Name</label> <input name="lastName" id="lastName" data-testid="lastName" value={lastName} onChange={handleChange} /> </div> {lastNameError && <p className="errorMessage">{lastNameError}</p>} <div className={`formGroup${emailError ? ' error' : ''}`}> <label htmlFor="email">Email</label> <input type="email" name="email" id="email" data-testid="email" value={email} onChange={handleChange} /> </div> {emailError && <p className="errorMessage">{emailError}</p>} <button type="submit">Request Demo</button> </form> ) }
And the resulting RequestForm.css
file looks like this:
.formGroup { margin-bottom: 2rem; } .formGroup.error { margin-bottom: 0; } .formGroup label { padding-right: 1rem; display: inline-block; width: 6rem; } .requestForm { border: solid 0.0125rem #000; border-radius: 0.25rem; padding: 1rem; display: inline-block; text-align: left; } .errorMessage { color: red; margin-top: 0; line-height: 1; }
Let’s check out our app’s UI after submitting an empty form again:
Much better! The error messages are shown in red, and the form maintains a consistent height regardless of whether or not error messages appear on the screen.
As always, we’ll check our tests as well to make sure we didn’t break anything:
Everything is still passing! Excellent. Another successful refactor.
Test #7: Confirmation Message on Successful Form Submission
With that, we’re ready for our seventh and final test. We now want to assert that the form can be successfully submitted and that a confirmation screen is shown to the user.
We’ll write the following test:
it('replaces the form with a confirmation message when submitted successfully', () => { render(<RequestForm />) fireEvent.change(screen.getByLabelText('First Name'), { target: { value: 'Tyler' }, }) expect(screen.getByLabelText('First Name').value).toBe('Tyler') fireEvent.change(screen.getByLabelText('Last Name'), { target: { value: 'Hawkins' }, }) expect(screen.getByLabelText('Last Name').value).toBe('Hawkins') fireEvent.change(screen.getByLabelText('Email'), { target: { value: '[email protected]' }, }) expect(screen.getByLabelText('Email').value).toBe('[email protected]') fireEvent.click(screen.getByText('Request Demo')) expect( screen.getByText('Thank you! We will be in touch with you shortly.') ).toBeInTheDocument() expect(screen.queryByLabelText('First Name')).not.toBeInTheDocument() expect(screen.queryByLabelText('Last Name')).not.toBeInTheDocument() expect(screen.queryByLabelText('Email')).not.toBeInTheDocument() })
Walking through the test, we first render the form. We then fill out the form, one input at a time, and assert that the value of each input is what we would expect. Then we click the submit button to submit the form. We then assert that the confirmation message is shown on the screen and that the original form no longer appears.
When we run our tests, this test fails. (Shocker, right?)
Now we can write our source code to implement the successful form submission functionality. We’ll add the confirmation text that is conditionally rendered when a new submitted
piece of state is true
. We’ll also break up our single onChange
handler into separate onChange
handlers just for fun.
The final RequestForm.js
file looks like this:
import React, { useState } from 'react' import './RequestForm.css' export const RequestForm = () => { const [firstName, setFirstName] = useState('') const [lastName, setLastName] = useState('') const [email, setEmail] = useState('') const handleFirstNameChange = e => { setFirstName(e.target.value) } const handleLastNameChange = e => { setLastName(e.target.value) } const handleEmailChange = e => { setEmail(e.target.value) } const [firstNameError, setFirstNameError] = useState('') const [lastNameError, setLastNameError] = useState('') const [emailError, setEmailError] = useState('') const [submitted, setSubmitted] = useState(false) const handleSubmit = e => { e.preventDefault() setFirstNameError(firstName ? '' : 'First Name field is required') setLastNameError(lastName ? '' : 'Last Name field is required') setEmailError(email ? '' : 'Email field is required') if (firstName && lastName && email) { setSubmitted(true) } } return submitted ? ( <p>Thank you! We will be in touch with you shortly.</p> ) : ( <form className="requestForm" onSubmit={handleSubmit}> <div className={`formGroup${firstNameError ? ' error' : ''}`}> <label htmlFor="firstName">First Name</label> <input name="firstName" id="firstName" data-testid="firstName" value={firstName} onChange={handleFirstNameChange} /> </div> {firstNameError && <p className="errorMessage">{firstNameError}</p>} <div className={`formGroup${lastNameError ? ' error' : ''}`}> <label htmlFor="lastName">Last Name</label> <input name="lastName" id="lastName" data-testid="lastName" value={lastName} onChange={handleLastNameChange} /> </div> {lastNameError && <p className="errorMessage">{lastNameError}</p>} <div className={`formGroup${emailError ? ' error' : ''}`}> <label htmlFor="email">Email</label> <input type="email" name="email" id="email" data-testid="email" value={email} onChange={handleEmailChange} /> </div> {emailError && <p className="errorMessage">{emailError}</p>} <button type="submit">Request Demo</button> </form> ) }
And with that, we now have seven passing tests!
We can confirm the behavior manually in our app too by filling out the form fields:
We’ll click the submit button and… voilà! A simple confirmation message appears on the screen.
We did it! 💪
Resulting Code Coverage
We’ve now finished building our “Request Demo” form. We’ve built out all the functionality requested by our product manager and UX designer, and the resulting UI matches the mockups perfectly.
But how did we do on our test coverage? Were the tests we wrote sufficient? We can generate the test coverage report by running yarn test:coverage
. The output is below:
Amazing! 100% test coverage. This is one of the major benefits of test-driven-development: all of your code, in theory, should be covered by tests because the only source code you write is code to make your tests pass. And because the tests are derived from product requirements, you can be sure that they are testing the right things.
Conclusion
So, what have we learned? At the beginning of this article, we were determined to answer three questions:
- Can we use TDD to build UIs?
- If so, how do we do it?
- And finally, should we use TDD to build UIs?
So, can we use TDD when building UIs? Yes! We absolutely can!
And how do we do this? By following a process similar to what we’ve outlined by building our demo app. We follow the red, green, refactor cycle and implement style changes during the “refactor” phase.
Now, should we use TDD when building UIs? Maybe. Test-driven development isn’t everyone’s cup of tea. But I strongly believe that all frontend developers should at least try it. Give it a shot and see if it works for you. As we’ve demonstrated, using TDD to build UIs isn’t as difficult as you may think. In fact, you may even find that TDD speeds up your development process while ensuring that you test the right things.
Happy coding! 🚀