DEV Community

Cover image for Master React Testing Step-by-Step: Jest, Vitest & React Testing Library
Yogesh Chavan
Yogesh Chavan

Posted on

Master React Testing Step-by-Step: Jest, Vitest & React Testing Library

A comprehensive guide to testing React applications with Jest, Vitest, and React Testing Library.


Introduction to Testing

Why Test Your Code?

Testing is an essential part of software development that helps ensure your code works correctly and continues to work as you make changes. In React applications, testing gives you confidence that your components render correctly, handle user interactions properly, and manage state as expected.

Without tests, you might ship broken code to production, spend hours debugging issues that tests would have caught immediately, or be afraid to refactor code because you're not sure if you'll break something.

Benefits of Testing

  1. Catch bugs early - Find issues before they reach production.

  2. Documentation - Tests describe how your code should behave.

  3. Confidence to refactor - Change code without fear of breaking things.

  4. Better design - Writing testable code often leads to better architecture.

  5. Faster development - Automated tests are faster than manual testing.

Types of Tests

Unit Tests focus on testing individual pieces of code in isolation - a single function, a component, or a hook. They are fast to run and help pinpoint exactly where bugs occur. Most of your tests should be unit tests.

Integration Tests verify that multiple pieces of code work together correctly. For example, testing that a form component correctly calls an API and updates state. These tests give you more confidence that features work end-to-end.

End-to-End (E2E) Tests simulate real user behavior by testing the entire application in a browser. Tools like Cypress or Playwright are used for E2E testing. While comprehensive, these tests are slower and more brittle than unit tests.

The Testing Trophy

Kent C. Dodds popularized the "Testing Trophy" which suggests focusing most effort on integration tests, with unit tests for complex logic, and minimal E2E tests for critical paths. The key principle is to test your software the way users actually use it.

Test Type Speed Confidence Cost
Unit Tests Very Fast Lower Low
Integration Fast Higher Medium
E2E Tests Slow Highest High

Tip: Write tests that give you confidence your app works. Don't aim for 100% coverage - aim for meaningful tests!


Don't miss to check out the important announcement at the end of the article.

Setting Up Your Environment

Prerequisites

Before setting up testing, you should have Node.js installed on your machine. You'll also need a basic understanding of React fundamentals including components, props, state, and hooks.

Creating a React Project with Vite

We'll use Vite to create our React project because it's fast and has excellent support for modern JavaScript features. Run the following commands in your terminal:

npm create vite@latest react-testing-demo -- --template react
cd react-testing-demo
npm install
Enter fullscreen mode Exit fullscreen mode

Installing Vitest

Vitest is a blazing-fast test runner built for Vite projects. It's compatible with Jest's API, making it easy to migrate existing tests. Install Vitest and its dependencies:

npm install -D vitest @testing-library/react @testing-library/jest-dom
npm install -D @testing-library/user-event jsdom
Enter fullscreen mode Exit fullscreen mode

Understanding the Packages

  • vitest - The test runner itself. It executes your tests, provides assertions, mocking capabilities, and integrates seamlessly with Vite's build system for fast test execution.

  • @testing-library/react - Provides utilities to render React components in tests and query the rendered output. It encourages testing components the way users interact with them.

  • @testing-library/jest-dom - Adds custom matchers like toBeInTheDocument(), toHaveTextContent(), and toBeVisible() that make assertions more readable and expressive.

  • @testing-library/user-event - Simulates real user interactions like clicking, typing, and selecting. It's more realistic than fireEvent as it triggers all the events a real interaction would cause.

  • jsdom - A JavaScript implementation of the DOM that runs in Node.js. It provides a browser-like environment where your React components can render during tests.

Configuring Vitest

Update your vite.config.js file to include the test configuration:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/setupTests.js',
  },
})
Enter fullscreen mode Exit fullscreen mode

Creating the Setup File

Create a setup file at src/setupTests.js to configure testing utilities:

import '@testing-library/jest-dom'
Enter fullscreen mode Exit fullscreen mode

Adding Test Scripts

Add test scripts to your package.json:

{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "test": "vitest",
    "test:ui": "vitest --ui",
    "coverage": "vitest run --coverage"
  }
}
Enter fullscreen mode Exit fullscreen mode

Tip: Use npm test for watch mode during development. Use npm run coverage to see code coverage reports.


Writing Your First Test

