Skip To Content
Back to Ponderings

Demystifying Front-End Testing

Demystifying Front-End Testing

Software developers have long recognized the immense value of testing techniques in ensuring the reliability and stability of backend applications. But what about the realm of front-end development?

Throughout my career as a software developer, I have witnessed several testing techniques applied to backend applications. As these applications grew and expanded in complexity, manual testing would have required days of effort. However, the implementation of tests had resulted in significant time savings that had not gone unnoticed, with many developers and testers considering them indispensable.

In front-end projects, I have always wanted to incorporate that same structure that has been successful in these backend frameworks. However, I never quite knew where to start or how to translate it to a front-end environment. Testing that a method or logic returns an expected value seems straightforward, but how do you apply that mentality to UI where the parameters can be dependent on user interactions?

As I explored front-end application testing, I was surprised by how easy it was to implement and even more so by the immediate benefits it brought. This revelation prompted me to document and share my experience, providing a glimpse into what this process entails, the advantages it offers, and an example on how to apply these concepts to a sample component

Why Testing the Front-End Matters

Every change in an application must, at the very least, be tested by a developer (dev tested) to validate that the new and, if applicable, previous functionality work as intended. Frustratingly, if a section of the application is subject to multiple changes, it becomes repetitive having to verify the core functionality every time something changes. Front-end tests are a way of “writing” these dev tests. While there is a small time investment to implement tests, their long-term benefits easily outweigh the initial cost, making them worthwhile.

Adding tests to a front-end application establishes a structure to validate the acceptance criteria of its components. These tests serve as a reliable tool for developers to verify that changes do not inadvertently affect the components’ intended behavior. Ensuring the front-end application works as intended is crucial because it is the part users interact with directly. Any bugs, even minor ones, can be easily detected by users and can impact their experience negatively.

What To Test Against

A component is created to serve a purpose in the application, whether purely visual or encapsulating some functionality. The component will ultimately be rendered to the screen where a user will interact with it in some way. Front-end tests aim to validate that the user sees the correct behavior as they view or use that component.

Because of this, the test should focus on the behavior of the component and not around its implementation details. This means that our test should not care how the data being displayed is being tracked or that an internal object contains a particular property, we should care that the user interactions around a component are working as intended. It may be a bit confusing, but think about it this way – a refactoring in your implementation should not affect the component’s UI behavior. In a React environment, changing the data source from using state to using props should not affect how the component behaves on the screen.

Since the test should be focused on behavior and not implementation, we need a way to validate these UI interactions. For this, our tests will need to access the DOM structure itself to validate these interactions. Luckily, many testing packages are included by default in some front-end frameworks specializing in this.

Types of Front-End Tests & When to Use Them

The following are two different procedures of unit testing that we can follow when writing tests:

  • Snapshot testing – This consists of having the test framework render a component, destructuring what the component would look like in the HTML, and saving a snapshot of it at that particular point in time if none exists. Future tests will then compare subsequent snapshots against the saved one and point out any differences between both. This type of testing is useful when we want to validate that the component doesn’t change unexpectedly in the way it renders to the screen. Snapshots should be committed to the repository.
  • DOM/End-to-End testing – These types of tests aim to mock a user interaction with a component/workflow and assert that the outcomes are what we expect. With these, we’ll have more detailed assertions for the state of elements and data. For example, these could be used to verify that, when clicking on a submit button in a form, data is submitted to an endpoint and that the page state changes to notify a user their data was successfully entered.

Determining what test to use comes down to how extensive the tests need to be. Snapshots are quicker to create; however, they have the fallback that any change in how the HTML structure renders, whether purposefully or by accident, will cause that test to fail. Snapshot tests don’t test for functionality, so developers are responsible for validating that the snapshot generated looks as intended every time a new snapshot is generated.

DOM testing requires more time to write because of having to transform the user actions into code and assert that the component is displaying the correct information. However, unless changes are being made to the structure directly, these tests should not be affected by modifications made to the component’s implementation.

Because of this, I would recommend snapshot tests for components that are small in functionality, UI exclusive, or validate that the component renders correctly; for everything else, I would recommend DOM testing.

Writing Tests For A Small Component

In the example below, we will be using a react environment created by the create-react-app NPM package. The created app will include Jest, which is a JavaScript framework used for creating and running tests. We will also use some of these additional packages:

  • Jest-dom – While Jest has matchers to validate data, it doesn’t have a good way to validate DOM elements and their presence/values in a document. Jest-dom is an extension that adds these.
  • React Testing Library – We’ll be using the React Testing Library to validate our test cases as this one is specialized to work with react components.
  • User Event – We’re using this over React Testing Library’s built-in fire event. One of the main differences is that fire event triggers an elements event while a user event simulates an actual interaction. For example, triggering a click using fire event would only dispatch the onClick event of a specific element. In contrast, using user-event we can actually simulate a user click on the element itself, which could potentially trigger more than one event depending on how the HTML is structured.

The following is the component that we will be adding DOM testing to:

import { useState } from "react";
 
