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:
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.
import { JSDOM } from "jsdom";
// Create a JSDOM instance with basic HTMLconst dom = new JSDOM( "<!DOCTYPE html><p id='hello'>Hello world!</p>");
You can access the DOM representation in various ways:
// Serialize the DOM to HTMLconsole.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 browsersdom.window.document
is equivalent todocument
in browser environments
You can manipulate the DOM just as you would in a browser:
// Get an element by IDconst 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.
<!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>
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:
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:
/** * @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.
/** * @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)
- Use
- Define our tests using
test
andexpect
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:
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:
/** * @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 contentgetByRole
: 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.
<!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>
:
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.
/** * @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.
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:
- Finding form elements based on how users would perceive them (using labels and roles)
- Simulating realistic user interactions with
userEvent.type()
anduserEvent.click()
- 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.
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.
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.
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:
- Error Prevention: The application doesn’t create incomplete photo cards when required fields are missing
- 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:
toBeEmptyDOMElement()
checks if the list of photo cards is emptytoBeInTheDocument()
checks if an element is present in the DOM
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:
- Rendering the UI and performing actions
- Taking a “snapshot” of the resulting DOM structure
- Comparing this snapshot against a stored reference version
Let’s implement a test that verifies our application correctly handles multiple photo card submissions:
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:
- Generate a reference snapshot of the
photoCardList
element - Store this snapshot in a
__snapshots__
directory next to your test file - 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.