Skip to content

UI-Based Integration Testing

Integration testing plays a crucial role when developing UI-based applications, bridging the gap between unit tests and full end-to-end tests. We explore how to effectively implement integration tests for web applications.

While we’ve previously focused on testing library code through a developer’s perspective, UI-based applications (web and mobile apps) require a different testing approach. The key difference is in how we interact with the software:

  • Library testing: Focuses on developer-facing APIs
  • UI application testing: Must simulate end-user interactions like looking, reading, scrolling, clicking, and typing

For UI-based applications, our tests need to mimic how real users interact with the interface. This requires special tooling to properly simulate user behaviors like:

  • Visual inspection and reading content
  • Navigation through scrolling
  • Clicking on buttons and interactive elements
  • Typing text into forms
  • Responding to application feedback

One approach is using end-to-end testing tools like Playwright or Cypress, which simulate a real browser environment. However, these tools can be slower and more complex to set up. They can also break when the UI changes, leading to brittle tests.

While there are solutions such as Storybook and Chromatic that can help with visual testing, we’ll focus on a more lightweight “integration testing” approach that:

  • Focuses on validating specific UI components (forms, buttons, interactive elements)
  • Tests interactions between the UI itself and its associated behavior code
  • Verifies specific outcomes (like a dialog opening when a button is clicked)

These tests are broader than unit tests because they validate interactions between different parts of the application, yet more focused than full end-to-end tests.

In this lecture, we’ll integration testing specifically for client-side web applications, using testing based on general UI testing principles. We’ll leverage Vitest and Testing Library to create our tests, which will be run in a browser-like environment using jsdom. This allows us to simulate user interactions and validate the expected outcomes. These tests don’t rely on a real browser, nor do they render the CSS styles, but they do provide a good approximation of how the UI behaves in a real environment.

Setting Up jsdom

To effectively test UI components, we need to simulate how users interact with our application’s interface. While these interactions typically occur in a web browser, using an actual browser for testing can be inefficient and cumbersome.

jsdom offers a lightweight alternative to browser-based testing. As a JavaScript implementation of web standards (including WHATWG DOM and HTML standards), jsdom can:

  • Parse and “render” HTML into an in-memory DOM tree
  • Provide the same programmatic DOM access mechanisms as a browser
  • Support testing without the overhead of a full browser environment

Unlike browsers, jsdom doesn’t create visual representations of HTML. Instead, it builds the equivalent DOM structure that allows us to interact with elements programmatically.

Let’s explore how to use jsdom in your testing workflow. Install it as a development dependency in your project:

Terminal window
npm install --save-dev jsdom

Then create a simple test file to demonstrate how to use jsdom. In this example, we’ll create a basic HTML structure and manipulate it using jsdom.

jsdomExperiment.js
import { JSDOM } from "jsdom";
// Create a JSDOM instance with basic HTML
const dom = new JSDOM(
"<!DOCTYPE html><p id='hello'>Hello world!</p>"
);

You can access the DOM representation in various ways:

jsdomExperiment.js
// Serialize the DOM to HTML
console.log(dom.serialize());

When you run dom.serialize(), jsdom outputs a complete HTML document, including automatically added elements:

<!DOCTYPE html>
<html>
<head></head>
<body>
<p id="hello">Hello world!</p>
</body>
</html>

Notice how jsdom automatically adds the <html>, <head>, and <body> elements that would be implied in a browser environment.

jsdom provides the same DOM interface as browsers through dom.window:

  • dom.window corresponds to the global window object in browsers
  • dom.window.document is equivalent to document in browser environments

You can manipulate the DOM just as you would in a browser:

jsdomExperiment.js
// Get an element by ID
const paragraph = dom.window.document.getElementById("hello");
console.log(paragraph.textContent); // "Hello world!"

With these fundamentals in place, we can now adapt jsdom for testing client-side web applications with a few minor adjustments to this basic setup.

Loading and Rendering an Application for Testing

When testing web applications, we often need to work with multiple files that comprise the complete application. Let’s explore how to effectively load and test a simple counter application consisting of HTML and JavaScript files.

