React Testing, Part 2

Image: Quickmeme
My last post was titled “Testing React Router: Part 1.” Which seems to imply there’s a Part 2 about React Router. But I want to… er… re-route the conversation. This post is about testing React components and how integration tests can save the day.

The project was almost complete. My time as team captain was almost over. Victory was in sight…

My team had been working on Source Maps “Dragondrop,” a UI feature allowing users to drag and drop source maps to unminify Javascript error stack traces. As the project’s team captain, I had generated a list of test cases and scheduled a team “bug hunt” to go through them manually.

The test plan looked something like this:

  1. Happy path! Drag and drop the correct source map onto the stack trace. Verify unminified line #, column #, and source code.
  2. Drag and drop the wrong source map onto the stack trace. Verify error message banner saying wrong file.
  3. Drag and drop a source map with no source content. Verify warning banner saying no source content, but correctly unminified line # and column #.

Etc.

Our bug hunt revealed that most test cases passed! But it also revealed a subtly bug… and that final bug fix ballooned into taking multiple extra days.

How could we have caught this UI issue earlier? And what could we do to prevent regressions while we refactored the code to fix the problem?

Testing Front-End Applications Is About User Perspective

React child components re-render when there’s a state change in a parent component. In our app, these children were presentational components called StackTraceItems – the individual line items of a stack trace. The parent was StackTrace, the container component at the top level of the hierarchy, where we stored uploaded source maps as state.

Source maps are stored in StackTrace state.

Here was the problem: when a user dragged in the wrong source map, StackTrace stored the file, applied the source map to the minified stack trace, and then confirmed whether or not unminification had been successful. Even if it was not successful, the state change in StackTrace caused StackTraceItems to update as if a correct source map had been uploaded.

Adding insult to injury, all of our tests were passing.

Our tests were passing, all right, but they were all unit tests. All they did was confirm that components rendered properly given certain props, and that user interaction worked as expected. The problem we were facing was that, from a user perspective, the app looked broken.

How to Write Front-end Tests That Save You Time and Anxiety

0. Have the right tools

These are the libraries and tools that allowed us to write all the React tests we wanted:

1. Have a basic set of unit tests for every component

Unit tests are great for testing components in isolation. These tests tell you whether the component renders at all, and that it does X when you click it.

Unit tests should check that:

  • component shallowly renders, given props (Enzyme’s shallow)
  • user interaction works as expected
  • elements that you want to show/hide will appear or disappear depending on props

Keep unit tests basic. And don’t rely on unit tests alone.

2. Add integration tests for all major user flows

Integration tests are necessary for testing actual user experience. After all, your user is going to experience your app as an holistic piece of software, not in the form of isolated components.

If your app is structured to have just one source of truth – where high-level state changes trigger a cascade of updates to lower-level components – it’s easy to test.

Integration tests should:

  • deep-render your components (Enzyme’s mount)
  • call setState on your top-level stateful component to trigger the changes you want to test
  • check for props passed to presentational components, thereby validating what we want the user to see on the page

What clues do you find yourself looking for when you manually test something? What tells you that a code change worked or not? Check for the props behind those visual cues in your integration tests. Your tests should impersonate your user’s eyes.

“Unit testing is great: it’s the best way to see if an algorithm does the right thing every time, or to check our input validation logic, or data transformations, or any other isolated operation. Unit testing is perfect for fundamentals. But front-end code isn’t about manipulating data. It’s about user events and rendering the right views at the right time. Front-ends are about users.” – Toptal.com

3. Don’t leave integration tests until the end

We were about 2/3 of the way through the project when I wrote up our bug-hunt test plan. The team went through the test plan twice in QA bug hunts, where it was easy as pie to find the last remaining bugs and UX fixes.

But that test plan should have doubled as an outline for integration tests right away. In fact, writing the integration tests should have happened at about the same point in the project as coming up with the test plan. That way, all that high-level testing would have been automated for future use, and at a point in the project when we had a good sense of our app’s major user flows and potential pitfalls!

In Summary

Unit tests are a great baseline. But we needed integration tests to allow us to refactor boldly, as well as save us time in manual testing along the way.

Writing tests during a fast-paced project always feels like a roadblock on your journey down the yellow-brick road. But taking that time might be the only way you get back to Kansas all in one piece.