Test-Driven Development in the Modern Frontend

Christian Bouwense
Bigeye
Published in
7 min readAug 26, 2022

Test-Driven Development (TDD) is a discipline wherein automated tests are written before implementing production code. Kent Beck, the founder of this core Agile practice, put it poetically when he wrote

[you] can find in TDD a way to do well by doing good.

TDD’s relevance follows a predictable cycle. Peaks of roaring popularity are followed by valleys of developers wondering if it died. An area of software engineering with a particular disinterest towards test-driven development is the frontend community. As far as software goes, UI specifications tend to be the softest. Who wants to write and maintain tests that may need to change?

UIs developed in a test-driven manner enable quicker changes and confident deployments. This article will attempt to demonstrate how.

Toss, Tug, Traverse

Test-driven development follows the simple rhythm of “red, green, refactor”. Write a test for your desired behavior, hack together simple code that makes it pass, and finally refactor your implementation into production-ready code.

While the mantra “red, green, refactor” is ubiquitous, I prefer the rephrasing “toss, tug, traverse”. As demonstrated by this famous scene from Star Wars, Luke tosses his grappling hook onto a ceiling fixture, tugs on it to make sure it will hold, and finally traverses the gap. In TDD, we

  1. toss out a test for a behavior in our specification
  2. give it a tug by making it pass with simple code
  3. traverse refactoring confidently

Motivation

Consider a humble counter. It is discovered the buttons do not change the value, and it’s your job to fix it.

Counter.tsx

Dependencies

Jest will act as the testing framework and React the UI library. Simulation of rendering will be done with React Testing Library, and user interactions with Testing Library’s User Event. Keep in mind, however, the principles covered in this article are not specific to React. Testing Library’s guiding principle is testing behavior over implementation, so these testing patterns will work with Angular, Svelte, or even plain HTML.

yarn add --dev jest @testing-library/react @testing-library/user-event

Red (Toss)

The first law of TDD states

You are not allowed to write any production code unless it is to make a failing unit test pass

so we will start by writing a failing test.

Create a file named Counter.test.tsx. Begin by asking “what should this component do?” When a user clicks the increment button it should update the counter value by one. Create a test hierarchy from this answer, using describe blocks for context and it blocks for expectations.

A good testing pattern to follow is “arrange, act, assert.” Our tests will implement each step as follows

  1. Arrange: simulate rendering the Counter component
  2. Act: simulate a user clicking on the desired button
  3. Assert: check that the expect behavior is present

Running our test with yarn run jest --watch yields the following failure:

The second law of TDD dictates

You are not allowed to write any more of a unit test than is sufficient to fail, and compilation failures are failures.

Mission accomplished. Our hook is set.

Green (Tug)

Our test written, we now set our sights on making it pass. First, fix the compilation errors by importing all dependencies in the test file:

Jest was run in watch mode, so upon saving these changes the following output appears:

When our simulated user clicked the increment button, jest expected the value to be 1, but instead found 0. This is precisely the bug we are fixing. Now let’s give our test a tug.

You may protest, rightly pointing out that this implementation is absurd! Our button will only ever increase the value to 1.

Remember, we are not supposed to be implementing the feature right now. We’re just tugging.

The goal is to ensure our test passes when it is undeniable that it should. Our tug must be so absurdly specific that a correct test has no choice but to pass. Kent Beck’s tugging advice is to “do the simplest thing that could possibly work.”

The tug was a success. It is advisable to add a commit to version control once the test turns green. This ensures a safe point to revert back to if refactoring gets too crazy.

Refactor (Traverse)

The third law of TDD states

You are not allowed to write any more production code than is sufficient to pass the one failing unit test.

This does not mean we cannot change our current implementation. Rather, while refactoring, new behaviors should not be implemented. Our behavior under test is clicking the increment button a single time; no more, no less. Only cross the gap you have prepared to traverse.

Instead of conditionally rendering two different paragraph tags, we can simply decide which number to display. We can also extract our handler.

With those small changes, refactoring is complete. Amend your commit, or create a new one, with every refactor. In this way, each commit will contain an implementation becoming more robust, easier to read, and closer to production quality.

Expanding Functionality

In this rhythm, we gradually increase the scope of our application’s behavior. To more fully capture the specification’s functionality, another failing test must be written.

First a test for one click, and now for three. You may be wondering if a test for every valid number is necessary. Thankfully not; a good pattern to follow is “none, one, some.” This contrived example assumes there was a test for zero clicks (“none”) before we started. Our first test expected behavior with “one” click, and this second test will cover “some” clicks.

This pattern should be familiar to anyone experienced with inductive proofs. Prove a conjecture’s base case (“none” or “one”), and then provide an inductive step (“some”).

Another state hook, perhaps named incrementWasClickedThreeTimes, seems clunky at best. Our component will need to juggle two flags and contain some nested conditional logic:

{incrementWasClicked ? incrementWasClickedThreeTimes ? 3 : 1 : 0}

Not quite “the simplest thing that could possibly work.” Replacing the incrementWasClicked flag with state representing the counter’s value would now be the easiest way to give our test a tug.

Jest re-runs our tests on save, and we are back to green.

Astute React developers will notice we have a subtle race condition in our handler. We are relying on the mutable state of value which is not guaranteed. Supply the setter with a callback to make it atomic.

The tests are still green. Amend the commit. By taking small, almost trivial steps our implementation is the simplest possible solution that satisfies our specification.

In the pursuit of brevity, I will add a similar test and implementation for the decrement button without demonstration.

Tests that inspire confidence

The bug has been fixed and the patch is live. All is well. You open your news feed and read a headline entitled “React team deprecates useState, urges pivot to useReducer.” Not to worry, your tests are in place, and you can rely on them to guide you through a refactor. You gave them each a tug, after all.

Reading up on the useReducer docs, you find an example of a counter. Copy/paste their demo, add some types, and wire it into the markup.

Without a suite of tests, one would have to analyze every line of this new code, mentally simulating what it might do. With our suite of tests, simply check the jest output.

Our tests have eliminated fear of regression and inspired confidence.

Our question “who wants to write and maintain tests that may need to change” can now be understood as a mistake in perspective. Yes, tests may need to be tweaked with evolving specs, but a there is a more important question.

Who wants to write and maintain untested code that will change?

--

--

Christian Bouwense
Bigeye
Editor for

Software engineer, deadlift enthusiast, soapmaker.