export default function InputTable() {
 
  const [inputValue, setInputValue] = useState("");
  const [tableData, setTableData] = useState([]);
 
  const handleInputChange = (event) => {
    setInputValue(event.target.value);
  };
 
  const addNewItem = () => {
    setTableData([...tableData, inputValue]);
    setInputValue("");
  };
 
  return (
    <>
      <input value={inputValue} onChange={handleInputChange} />
      <button onClick={addNewItem}>Add Item</button>
      
      <table>
        <tbody>
          {tableData.map((data) => (
            <tr role="row">
              <td name={data}>{data}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </>
  );
} 

 

Here’s a visual representation of how the component looks (I added some custom CSS to better visualize the table):

Demystifying Front-End TestingThe component consists of a list of elements that get displayed to the view. The list is initially empty, but a user can add elements to the list by entering characters in the input box and clicking the add item button. All these values are being tracked by internal state. We’ll want to make sure that we are testing the key functionality of this component, so adding tests for verifying the following should be the minimum:

  • When the button is clicked, and the input contains a value, the value is added to the list and rendered to the screen.
  • When the button is clicked, and no value exists in the input, nothing is added to the list.
  • When the button is clicked, the input value is cleared.

Now, notice that regardless of the method, the above functionality is something that a developer would be expected to test before committing this new component.

Setting The Test Structure

Setting the structure using Jest is pretty straightforward:

// input-table.test.js

describe('Input Table', () => {

    test('When the button is clicked and the input contains a value that the value is added to the list and rendered to the screen', async () => {
        
    });

    test('When the button is clicked and no value exists in the input that nothing is added to the list', async () => {

    });


    test('When the button is clicked that the input value is cleared', () => {

    });
});

 

Jest provides us with different global methods that we can use for our test, in our particular case we’ll be using the describe and test methods. The describe method serves as a way to group tests related to a common piece of functionality; in this case, these are the tests related to the input table component. The test method is where all test operations should be placed. Both of these methods take in a string as their first parameter, which serves as the identifier for that group/operation and which is visible in the test output. This makes it easier to identify where a test is located in case of a failure. Once we have our structure set up, we use the React library to render our component programmatically.

How To Write A Test

The main idea of how to approach writing tests is that they should simulate as closely as possible how a user would interact with a particular component and then validate that the user sees the correct output. In our case, we need to be able to simulate a click on the input box, a user typing characters into the input box, and a click on the button. Here’s what that looks like as a test:

import { render, screen } from "@testing-library/react";
import userEvent from '@testing-library/user-event';
import InputTable from "./input-table";

test('When the button is clicked and the input contains a value that the value is added to the list and rendered to the screen ', async () => {

    // Create the user instance 
    const user = userEvent.setup();
    const userInput = 'Hello!';

    // Render the component 
    render(<InputTable />)

    // Find the input element and simulate typing of our characters  
    // using the user instance 
    await user.type(screen.getByRole('textbox'), userInput);

    // Find the button element and simulate a click 
    await user.click(screen.getByRole('button'));

    // Validate the characters we typed are on the screen 
    expect(screen.getByRole('cell', { name: userInput })).toBeInTheDocument();
});

 

Here is what we’re doing through the test. We are first creating a user instance that we’ll use to simulate user input. From there, we render the component into the DOM by using the render method from the testing library. Once the component is rendered, we can now interact with it. The screen object from the testing library, which is a reference to the main container where our component is rendered on, allows us to find the components elements based on different criteria. In our case, we found the elements by their aria role, but the testing library supports multiple other ways. From here on, we just think of the steps we would take as if interacting with the element and, with the help of the user instance, simulate the typing and clicking of the buttons.

Once the user interactions are done, we test that the output we are expecting is “on” the screen. For this, we use the expect method, which helps us validate whatever parameters we pass inside. In our case, we want to validate that the cell that would contain the value we entered is in the document. After we are content writing the test, we need to run the test command. Since we are using the create-react-app package to create the application, the test command should be available to us. We just need to run npm run test, and the following output should display:

Notice that the strings in the describe and tests methods are visible in the output, which makes it easier to understand. In this case, since the other three tests do not have anything within the element, they pass automatically. The good thing is that sometimes writing the first test makes the other tests easier to write since the most time-consuming effort is being able to reference the elements correctly. Let’s write the second test using some of the code already from the first one:

test('When the button is clicked and no value exists in the input that nothing is added to the list', async () => {

    // Create the user instance 
    const user = userEvent.setup();

    // Render the component 
    render(<InputTable />)

    // Find the button element and simulate a click 
    await user.click(screen.getByRole('button'));

    // Validate that no cells were added to the screen 
    expect(screen.queryAllByRole('cell')).toHaveLength(0);
});

 

As seen below, we borrowed most of the code from the first test into the second, with a slight modification on what we are validating. If we run the test, we get the following:

That is because our code for adding new items to the list:

const addNewItem = () => { 
    setTableData([...tableData, inputValue]); 
    setInputValue(""); 
};

 

Is not doing any input validation. If we modify that to include only non-empty strings:

const addNewItem = () => {
    if (inputValue) {
        setTableData([...tableData, inputValue]);
        setInputValue("");
    }
};

 

Running the tests once more, we get:

Final Thoughts

Testing serves as a valuable tool to enhance the quality of the front-end in applications, while also enabling the early detection of errors. It plays a crucial role in ensuring that the user interface operates seamlessly and offers a user-friendly experience. By identifying and addressing issues earlier on, developers can swiftly resolve issues, ultimately ensuring a top-notch user experience. The lessons in this paper scratch the surface on front-end, however I hope that it at least clears any doubts on how to write and approach them and gives you the tools necessary to incorporate them in any project you’re working on.

Be a Fly On the Wall Subscribe to our newsletter, Metamorphosis, and get a leap ahead of your competitors through guest contributed articles, white papers, and company news.

We don't support Internet Explorer

Please use Chrome, Safari, Firefox, or Edge to view this site.