Test File Structure

Test files in Vitest/Jest typically follow naming conventions like Component.test.js or Component.spec.js. Place test files next to the components they test or in a __tests__ folder.

src/
  components/
    Button/
      Button.jsx
      Button.test.jsx
  __tests__/
    utils.test.js
Enter fullscreen mode Exit fullscreen mode

Basic Test Anatomy

Every test follows a simple structure: describe what you're testing, set up the test, perform actions, and assert the expected results. Let's write our first test. Create a new file called src/math.test.js:

import { describe, it, expect } from 'vitest'

describe('Math operations', () => {
  it('should add two numbers correctly', () => {
    const result = 2 + 2
    expect(result).toBe(4)
  })

  it('should multiply two numbers correctly', () => {
    const result = 3 * 4
    expect(result).toBe(12)
  })
})
Enter fullscreen mode Exit fullscreen mode

When you run 'npm test', you should see output similar to this:

Test Output

Understanding the Test Syntax

describe() - The describe function groups related tests together. It takes a description string and a callback function containing the tests. You can nest describe blocks for better organization.

it() / test() - The it function (or test, they're identical) defines a single test case. The description should clearly state what behavior is being tested. Write it as "it should..." for clarity.

expect() - The expect function creates an assertion. It takes a value and returns an object with matcher methods like toBe(), toEqual(), toBeTruthy(), etc.

Common Matchers

// Equality
expect(value).toBe(4)              // Strict equality (===)
expect(value).toEqual({a: 1})      // Deep equality for objects
expect(value).not.toBe(5)          // Negation

// Truthiness
expect(value).toBeTruthy()         // Truthy value
expect(value).toBeFalsy()          // Falsy value
expect(value).toBeNull()           // Exactly null
expect(value).toBeDefined()        // Not undefined

// Numbers
expect(value).toBeGreaterThan(3)
expect(value).toBeLessThanOrEqual(10)
expect(value).toBeCloseTo(0.3, 5)

// Strings
expect(string).toMatch(/pattern/)
expect(string).toContain('substring')

// Arrays
expect(array).toContain(item)
expect(array).toHaveLength(3)
Enter fullscreen mode Exit fullscreen mode

Tip: Run npm test now to see your first test pass! Vitest will watch for changes automatically.


Testing React Components

Introduction to React Testing Library

React Testing Library (RTL) is the recommended way to test React components. Its guiding principle is: "The more your tests resemble the way your software is used, the more confidence they can give you." RTL encourages testing behavior, not implementation details.

Creating a Simple Component

Let's create a simple Greeting component and test it:

// src/components/Greeting.jsx
function Greeting({ name }) {
  return (
    <div>
      <h1>Hello, {name}!</h1>
      <p>Welcome to React Testing</p>
    </div>
  )
}

export default Greeting
Enter fullscreen mode Exit fullscreen mode

Writing the Test

// src/components/Greeting.test.jsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import Greeting from './Greeting'

describe('Greeting', () => {
  it('renders the greeting message with name', () => {
    render(<Greeting name="John" />)

    expect(screen.getByText('Hello, John!')).toBeInTheDocument()
  })

  it('renders the welcome message', () => {
    render(<Greeting name="Jane" />)

    expect(screen.getByText('Welcome to React Testing')).toBeInTheDocument()
  })
})
Enter fullscreen mode Exit fullscreen mode

Understanding render() and screen

render() - The render function takes a React element and renders it into a virtual DOM. It returns utility functions for interacting with the rendered component, but using screen is preferred.

screen - The screen object provides query methods to find elements in the rendered output. It's the recommended way to query elements because it's more explicit and cleaner.

Query Methods

Query No Match Multiple Match Async
getBy Throws Throws No
queryBy null Throws No
findBy Throws Throws Yes
getAllBy Throws Array No
queryAllBy [] Array No
findAllBy Throws Array Yes

Tip: Use getBy for elements that should exist, queryBy for elements that might not exist, and findBy for async elements.

Common Assertion Methods

  • toBeInTheDocument() - Checks if an element is present in the document. This is one of the most frequently used matchers from @testing-library/jest-dom.

  • toHaveTextContent(text) - Verifies that an element contains the specified text content.

  • toBeVisible() - Checks if an element is currently visible to the user (not hidden by CSS).

  • toBeDisabled() / toBeEnabled() - Checks if a form element is disabled or enabled.

  • toHaveValue(value) - Verifies the current value of an input, select, or textarea element.

  • toHaveBeenCalled() - Checks if a mock function was called at least once.

  • toHaveBeenCalledTimes(n) - Verifies that a mock function was called exactly n times.

  • toHaveBeenCalledWith(args) - Checks if a mock function was called with specific arguments.


Testing User Interactions

Introduction to user-event

The @testing-library/user-event library simulates user interactions like clicking, typing, and selecting. It's preferred over fireEvent because it more closely mimics actual user behavior, including triggering all the events a real interaction would cause.

Creating an Interactive Component

// src/components/Counter.jsx
import { useState } from 'react'

function Counter() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <h2>Count: {count}</h2>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
      <button onClick={() => setCount(count - 1)}>
        Decrement
      </button>
      <button onClick={() => setCount(0)}>
        Reset
      </button>
    </div>
  )
}

