Skip to content
Testing Library Guide — Writing Better Frontend Tests

Testing Library Guide — Writing Better Frontend Tests

DodaTech Updated Jun 7, 2026 9 min read

Testing Library is a lightweight set of utilities for testing UI components that encourages testing from the user’s perspective — querying by accessible roles, labels, and text rather than implementation details like class names or component state.

What You’ll Learn

By the end of this tutorial, you’ll be able to use getBy, findBy, and queryBy queries to locate elements, simulate user interactions with @testing-library/user-event, write accessible tests that resist refactoring, and test React components effectively with Jest.

Why Testing Library Matters

Testing Library shifts the focus from testing implementation details to testing behavior. Tests written with Testing Library don’t break when you rename a CSS class or refactor internal state — they only break when the user experience changes. At DodaTech, Testing Library is the standard for frontend component tests in Doda Browser’s extension UI and DodaZIP’s web interface.

Testing Library Learning Path

    flowchart LR
  A[Testing Basics] --> B[Jest]
  B --> C[Testing Library]
  C --> D[Playwright]
  C --> E[Cypress]
  C --> F{You Are Here}
  style F fill:#f90,color:#fff
  
Prerequisites: Solid understanding of JavaScript and React. Completion of the Jest tutorial is highly recommended.

Setting Up Testing Library

For a React project with Jest:

npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event

Create a setup file to extend Jest matchers:

// jest.setup.js
import '@testing-library/jest-dom';

Add to Jest config:

{
  "jest": {
    "setupFilesAfterSetup": ["./jest.setup.js"]
  }
}

Core Queries: getBy, findBy, queryBy

Testing Library provides three prefix types for locating elements:

PrefixBehaviorWhen to Use
getByReturns element or throwsElement must exist synchronously
findByReturns promise, waits up to timeoutElement appears after async action
queryByReturns element or nullElement may not exist

Each prefix pairs with a query type: Role, LabelText, PlaceholderText, Text, DisplayValue, AltText, Title, TestId.

// Greeting.jsx
function Greeting({ name }) {
  return <h1>Hello, {name}!</h1>;
}

// Greeting.test.jsx
import { render, screen } from '@testing-library/react';
import Greeting from './Greeting';

test('renders greeting with name', () => {
  render(<Greeting name="Alice" />);
  const heading = screen.getByRole('heading', { name: /hello, alice/i });
  expect(heading).toBeInTheDocument();
});

test('getByText finds element by text content', () => {
  render(<Greeting name="Bob" />);
  expect(screen.getByText('Hello, Bob!')).toBeInTheDocument();
});

Expected output:

PASS  ./Greeting.test.jsx
  ✓ renders greeting with name (25 ms)
  ✓ getByText finds element by text content (10 ms)

User Events

@testing-library/user-event simulates real browser interactions more accurately than fireEvent.

// Counter.jsx
import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p aria-live="polite">Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
    </div>
  );
}

// Counter.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';

test('increments count on button click', async () => {
  const user = userEvent.setup();
  render(<Counter />);

  const button = screen.getByRole('button', { name: 'Increment' });
  await user.click(button);

  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

test('increments multiple times', async () => {
  const user = userEvent.setup();
  render(<Counter />);

  const button = screen.getByRole('button', { name: 'Increment' });
  await user.click(button);
  await user.click(button);
  await user.click(button);

  expect(screen.getByText('Count: 3')).toBeInTheDocument();
});

Expected output:

PASS  ./Counter.test.jsx
  ✓ increments count on button click (30 ms)
  ✓ increments multiple times (25 ms)

Async Queries with findBy

Use findBy when elements appear after a delay, like after an API call or animation.

// UserProfile.jsx
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]);

  if (!user) return <div aria-label="loading">Loading...</div>;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

// UserProfile.test.jsx
import { render, screen } from '@testing-library/react';
import UserProfile from './UserProfile';

beforeEach(() => {
  global.fetch = jest.fn(() =>
    Promise.resolve({
      json: () => Promise.resolve({ name: 'Alice', email: 'alice@test.com' }),
    })
  );
});

test('shows loading state initially', () => {
  render(<UserProfile userId={1} />);
  expect(screen.getByLabelText('loading')).toBeInTheDocument();
});

