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 getByRole and getByLabelText over getByTestId to write tests that double as accessibility audits
  • Use @testing-library/user-event over fireEvent for 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:

  1. ByRole - Queries by ARIA role. If this works, your accessibility is probably solid.
  2. ByLabelText - Queries form elements by their associated label.
  3. ByPlaceholderText - Fallback for inputs without proper labels (fix your labels instead).
  4. ByText - Queries by visible text content.
  5. 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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.