counter.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Counter</title>
<script type="module" src="counter.js" defer></script>
</head>
<body>
<button id="counter">0</button>
</body>
</html>
counter.js
export function setupCounter() {
const counter = document.getElementById("counter");
let count = 0;
counter.addEventListener("click", () => {
count++;
counter.textContent = count;
});
}
document.addEventListener("DOMContentLoaded", setupCounter);

This application implements a simple button that displays a count starting at 0 and increments by 1 each time it’s clicked.

To properly test this application, we need to:

  • Load the HTML into JSDOM
  • Execute the JavaScript file against the DOM
  • Ensure each test starts with a fresh DOM state

Make sure Vitest is installed in your project as a development dependency:

Terminal window
npm install --save-dev vitest

Let’s create a testing file called counter.test.js in the same directory as our application files. At the top of our test file, specify that we want to use the jsdom environment:

counter.test.js
/**
* @vitest-environment jsdom
*/

This docblock comment configures Vitest to use jsdom for this specific test file. Alternatively, you could set this globally in your Vitest configuration. There are other types of environments available such as happy-dom, node, or edge-runtime.

Next, we want to load our HTML file into jsdom. We want to do this before each test runs, using beforeEach, ensuring we start each test with a fresh DOM state. We can use the path and fs modules to read the file and pass its contents to jsdom. Once it’s loaded, we can run our JS script, then run our tests.

counter.test.js
/**
* @vitest-environment jsdom
*/
import fs from "fs";
import path from "path";
import { describe, test, expect, beforeEach } from "vitest";
import { setupCounter } from "./counter.js";
describe("Counter Application", () => {
beforeEach(() => {
const htmlPath = path.resolve(__dirname, "./counter.html");
const htmlContent = fs.readFileSync(htmlPath, "utf-8");
document.body.innerHTML = htmlContent;
setupCounter();
});
test("should start with initial count of 0", () => {
const counterButton = document.getElementById("counter");
expect(counterButton.textContent).toBe("0");
});
test("should increment count when clicked", async () => {
const counterButton = document.getElementById("counter");
counterButton.click();
expect(counterButton.textContent).toBe("1");
counterButton.click();
expect(counterButton.textContent).toBe("2");
});
});

In our test file, we:

  • Import the necessary modules from path, fs, Vitest, and our application code
  • Use beforeEach to load the HTML file and set up the counter function before each test
    • Use path.resolve to get the absolute path of the HTML file
    • Use fs.readFileSync to return the contents of the path
    • Use document.body.innerHTML to set the HTML content of the DOM (using jsdom)
    • Call setupCounter() to initialize the event listeners and functionality (limitations of jsdom and Vitest/Jest require this)
  • Define our tests using test and expect to assert the expected behavior of the application

Note that we don’t have to explicitly import jsdom, as Vitest automatically sets it up for us when we specify the environment, but jsdom still needs to be installed as a dependency in your project.

While this works, we are using the document interface to manipulate the DOM directly. This is not how a real user would interact with the application. Instead, we should use a library that simulates user interactions more closely.

Using Testing Library for User Interactions

The Testing Library family of tools offers a more user-centric approach to testing UI interactions compared to direct DOM manipulation. Instead of focusing on implementation details, these libraries help you write tests that interact with your application the way real users would.

The Testing Library consists of several packages:

  • @testing-library/dom: The core library that works with any web application
  • @testing-library/user-event: Simulates realistic user interactions
  • @testing-library/jest-dom/vitest: Provides custom matchers for assertions
  • Framework-specific adapters: For React, Vue, Angular, and others

Let’s implement this approach for our counter application:

Terminal window
npm install --save-dev @testing-library/dom @testing-library/user-event @testing-library/jest-dom vitest

Now you can use the Testing Library to write your tests. Here’s how to modify our previous test file to use the Testing Library:

counter.test.js
/**
* @vitest-environment jsdom
*/
import fs from "fs";
import path from "path";
import { describe, test, expect, beforeEach } from "vitest";
import { setupCounter } from "./counter.js";
import { getByText } from "@testing-library/dom";
import "@testing-library/jest-dom/vitest";
import { userEvent } from "@testing-library/user-event";
describe("Counter Application", () => {
beforeEach(() => {
const htmlPath = path.resolve(__dirname, "./counter.html");
const htmlContent = fs.readFileSync(htmlPath, "utf-8");
document.body.innerHTML = htmlContent;
setupCounter();
});
test("counter increments when clicked", async () => {
const counter = getByText(document.body, "0");
await userEvent.click(counter);
expect(counter).toHaveTextContent("1");
});
});