test('displays user data after loading', async () => {
  render(<UserProfile userId={1} />);
  const name = await screen.findByRole('heading', { name: 'Alice' });
  expect(name).toBeInTheDocument();
  expect(screen.getByText('alice@test.com')).toBeInTheDocument();
});

Expected output:

PASS  ./UserProfile.test.jsx
  ✓ shows loading state initially (12 ms)
  ✓ displays user data after loading (25 ms)

The Testing Library Approach

    flowchart LR
  A[Render Component] --> B[Find Elements by Role/Text/Label]
  B --> C[Simulate User Event]
  C --> D[Assert on Visible State]
  D --> E[Not on Internal State]
  

Accessibility Testing

Testing Library encourages testing by ARIA roles, which naturally verifies accessibility. If a screen reader can find it, your test can find it.

test('form has accessible submit button', () => {
  render(<LoginForm />);
  expect(screen.getByRole('button', { name: /sign in/i })).toBeEnabled();
});

test('error messages are announced to screen readers', () => {
  render(<LoginForm />);
  fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
  expect(screen.getByRole('alert')).toHaveTextContent('Email is required');
});

Common Testing Library Mistakes

1. Querying by Implementation Details

Using container.querySelector('.btn-primary') ties tests to CSS class names that change during redesign.

Fix: Use screen.getByRole('button', { name: /submit/i }) — it’s resilient to styling changes and verifies accessibility.

2. Using getBy for Async Elements

getBy throws immediately if the element isn’t in the DOM. For elements that appear after a promise resolves, this causes false failures.

Fix: Use findBy (returns a promise, retries until timeout) for elements that appear after async operations.

3. Forgetting to await userEvent Methods

userEvent.click() and similar methods return promises. Without await, the click fires but the subsequent DOM update may not have happened yet.

Fix: Always use await user.click() where the click triggers a state update.

4. Testing Component State Instead of Output

Asserting that count === 3 on the component’s internal state doesn’t verify that the user sees “Count: 3”.

Fix: Assert on what the user sees in the DOM: expect(screen.getByText('Count: 3')).toBeInTheDocument().

5. Not Using screen for Queries

Storing render result and destructuring is verbose and error-prone.

Fix: Use the screen object — it has all query methods and cleans up automatically.

6. Using fireEvent Instead of userEvent

fireEvent dispatches a single event. userEvent simulates the full interaction (keyboard, focus, click events).

Fix: Prefer userEvent for realistic interaction simulation. Only use fireEvent for rare edge cases.

7. Making Tests Too Granular

Testing every single prop renders creates maintenance burden. Every prop test breaks when the component changes.

Fix: Test user-observable behavior — what the user sees, clicks, and types — not individual prop values.

Practice Questions

1. What’s the difference between getByRole and getByTestId?

getByRole queries by the ARIA role (button, heading, textbox) which is accessible to screen readers. getByTestId queries by data-testid attribute which bypasses accessibility.

2. When should you use findBy instead of getBy?

Use findBy when the element appears after an asynchronous operation (API call, timeout, animation). findBy returns a promise and retries until the element appears or the timeout expires.

3. Why should you prefer screen.getByRole over container.querySelector?

getByRole tests are resilient to DOM changes (class name changes, wrapper divs) and verify that elements are accessible. querySelector ties tests to implementation details.

4. What does @testing-library/jest-dom provide?

It extends Jest matchers with DOM-specific assertions like toBeInTheDocument(), toBeVisible(), toHaveTextContent(), toBeDisabled(), and toHaveAttribute().

5. Challenge: Write tests for a toggle component.

Build a ToggleSwitch component that shows “On” or “Off” and changes on click. Test: initial state, click toggles text, disabled state does not respond to clicks, and ARIA attributes are correct.

Mini Project: Testing a Form Component

// SignupForm.jsx
import { useState } from 'react';

function SignupForm({ onSubmit }) {
  const [errors, setErrors] = useState({});

  function handleSubmit(e) {
    e.preventDefault();
    const data = new FormData(e.target);
    const email = data.get('email');
    const password = data.get('password');

    const newErrors = {};
    if (!email) newErrors.email = 'Email is required';
    if (!password) newErrors.password = 'Password is required';
    if (password && password.length < 8) newErrors.password = 'Password must be at least 8 characters';

    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors);
      return;
    }

    onSubmit({ email, password });
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" aria-describedby="email-error" />
      {errors.email && <p id="email-error" role="alert">{errors.email}</p>}

      <label htmlFor="password">Password</label>
      <input id="password" name="password" type="password" aria-describedby="password-error" />
      {errors.password && <p id="password-error" role="alert">{errors.password}</p>}

      <button type="submit">Sign Up</button>
    </form>
  );
}

