This site runs best with JavaScript enabled.

Making your UI tests resilient to change

Software Engineer, React Training, Testing JavaScript Training

Photo by Warren Wong on Unsplash

User interface tests are famously finicky and prone to breakage. Let's talk about how to improve this.

You're a developer and you want to avoid shipping a broken login experience, so you're writing some tests to make sure you don't. Let's get a quick look at an example of such a form:

Login form from the Conduit App

1const form = (
2 <form onSubmit={this.submitForm}>
3 <fieldset>
4 <fieldset className="form-group">
5 <input
6 className="email-field form-control form-control-lg"
7 type="email"
8 placeholder="Email"
9 />
10 </fieldset>
11 <fieldset className="form-group">
12 <input
13 className="password-field form-control form-control-lg"
14 type="password"
15 placeholder="Password"
16 />
17 </fieldset>
18 <button
19 className="btn btn-lg btn-primary pull-xs-right"
20 type="submit"
21 disabled={this.props.inProgress}
22 >
23 Sign in
24 </button>
25 </fieldset>
26 </form>

Now, if we were to test this form, we'd want to fill in the username, password, and submit the form. To do that properly, we'd need to render the form and query the document to find and operate on those nodes. Here's what you might try to do to make that happen:

1const emailField = rootNode.querySelector('.email-field')
2const passwordField = rootNode.querySelector('.password-field')
3const submitButton = rootNode.querySelector('.btn')

And here's where the problem comes in. What happens when we add another button? What if we added a "Sign up" button before the "Sign in" button?

2 className="btn btn-lg btn-secondary pull-xs-right"
3 disabled="{this.props.inProgress}"
5 Sign up
8 className="btn btn-lg btn-primary pull-xs-right"
9 type="submit"
10 disabled="{this.props.inProgress}"
12 Sign in

Whelp, that's going to break our tests. Total bummer.

total bummer...

But that'd be pretty easy to fix right?

1// change this:
2const submitButton = rootNode.querySelector('.btn')
3// to this:
4const submitButton = rootNode.querySelectorAll('.btn')[1]

And we're good to go! Well, if we start using CSS-in-JS to style our form and no longer need the email-field and password-field class names, should we remove those? Or do we keep them because our tests use them? Hmmmmmmm..... 🤔

What I don't like about using class names for my selectors is that normally we think of class names as a way to style things. So when we start adding a bunch of class names that are not for that purpose it makes it even harder to know what those class names are for and when we can remove class names.

And if we simply try to reuse class names that we're already just using for styling then we run into issues like the button up above. And any time you have to change your tests when you refactor or add a feature, that's an indication of a brittle test. The core issue is that the relationship between the test and the source code is too implicit. We can overcome this issue if we make that relationship more explicit.

If we could add some metadata to the element we're trying to select that would solve the problem. Well guess what! There's actually an existing API for this! It's data- attributes!

Data from Star Trek The Next Generation saying YES

So let's update our form to use data- attributes:

1const form = (
2 <form onSubmit={this.submitForm}>
3 <fieldset>
4 <fieldset className="form-group">
5 <input
6 className="form-control form-control-lg"
7 type="email"
8 placeholder="Email"
9 data-testid="email"
10 />
11 </fieldset>
12 <fieldset className="form-group">
13 <input
14 className="form-control form-control-lg"
15 type="password"
16 placeholder="Password"
17 data-testid="password"
18 />
19 </fieldset>
20 <button
21 className="btn btn-lg btn-primary pull-xs-right"
22 type="submit"
23 disabled={this.props.inProgress}
24 data-testid="submit"
25 >
26 Sign in
27 </button>
28 </fieldset>
29 </form>

And now, with those attributes, our selectors look like this:

1const emailField = rootNode.querySelector('[data-testid="email"]')
2const passwordField = rootNode.querySelector('[data-testid="password"]')
3const submitButton = rootNode.querySelector('[data-testid="submit"]')

Awesome! So now, no matter how we change our markup, as long as we keep those data-testid attributes intact, then our tests wont break. Plus, it's much more clear what the purpose of these attributes is which makes our code more maintainable as well.

Here's a little utility called sel (short for select) that I use sometimes to make this a little easier:

1const sel = id => `[data-testid="${id}"]`
2const emailField = rootNode.querySelector(sel('email'))
3const passwordField = rootNode.querySelector(sel('password'))
4const submitButton = rootNode.querySelector(sel('submit'))

This is great for end to end tests as well. So I suggest that you use it for that too! However, some folks have expressed to me concern about shipping these attributes to production. If that's you, please really consider whether it's actually a problem for you (because honestly it's probably not as big a deal as you think it is). If you really want to, you can compile those attributes away with babel-plugin-react-remove-properties.

I should also note that if you're using enzyme to test React components, you might be interested in this to avoid some issues with enzyme's find returning component instances along with DOM nodes.

I hope this is helpful to you. Good luck! Enjoy :)

Discuss on TwitterEdit post on GitHub

Share article
loading relevant upcoming workshops...

Your essential guide to flawless testing.

Jump on this self-paced workshop and learn the smart, efficient way to test any JavaScript application. 🏆

Start Now
Kent C. Dodds

Kent C. Dodds is a JavaScript software engineer and teacher. He's taught hundreds of thousands of people how to make the world a better place with quality software development tools and practices. He lives with his wife and four kids in Utah.