JavaScript Testing Library: DOM Testing
Testing Library exists because most frontend tests are written wrong. They test implementation details—internal state, component methods, CSS classes—that users never see or care about. When you...
Key Insights
- Testing Library’s philosophy centers on testing user behavior rather than implementation details, making your tests more resilient to refactoring while ensuring real functionality works
- Query priority matters: prefer
getByRoleandgetByLabelTextovergetByTestIdto write tests that double as accessibility audits - Use
@testing-library/user-eventoverfireEventfor realistic interaction simulation—it handles focus, keyboard events, and timing like a real user would
Introduction to Testing Library Philosophy
Testing Library exists because most frontend tests are written wrong. They test implementation details—internal state, component methods, CSS classes—that users never see or care about. When you refactor your code, these tests break even though the application still works perfectly.
The guiding principle is simple: test your software the way users actually use it. Users don’t check if a React component’s internal state changed. They click buttons, fill forms, and read text on screen. Your tests should do the same.
DOM testing matters because the DOM is the contract between your code and your users. If a button renders correctly and responds to clicks, your users are happy. They don’t care if you’re using React, Vue, or vanilla JavaScript under the hood. Testing Library embraces this reality by providing utilities that work with any framework—or no framework at all.
Setting Up Your Testing Environment
Getting started requires minimal configuration. Install the core package and the Jest DOM matchers for better assertions:
npm install --save-dev @testing-library/dom @testing-library/jest-dom @testing-library/user-event
If you’re using Vitest (which I recommend for modern projects), your setup file looks like this:
// vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./test/setup.js'],
globals: true,
},
});
// test/setup.js
import '@testing-library/jest-dom';
For Jest users, the configuration is similar:
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['./test/setup.js'],
};
Now write your first test to verify everything works:
// button.test.js
import { screen } from '@testing-library/dom';
function renderButton(label) {
document.body.innerHTML = `
<button type="button">${label}</button>
`;
}
test('renders a button with the correct label', () => {
renderButton('Submit');
const button = screen.getByRole('button', { name: 'Submit' });
expect(button).toBeInTheDocument();
});
Core Query Methods
Testing Library provides three categories of queries, each with a specific use case:
getBy* - Returns the matching element or throws an error if not found. Use this when you expect the element to exist immediately.
queryBy* - Returns the matching element or null if not found. Use this when testing that something does not exist.
findBy* - Returns a promise that resolves when the element appears. Use this for async content.
Each category has an *AllBy variant that returns arrays instead of single elements.
The query priority isn’t arbitrary—it’s designed to push you toward accessible markup:
ByRole- Queries by ARIA role. If this works, your accessibility is probably solid.ByLabelText- Queries form elements by their associated label.ByPlaceholderText- Fallback for inputs without proper labels (fix your labels instead).ByText- Queries by visible text content.ByTestId- Last resort when nothing else works.
Here’s a practical example testing a login form:
import { screen } from '@testing-library/dom';
function renderLoginForm() {
document.body.innerHTML = `
<form>
<label for="email">Email address</label>
<input type="email" id="email" name="email" />
<label for="password">Password</label>
<input type="password" id="password" name="password" />
<button type="submit">Sign in</button>
<button type="button">Cancel</button>
</form>
`;
}
test('login form has accessible inputs and buttons', () => {
renderLoginForm();
// Query by role - tests accessibility
expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
// Query by label text - ensures labels are properly associated
expect(screen.getByLabelText('Email address')).toHaveAttribute('type', 'email');
expect(screen.getByLabelText('Password')).toHaveAttribute('type', 'password');
// Verify we have exactly two buttons
expect(screen.getAllByRole('button')).toHaveLength(2);
});
test('queryBy returns null for non-existent elements', () => {
renderLoginForm();
expect(screen.queryByRole('button', { name: 'Delete' })).toBeNull();
});
Firing Events and User Interactions
The fireEvent API dispatches DOM events directly, but it’s low-level. For most tests, use @testing-library/user-event instead—it simulates complete user interactions including focus management, keyboard events, and proper event sequencing.
import { screen } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
function renderToggle() {
document.body.innerHTML = `
<button type="button" aria-pressed="false">Dark mode</button>
`;
const button = document.querySelector('button');
button.addEventListener('click', () => {
const pressed = button.getAttribute('aria-pressed') === 'true';
button.setAttribute('aria-pressed', String(!pressed));
});
}
test('toggle button switches state on click', async () => {
const user = userEvent.setup();
renderToggle();
const toggle = screen.getByRole('button', { name: 'Dark mode' });
expect(toggle).toHaveAttribute('aria-pressed', 'false');
await user.click(toggle);
expect(toggle).toHaveAttribute('aria-pressed', 'true');
await user.click(toggle);
expect(toggle).toHaveAttribute('aria-pressed', 'false');
});
For form testing, user-event handles typing realistically:
test('form submission collects input values', async () => {
const user = userEvent.setup();
let submittedData = null;
document.body.innerHTML = `
<form>
<label for="username">Username</label>
<input type="text" id="username" name="username" />
<button type="submit">Register</button>
</form>
`;
document.querySelector('form').addEventListener('submit', (e) => {
e.preventDefault();
const formData = new FormData(e.target);
submittedData = Object.fromEntries(formData);
});
await user.type(screen.getByLabelText('Username'), 'johndoe');
await user.click(screen.getByRole('button', { name: 'Register' }));
expect(submittedData).toEqual({ username: 'johndoe' });
});
Working with Async DOM Updates
Modern applications load data asynchronously. Testing Library handles this with findBy queries and the waitFor utility.
import { screen, waitFor } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
function renderUserProfile() {
document.body.innerHTML = `
<div id="profile">
<button type="button">Load Profile</button>
</div>
`;
const container = document.getElementById('profile');
const button = container.querySelector('button');
button.addEventListener('click', async () => {
button.textContent = 'Loading...';
button.disabled = true;
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 100));
container.innerHTML = `
<h2>John Doe</h2>
<p>john@example.com</p>
`;
});
}
test('loads and displays user profile', async () => {
const user = userEvent.setup();
renderUserProfile();
await user.click(screen.getByRole('button', { name: 'Load Profile' }));
// findBy waits for the element to appear
const heading = await screen.findByRole('heading', { name: 'John Doe' });
expect(heading).toBeInTheDocument();
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
test('shows loading state while fetching', async () => {
const user = userEvent.setup();
renderUserProfile();
await user.click(screen.getByRole('button', { name: 'Load Profile' }));
// Use waitFor for assertions on transient states
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Loading...' })).toBeDisabled();
});
});
Common pitfall: don’t use arbitrary setTimeout delays in tests. Use findBy or waitFor to let Testing Library handle timing automatically.
Custom Matchers and Debugging
The jest-dom package provides matchers that make assertions readable and meaningful:
import { screen } from '@testing-library/dom';
function renderNavigation() {
document.body.innerHTML = `
<nav aria-label="Main navigation">
<a href="/" aria-current="page">Home</a>
<a href="/about">About</a>
<a href="/contact" class="disabled" aria-disabled="true">Contact</a>
</nav>
`;
}
test('navigation renders with correct states', () => {
renderNavigation();
const nav = screen.getByRole('navigation', { name: 'Main navigation' });
expect(nav).toBeVisible();
const homeLink = screen.getByRole('link', { name: 'Home' });
expect(homeLink).toHaveAttribute('aria-current', 'page');
const contactLink = screen.getByRole('link', { name: 'Contact' });
expect(contactLink).toHaveAttribute('aria-disabled', 'true');
expect(contactLink).toHaveClass('disabled');
expect(screen.getByRole('link', { name: 'About' })).toHaveAttribute('href', '/about');
});
When tests fail mysteriously, use screen.debug() to see the current DOM state:
test('debugging example', () => {
renderNavigation();
// Prints the entire DOM to console
screen.debug();
// Print a specific element
screen.debug(screen.getByRole('navigation'));
});
The logRoles utility helps identify what roles are available:
import { logRoles } from '@testing-library/dom';
test('discover available roles', () => {
renderNavigation();
logRoles(document.body);
});
Best Practices and Common Patterns
| Do | Don’t |
|---|---|
Use getByRole as your first choice |
Reach for getByTestId immediately |
| Test visible behavior and output | Test internal state or implementation |
Use user-event for interactions |
Use fireEvent for complex interactions |
Let findBy handle async timing |
Add arbitrary setTimeout delays |
| Write tests that would pass with any framework | Couple tests to specific framework internals |
Use queryBy to assert absence |
Wrap getBy in try-catch |
Testing Library forces you to write accessible applications. If you can’t query an element by role, your markup probably needs improvement. Treat failing queries as accessibility bugs, not testing inconveniences.
Keep tests focused on user outcomes. A test called “submits the form successfully” should verify what the user sees after submission, not what internal functions were called. This approach produces tests that survive refactoring and catch real bugs.
Your test suite should give you confidence to ship. Testing Library helps you build that confidence by testing what actually matters to users.