// SignupForm.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SignupForm from './SignupForm';

describe('SignupForm', () => {
  test('renders form fields and submit button', () => {
    render(<SignupForm onSubmit={() => {}} />);
    expect(screen.getByLabelText('Email')).toBeInTheDocument();
    expect(screen.getByLabelText('Password')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Sign Up' })).toBeInTheDocument();
  });

  test('shows validation errors for empty fields', async () => {
    const user = userEvent.setup();
    render(<SignupForm onSubmit={() => {}} />);

    await user.click(screen.getByRole('button', { name: 'Sign Up' }));

    expect(screen.getByRole('alert', { name: /email is required/i }));
    expect(screen.getAllByRole('alert')).toHaveLength(2);
  });

  test('shows password length error', async () => {
    const user = userEvent.setup();
    render(<SignupForm onSubmit={() => {}} />);

    await user.type(screen.getByLabelText('Email'), 'a@b.com');
    await user.type(screen.getByLabelText('Password'), 'short');
    await user.click(screen.getByRole('button', { name: 'Sign Up' }));

    expect(screen.getByText('Password must be at least 8 characters')).toBeInTheDocument();
  });

  test('calls onSubmit with valid data', async () => {
    const user = userEvent.setup();
    const handleSubmit = jest.fn();
    render(<SignupForm onSubmit={handleSubmit} />);

    await user.type(screen.getByLabelText('Email'), 'alice@test.com');
    await user.type(screen.getByLabelText('Password'), 'password123');
    await user.click(screen.getByRole('button', { name: 'Sign Up' }));

    expect(handleSubmit).toHaveBeenCalledWith({
      email: 'alice@test.com',
      password: 'password123',
    });
  });

  test('clears errors when user starts typing', async () => {
    const user = userEvent.setup();
    render(<SignupForm onSubmit={() => {}} />);

    await user.click(screen.getByRole('button', { name: 'Sign Up' }));
    expect(screen.getAllByRole('alert')).toHaveLength(2);

    await user.type(screen.getByLabelText('Email'), 'a');
    // Errors might remain until next submit depending on implementation
  });
});

Expected output:

PASS  ./SignupForm.test.jsx
  SignupForm
    ✓ renders form fields and submit button (20 ms)
    ✓ shows validation errors for empty fields (40 ms)
    ✓ shows password length error (35 ms)
    ✓ calls onSubmit with valid data (45 ms)
    ✓ clears errors when user starts typing (30 ms)

FAQ

What’s the difference between Testing Library and Enzyme?
Testing Library tests from the user’s perspective (by role, text, label). Enzyme tests from the implementation perspective (by class name, component state, shallow rendering). Testing Library’s approach produces more resilient tests.
Can I use Testing Library without a framework?
Yes. @testing-library/dom works with any JavaScript environment. Framework-specific packages (@testing-library/react, @testing-library/vue) are wrappers around the core DOM API.
How do I test a component that uses router or context?
Wrap the component in a test provider: render(<MemoryRouter><YourComponent /></MemoryRouter>) for React Router, or render(<ThemeProvider><YourComponent /></ThemeProvider>) for context.
Does Testing Library support snapshot testing?
Not directly — Testing Library recommends behavior-based assertions over snapshots. If you want snapshots, use Jest’s toMatchSnapshot() on the rendered container, but expect frequent updates.
What’s the best practice for selecting elements?
Priority order: getByRole (most accessible), getByLabelText (forms), getByText (visible content), getByPlaceholderText (input hints), and getByTestId (last resort — use data-testid sparingly).

Try It Yourself

  1. Create a simple React component (a button that shows/hides content)
  2. Write a test using render, screen.getByRole, and userEvent.click
  3. Verify the content appears after clicking and disappears after clicking again
  4. Add an async operation (setTimeout) and test it with findBy
  5. Run the tests and view the output

What’s Next

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro