Test-driven development of... UIs?
Tags: frontend, testing
Published: 2023-10-05 11:56:57

Test-driven development (TDD) is one of my favourite methodologies to write code as it gives me confidence my code meets the specifications and is (mostly) bug free. However, I found it challenging to apply to front-end code, mostly due to a lack of appropriate tools. This blog post documents how I have successfully used TDD for building a UI for a recent project.

## What is test-driven development (TDD)?
Test-driven development is a practice of development where, to boil it down to a single sentence, one writes tests to codify the specification before writing the code to implement the solution. When writing the solution, a developer will typically work entirely within the test runner. As soon as the tests are green, the solution should be correct.

## How does this relate to UIs?
I have had difficulty applying TDD methodologies to UIs because:

- I need to see a UI to be able to develop it. I build UIs for a living and, despite my best efforts, I'm still unable to reason about HTML, CSS, and JavaScript in my head well enough to determine if a component works without being able to poke it in a browser. The output of components is essentially a big mass of HTML; knowing the correct selectors and assertions to use is not trivial.

- Specifications are usually user focused. Specifications for UI components are usually quite fuzzy, such as "it follows the designs" and "clicking causes the user to navigate to the correct place".

- Codifying the "correct" behaviour may not be straightforward. Determine if something is working "correctly" often requires a human's judgement.  For example, the designs might show that two elements are visually 16px apart, but this could be implemented using margin, padding, gap, etc.


## Trying TDD with Jest
[Jest](https://jestjs.io/) is one of the most common frameworks used for testing React apps and is included in [Create React App](https://create-react-app.dev/docs/running-tests/) by default.

![Screenshot of Jest output showing one passing test.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3ouvu4hckqr0n9j8yulh.png)
<figcaption>Screenshot showing Jest output showing a test run summary.

![Screenshot of Jest output showing a failing test. The test is looking for text that is not present in the component. Jest shows the HTML of the component and the failing assertion.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/u6segeyjiofmck8c28iw.png)
<figcaption>Screenshot showing Jest output in the case of failure. Here, Jest outputs the HTML that would be rendered on screen.

Jest is a headless test runner (meaning you can't see the rendered components) and uses an emulation of the browser DOM. This means that:

- Seeing whether the rendered output looks correct is impossible. Writing a test that the rendered output is correct is awfully hard.

- Checking the behaviour is correct is hard when you depend on browser behaviour, such as the address bar, local storage, CSS animations, etc. These must all be mocked out.

- In the case of a failing test, all you will get to debug is the failing assertion and the HTML that would be rendered. Figuring out why something failed can be frustrating and time consuming, particularly if your tests involve multiple user interaction steps.

These factors pushed me away from test-driven development for UIs. Using Jest, I found it too hard to write correct tests before starting development. Even when I managed to write tests beforehand, the tests would often not pass because the tests themselves were wrong, not the code. Trying to work entirely within the test runner was a frustrating endeavour.


## Moving to Cypress component tests

[Cypress](https://www.cypress.io/) is a tool for testing UIs and UI components that includes a test runner than runs in a real browser.

![Screenshot showing Cypress test runner UI. An component showing the text "ExampleComponent" is rendered. It shows there is one passing test.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/yztsfia66obmi2zwsnqa.png)
<figcaption>Screenshot showing Cypress test runner UI. The left-hand panel shows the steps involved in the test while the right-hand panel shows the rendered output.

Although Cypress has typically been a tool for end-to-end testing, they have recently ([v10, released in June 2022](https://docs.cypress.io/guides/references/changelog#10-0-0)) introduced features for component testing. Since it renders and runs in a real browser, in contrast to Jest:

- You can see if the rendered output looks correct which is invaluable when working on styling.

- You can explore the rendered output using the browser dev tools, which is particularly useful in the case of a failing test. The test runner also allows for manual interaction with the rendered component which can be helpful for investigating and debugging.

- If you have a test with multiple state changes (e.g. due to user clicks) you can use the test runner to see a snapshot (i.e. the rendered HTML, not a screenshot) of the component at each test step.

- You can test browser-dependent behaviour is correct, such as interaction with window.location and local storage. (Note, this is only possible in e2e testing mode)

Although this gets us closer to being able to do TDD, it is unfortunately still tricky to write a full set of complete tests before we start writing the code.

## TDD (sort of) for UIs

Even with Cypress, choosing the correct test steps to write without a component in front of you can be difficult. Fortunately, there is something we can do here: we can write the component tests alongside writing the component code. During a recent project, I found success with the following approach:

1. Write placeholder test and component files, such as in the React examples below. Opening this in Cypress will give you something to poke and play around with.

    Example.tsx
    ```typescript
    import React from "react";

    export interface ExampleComponentProps {
      someString: string;
    }
    ```

    Example.cy.tsx
    ```typescript
    it("renders someString prop", () => {
      cy.mount(<ExampleComponent someString="blah" />);
      cy.contains("blah");
    });
    ```

2. Identify a specification and write a test for it. For example, that it displays the `someString` prop:

    ```typescript
    it("renders someString prop", () => {
      cy.mount(<ExampleComponent someString="blah" />);
      cy.contains("blah");
    });
    ```
3. Implement the component code for that specification. While developing, you should be looking at your component output in the test running and you should *manually test to check both the component and the test are working as expected*. You should be checking the correct elements are being selected and interacted with and that any assertions on output are indeed correct.
4. Repeat steps 2 and 3 until the whole specification is implemented. You've now got a comprehensive set of tests for your UI.

## To recap, how does Cypress help with TDD of UIs?
Looking at the three problems I had with TDD of UIs, Cypress can help in the following ways:

- I need to see a UI to be able to develop it. The Cypress test runner shows the rendered output in a real browser.

- Specifications are usually user focused. Manual testing is often required to ensure these specifications are met. Using Cypress, one can manually test their tests and components are working correctly while developing.

- Codifying the "correct" behaviour may not be straightforward. This can still be an issue with Cypress as you still need to choose the correct thing to assert on, which is more an art than a science. However, its test runner makes it easier to construct more complicated assertions, e.g. querying the bounding box of two elements and checking they are the correct distance apart rather than asserting that one of the elements has the correct margin.

## Conclusion / TL;DR
- Test-driven development (TDD) of UIs is hard since it's difficult to write tests for a UI you cannot see. 

- Cypress component testing gives a lot of useful tools over Jest, but it is still difficult to write a complete test suite for a component before starting implementation.

- I have found success with an approach where I develop tests alongside implementation. This allows me to test the tests are correct during development.