export default Counter
Enter fullscreen mode Exit fullscreen mode

Testing Click Events

// src/components/Counter.test.jsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect } from 'vitest'
import Counter from './Counter'

describe('Counter', () => {
  it('starts with count of 0', () => {
    render(<Counter />)
    expect(screen.getByText('Count: 0')).toBeInTheDocument()
  })

  it('increments count when increment button clicked', async () => {
    const user = userEvent.setup()
    render(<Counter />)

    await user.click(screen.getByText('Increment'))

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

  it('decrements count when decrement button clicked', async () => {
    const user = userEvent.setup()
    render(<Counter />)

    await user.click(screen.getByText('Decrement'))

    expect(screen.getByText('Count: -1')).toBeInTheDocument()
  })
})
Enter fullscreen mode Exit fullscreen mode

Now try to add a test for the reset button click in the above test file and see if it passes to get hands-on practice.

Tip: Keep the npm test command running in the terminal so you can see the test success/failure in real-time.

Testing Form Inputs

// src/components/SearchBox.jsx
import { useState } from 'react'

function SearchBox({ onSearch }) {
  const [query, setQuery] = useState('')

  const handleSubmit = (e) => {
    e.preventDefault()
    onSearch(query)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="Search..."
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <button type="submit">Search</button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Testing Typing and Form Submission

// src/components/SearchBox.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, vi } from 'vitest';
import SearchBox from './SearchBox';

describe('SearchBox', () => {
  it('calls onSearch with query when form submitted', async () => {
    const user = userEvent.setup();
    const mockOnSearch = vi.fn();

    render(<SearchBox onSearch={mockOnSearch} />);

    const searchInput = screen.getByPlaceholderText('Search...');
    const searchButton = screen.getByText('Search');

    await user.type(searchInput, 'React');
    await user.click(searchButton);

    expect(mockOnSearch).toHaveBeenCalledWith('React');
  });
});
Enter fullscreen mode Exit fullscreen mode

Understanding Mocks

A mock is a fake implementation of a function that allows you to track how it was called without executing the real code. Mocks are essential in testing because they let you isolate the component you're testing from its dependencies.

In the example above, we use vi.fn() to create a mock function for onSearch. This lets us verify that the SearchBox component calls the onSearch prop with the correct argument when the form is submitted, without needing to implement actual search functionality.

Note: vi.fn() in Vitest is equivalent to jest.fn() in Jest. If you're using Jest instead of Vitest, simply replace vi.fn() with jest.fn() - they work exactly the same way.

Note: Always use userEvent.setup() at the start of your test. Remember that user-event methods are async!


Testing Props and State

Testing Components with Props

When testing components that receive props, verify that the component renders correctly with different prop values and handles edge cases like missing or invalid props.

// src/components/UserCard.jsx
function UserCard({ user, onEdit, onDelete }) {
  if (!user) {
    return <p>No user data available</p>
  }

  return (
    <div className="user-card">
      <h3>{user.name}</h3>
      <p>Email: {user.email}</p>
      <p>Role: {user.role || 'Member'}</p>
      <button onClick={() => onEdit(user.id)}>Edit</button>
      <button onClick={() => onDelete(user.id)}>Delete</button>
    </div>
  )
}

export default UserCard
Enter fullscreen mode Exit fullscreen mode

Testing Different Prop Scenarios

// src/components/UserCard.test.jsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import UserCard from './UserCard'

const mockUser = {
  id: 1,
  name: 'John Doe',
  email: 'john@example.com',
  role: 'Admin'
}

describe('UserCard', () => {
  it('renders user information correctly', () => {
    render(<UserCard user={mockUser} onEdit={vi.fn()} onDelete={vi.fn()} />)

    expect(screen.getByText('John Doe')).toBeInTheDocument()
    expect(screen.getByText('Email: john@example.com')).toBeInTheDocument()
    expect(screen.getByText('Role: Admin')).toBeInTheDocument()
  })

  it('shows default role when role not provided', () => {
    const userWithoutRole = { id: 2, name: 'Jane', email: 'jane@example.com' }
    render(<UserCard user={userWithoutRole} onEdit={vi.fn()} onDelete={vi.fn()} />)

    expect(screen.getByText('Role: Member')).toBeInTheDocument()
  })

  it('shows message when user is null', () => {
    render(<UserCard user={null} onEdit={vi.fn()} onDelete={vi.fn()} />)

    expect(screen.getByText('No user data available')).toBeInTheDocument()
  })
})
Enter fullscreen mode Exit fullscreen mode

Testing Callback Props

describe('UserCard callbacks', () => {
  it('calls onEdit with user id when edit clicked', async () => {
    const user = userEvent.setup()
    const mockOnEdit = vi.fn()

    render(<UserCard user={mockUser} onEdit={mockOnEdit} onDelete={vi.fn()} />)

    await user.click(screen.getByText('Edit'))

    expect(mockOnEdit).toHaveBeenCalledTimes(1)
    expect(mockOnEdit).toHaveBeenCalledWith(1)
  })

  it('calls onDelete with user id when delete clicked', async () => {
    const user = userEvent.setup()
    const mockOnDelete = vi.fn()

    render(<UserCard user={mockUser} onEdit={vi.fn()} onDelete={mockOnDelete} />)

    await user.click(screen.getByText('Delete'))

    expect(mockOnDelete).toHaveBeenCalledWith(1)
  })
})
Enter fullscreen mode Exit fullscreen mode

Testing State Changes

When testing components with internal state, focus on verifying the visible behavior changes rather than the state values directly. Test what the user sees and interacts with.

Tip: Use vi.fn() (Vitest) or jest.fn() (Jest) to create mock functions for testing callbacks.


Summary

In this tutorial, we covered the fundamentals of React testing, including setting up your testing environment with Vitest, writing your first tests, testing React components with React Testing Library, handling user interactions with user-event, and testing components with props and state.

Key takeaways:

  • Test behavior, not implementation details

  • Use screen queries to find elements the way users would

  • Mock functions help isolate components from their dependencies

  • Always use userEvent.setup() for simulating user interactions

  • Focus on what users see and experience

Want to Learn More?

This tutorial covers just the first half of the React Testing Guide. The full guide includes additional topics such as:

  • Testing Hooks and Custom Hooks

  • Mocking in Tests (Mock functions, Modules, Apis, Utility functions and timers)

  • Testing API Calls with Fetch(Mocking fetch requests, Testing error states)

  • Testing API Calls with Axios(Mocking axios Get and Post requests)

  • Async Testing Patterns(Handling promises and async operations)

  • Testing Best Practices(Writing maintainable tests, Common Anti-Patterns to Avoid, Tips & Tricks)

  • Jest vs Vitest Comparison(When to use which, Migration from Jest to Vitest)

and much more…

The complete guide also includes full source code access, so you can follow along, experiment, and practice with all the examples/code samples covered throughout the guide.

To dive deeper into React testing and master all these concepts, check out the complete React Testing Using Jest, Vitest, React Testing Library - Complete Beginners Guide available at courses.yogeshchavan.dev.

Announcement:

Republic Day Discount Offer - Get All Current + Future Courses, Ebooks, Webinars At Just $15 / ₹1200 Instead Of Regular Price $236 / ₹20,060.


Limited Time Offer.


About Me

I'm a freelancer, mentor, full-stack developer working primarily with React, Next.js, and Node.js with a total of 12+ years of experience.

Alongside building real-world web applications, I'm also an Industry/Corporate Trainer training developers and teams in modern JavaScript, Next.js and MERN stack technologies, focusing on practical, production-ready skills.

Also, created various courses with 3000+ students enrolled in these courses.

My Portfolio: https://yogeshchavan.dev/

Top comments (0)