Testing Library provides multiple ways to find elements, simulating how real users locate elements:

  • getByText: Finds an element by its text content
  • getByRole: Finds an element by its ARIA role (e.g., button, heading)

Alternatively, you can import { screen } from "@testing-library/dom"; and use screen.getByText or screen.getByRole to find elements.

The userEvent library simulates user interactions like clicking, typing, and more. You can use const user = userEvent.setup(); to create a user instance and call await user.click(counter).

Using await is important as these interactions are asynchronous, ensuring all events complete before assertions run.

When a real user clicks an element, it triggers multiple events in sequence:

  • mouseOver
  • mouseMove
  • mouseDown
  • focus (if focusable)
  • mouseUp
  • click

Finally, Testing Library encourages assertions based on what users would see:

expect(counter).toHaveTextContent('1');

The custom matcher toHaveTextContent() provided by @testing-library/jest-dom/vitest verifies text content the way a user would see it.

By focusing on user-oriented queries, interactions, and assertions, Testing Library helps create more resilient tests that validate behavior rather than implementation details.

Testing a More Complex Application

Let’s explore how the DOM Testing Library works with a more complex example than our simple counter application.

We’ll test an application that adds new content to the DOM based on user input. Specifically, this app allows users to input a photo URL and caption, then displays that photo in a “card” element in the DOM.

Here’s the HTML structure of our application. Note that we are using tailwindcss to style the page with classes instead of relying on a separate CSS file.

photos.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Photos</title>
<script type="module" src="photos.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
</head>
<body class="bg-gray-50 min-h-screen">
<div class="max-w-md mx-auto bg-white rounded-xl shadow-md p-6 m-6">
<h1 class="text-2xl font-bold text-gray-800 mb-6">Photo Gallery</h1>
<form id="add-photo-form" class="mb-6">
<div class="flex flex-col mb-6">
<label class="font-medium text-gray-700">
Photo URL
<input
id="url"
name="url"
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
placeholder="https://example.com/image.jpg"
/>
</label>
</div>
<div class="flex flex-col mb-6">
<label class="font-medium text-gray-700">
Caption
<input
id="caption"
name="caption"
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
placeholder="Enter a caption"
/>
</label>
</div>
<button
type="submit"
class="w-full flex justify-center py-2 px-4 mb-6 border border-transparent rounded-md shadow-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
>
Add photo
</button>
</form>
<ul id="photo-card-list" class="flex flex-col gap-6"></ul>
</div>
</body>
</html>

The HTML consists of two main parts:

  • A form where users enter a URL and caption
  • An empty unordered list where photo cards will be added

The JavaScript code is linked in the HTML’s <head>:

photos.js
function insertNewPhotoCard(url, caption) {
const photoItem = document.createElement("li");
photoItem.classList.add("justify-items-center");
const photoImg = document.createElement("img");
photoImg.src = url;
photoImg.alt = caption;
const captionP = document.createElement("p");
captionP.textContent = caption;
photoItem.append(photoImg, captionP);
const photoContainer = document.getElementById("photo-card-list");
photoContainer.appendChild(photoItem);
}
export function setupPhotoForm() {
const form = document.getElementById("add-photo-form");
const urlInput = document.getElementById("url");
const captionInput = document.getElementById("caption");
form.addEventListener("submit", function (event) {
event.preventDefault();
const url = urlInput.value;
const caption = captionInput.value;
if (url && caption) {
insertNewPhotoCard(url, caption);
urlInput.value = "";
captionInput.value = "";
}
});
}
document.addEventListener("DOMContentLoaded", setupPhotoForm);

This JavaScript attaches a submit listener to the form that:

  • Prevents the default form submission
  • Retrieves the URL and caption values
  • Calls insertNewPhotoCard() if both fields have values
  • Clears the input fields after submission

The insertNewPhotoCard() function creates a new list item with an image and caption, then appends it to the photo card list.

Finally, we can setup our test file in a very similar way to the previous example. We will use the same beforeEach setup to load the HTML and run the JavaScript code.

photos.test.js
/**
* @vitest-environment jsdom
*/
import fs from "fs";
import path from "path";
import { describe, test, expect, beforeEach } from "vitest";
import { screen } from "@testing-library/dom";
import "@testing-library/jest-dom/vitest";
import { userEvent } from "@testing-library/user-event";
import { setupPhotoForm } from "./photos.js";
describe("Photos Application", () => {
beforeEach(() => {
const htmlPath = path.resolve(__dirname, "./photos.html");
const htmlContent = fs.readFileSync(htmlPath, "utf-8");
document.body.innerHTML = htmlContent;
setupPhotoForm();
});
// ... write tests here
});

We are now ready to write our tests.

Testing New DOM Content Creation

Let’s create our first test for the photos application by verifying that the application correctly generates and inserts a photo card when a user submits a photo URL and caption.

photos.test.js
test("should insert a new photo card when the form is submitted", async () => {
const urlInput = screen.getByLabelText(/url/i);
const captionInput = screen.getByLabelText(/caption/i);
const submitButton = screen.getByRole("button", { name: /add photo/i });
await userEvent.type(urlInput, "https://picsum.photos/200/300");
await userEvent.type(captionInput, "Lorem Picsum");
await userEvent.click(submitButton);
const addedPhoto = screen.getByRole("img", { name: "Lorem Picsum" });
expect(addedPhoto).toBeVisible();
expect(addedPhoto).toHaveAttribute("src", "https://picsum.photos/200/300");
expect(addedPhoto).toHaveAttribute("alt", "Lorem Picsum");
const caption = screen.getByText("Lorem Picsum");
expect(caption).toBeVisible();
});

This test follows a user-centric approach by:

  1. Finding form elements based on how users would perceive them (using labels and roles)
  2. Simulating realistic user interactions with userEvent.type() and userEvent.click()
  3. Making assertions about what a user would see after submitting the form

Note that we’re again using await since user interactions are asynchronous operations consisting of multiple events. The screen object provides convenient access to query functions without needing to specify document as the search context.

While it may seem that we’re testing implementation wit the toHaveAttribute() matcher, we’re actually verifying the expected behavior of the application. The test checks that the photo card is created with the correct attributes and is visible to the user.

toBeVisible() is another custom matcher which checks if the element is visible in the DOM. This is important because an element may exist in the DOM but not be visible to users (e.g., display:none).

We could also check that the photo card is appended to the list of cards by checking the number of children in the list.

photos.test.js
const photoCardList = screen.getByRole("list");
expect(photoCardList).not.toBeEmptyDOMElement();
const photoCards = screen.queryAllByRole("listitem", { container: photoCardList });
expect(photoCards).toHaveLength(1);

This is not a user-centric approach. Instead, we should focus on what the user sees and interacts with.

Testing Correct Resetting of Input Fields

Let’s verify another important behavior of our photo application: ensuring that form fields are properly cleared after a successful submission. This improves the user experience by preparing the form for the next entry without requiring manual clearing.

photos.test.js
test("should clear form fields after successful submission", async () => {
const urlInput = screen.getByLabelText(/url/i);
const captionInput = screen.getByLabelText(/caption/i);
const submitButton = screen.getByRole("button", { name: /add photo/i });
await userEvent.type(urlInput, "https://picsum.photos/200/300");
await userEvent.type(captionInput, "Lorem Picsum");
await userEvent.click(submitButton);
expect(urlInput).toHaveDisplayValue("");
expect(captionInput).toHaveDisplayValue("");
});

We’re using toHaveDisplayValue() to check that the input fields are empty after submission. This matcher verifies the current value of the input field, ensuring it matches the expected empty state.

Testing Empty Form Submission

Let’s verify that our photo application correctly handles form submissions with incomplete data. The application should prevent adding new photo cards when required fields are missing, and it should preserve user input for correction rather than clearing the form.

photos.test.js
test("should not add photo card or clear form when missing url", async () => {
const captionInput = screen.getByLabelText(/caption/i);
const submitButton = screen.getByRole("button", { name: /add photo/i });
const photoCardList = screen.getByRole("list");
await userEvent.type(captionInput, "Lonely Caption :(");
await userEvent.click(submitButton);
expect(photoCardList).toBeEmptyDOMElement();
expect(captionInput).toHaveDisplayValue("Lonely Caption :(");
});
test("should not add photo card or clear form when missing caption", async () => {
const urlInput = screen.getByLabelText(/url/i);
const submitButton = screen.getByRole("button", { name: /add photo/i });
await userEvent.type(urlInput, "https://picsum.photos/200/300");
await userEvent.click(submitButton);
const potentialImage = screen.queryByRole("img", {
src: "https://picsum.photos/200/300",
});
expect(potentialImage).not.toBeInTheDocument();
expect(urlInput).toHaveDisplayValue("https://picsum.photos/200/300");
});

Note the use of queryByRole() instead of getByRole() for the potential image. getByRole() assumes the element exists and fails if it doesn’t, while queryByRole() is more flexible, returning null for non-existent elements. Use getByRole() for required elements and queryByRole() when checking for presence or absence.

This test validates two important user experience considerations:

  1. Error Prevention: The application doesn’t create incomplete photo cards when required fields are missing
  2. User Input Preservation: Form values are retained when submission fails, allowing users to correct their input without retyping everything

We test both possible scenarios (missing URL and missing caption) to ensure consistent behavior regardless of which field is incomplete.

In the tests, we illustrate different ways of checking for the presence or absence of elements:

Both somewhat rely on understanding the implementation details of the application, but they are still user-centric assertions. The tests focus on the expected behavior from a user’s perspective, ensuring that the application behaves as intended.

The second one is does nop rely on knowing that we have a list that encapsulates the photo cards. It simply checks that the image is not present in the DOM.

Snapshots

When testing applications that produce complex DOM structures, validating each element individually can become cumbersome. Snapshot testing offers an elegant solution for validating complex UI states after user interactions.

Snapshot testing works by:

  1. Rendering the UI and performing actions
  2. Taking a “snapshot” of the resulting DOM structure
  3. Comparing this snapshot against a stored reference version

Let’s implement a test that verifies our application correctly handles multiple photo card submissions:

photos.test.js
test("should insert multiple photo cards when submitted sequentially", async () => {
const urlInput = screen.getByLabelText(/url/i);
const captionInput = screen.getByLabelText(/caption/i);
const submitButton = screen.getByRole("button", { name: /add photo/i });
const photoCardList = screen.getByRole("list");
await userEvent.type(urlInput, "https://picsum.photos/200/300");
await userEvent.type(captionInput, "Lorem Picsum 200x300");
await userEvent.click(submitButton);
await userEvent.type(urlInput, "https://picsum.photos/300/400");
await userEvent.type(captionInput, "Lorem Picsum 300x400");
await userEvent.click(submitButton);
expect(photoCardList).toMatchSnapshot();
});

The first time you run this test, Vitest will:

  1. Generate a reference snapshot of the photoCardList element
  2. Store this snapshot in a __snapshots__ directory next to your test file
  3. Automatically pass the test (since there’s no previous snapshot to compare against)

The snapshot file contains an HTML-like representation of your DOM structure and should be committed to your repository as part of your test code.

In subsequent test runs, Vitest compares the current DOM state against this stored reference snapshot. Any differences will cause the test to fail, with Vitest highlighting the specific changes.

When you intentionally change your UI (like modifying the photo card layout), tests will fail because the DOM no longer matches the stored snapshot. You have two options to update your snapshot:

  • In watch mode, you can press the u key in the terminal to update the failed snapshot directly.
  • You can use the --update or -u flag when running Vitest to update all snapshots at once: npx vitest -u

Be cautious when updating snapshots—make sure tests aren’t failing due to actual bugs before regenerating reference snapshots.

These snapshot tests provide a powerful way to verify complex UI states without writing dozens of individual assertions.

Other Tests

While our test suite covers the core functionality, you might consider additional tests:

  • Different combinations of missing form fields
  • Form submission via Enter key in input fields
  • UI behavior with extremely long captions or URLs
  • Error handling for invalid image URLs

Summary

In this lecture, we explored how to effectively test UI-based applications using integration testing principles. We learned how to set up jsdom for testing, load HTML and JavaScript files, and simulate user interactions with the Testing Library.

We also discussed the importance of user-centric testing, focusing on how real users interact with the application rather than implementation details. By using tools like the Testing Library and Vitest, we can create robust tests that validate our application’s behavior and ensure a smooth user experience.