Back to blogs

Unit Testing for React Developers: The Basics

Published July 9, 2026

If you've been putting off learning unit testing because it feels like a rabbit hole, here's the good news: you only need a handful of concepts to get productive. This post covers config, coverage, thresholds, mocks, and a few real examples — nothing more.

1. Setting Up: Vitest vs Jest

If you're on Vite, use Vitest — it reuses your Vite config and is faster. If you're on CRA/Webpack, use Jest.

Vitest config (vite.config.ts)

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

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

Jest config (jest.config.js)

module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEach: ['<rootDir>/src/setupTests.js'],
moduleNameMapper: {
'\\.(css|less|scss)$': 'identity-obj-proxy',
},
}

Key options in both:

2. Coverage

Coverage tells you how much of your code ran during tests — not whether the tests are good, just how much was touched.

Enable coverage (Vitest)

vitest run --coverage

// vite.config.ts
test: {
coverage: {
provider: 'v8', // or 'istanbul'
reporter: ['text', 'html'],
},
}

Enable coverage (Jest)

jest --coverage

// jest.config.js
collectCoverage: true,
coverageReporters: ['text', 'html'],

Running this gives you a report like:

File | % Stmts | % Branch | % Funcs | % Lines
--------------|---------|----------|---------|--------
Button.tsx | 90.9 | 75 | 100 | 90.9

Four metrics matter:

3. Coverage Thresholds

Thresholds let you fail the build if coverage drops below a set percentage — useful in CI to stop coverage from silently eroding.

Vitest

coverage: {
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80,
},
}

Jest

coverageThreshold: {
global: {
lines: 80,
functions: 80,
branches: 70,
statements: 80,
},
}

Practical tip: Start low (50–60%) on an existing project and raise it gradually. Setting 90% on day one just gets the check disabled by a frustrated teammate later.

4. Mocks

Mocks replace real dependencies (API calls, modules, timers) with fake, controllable versions — so tests are fast and don't depend on a real backend.

Mocking a function

const mockFn = vi.fn() // Vitest
// const mockFn = jest.fn() // Jest

mockFn('hello')
expect(mockFn).toHaveBeenCalledWith('hello')

Mocking a module (e.g. an API service)

vi.mock('../services/userService', () => ({
getUser: vi.fn(() => Promise.resolve({ id: 1, name: 'Fayaz' })),
}))

Now any component importing getUser gets the fake version — no real network call happens.

Mocking fetch/axios in a component test

import { render, screen, waitFor } from '@testing-library/react'
import UserCard from './UserCard'
import { getUser } from '../services/userService'

vi.mock('../services/userService')

test('renders user name after fetch', async () => {
getUser.mockResolvedValue({ id: 1, name: 'Fayaz' })

render(<UserCard userId={1} />)

await waitFor(() => {
expect(screen.getByText('Fayaz')).toBeInTheDocument()
})
})

5. Simple Examples

Testing a pure function

// utils/formatCurrency.ts
export function formatCurrency(amount: number) {
return `₹${amount.toFixed(2)}`
}

// utils/formatCurrency.test.ts
import { formatCurrency } from './formatCurrency'

test('formats number as currency', () => {
expect(formatCurrency(100)).toBe('₹100.00')
})

Testing a component render

// Button.tsx
export function Button({ label, onClick }) {
return <button onClick={onClick}>{label}</button>
}

// Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { Button } from './Button'

test('calls onClick when clicked', () => {
const handleClick = vi.fn()
render(<Button label="Save" onClick={handleClick} />)

fireEvent.click(screen.getByText('Save'))

expect(handleClick).toHaveBeenCalledTimes(1)
})

Testing a service (business logic layer)

// services/orderService.ts
export function calculateTotal(items: { price: number; qty: number }[]) {
return items.reduce((sum, item) => sum + item.price * item.qty, 0)
}

// services/orderService.test.ts
import { calculateTotal } from './orderService'

test('calculates total price correctly', () => {
const items = [{ price: 100, qty: 2 }, { price: 50, qty: 1 }]
expect(calculateTotal(items)).toBe(250)
})

Notice the pattern: components are tested by rendering + interacting, services/utils are tested by calling the function directly with inputs and checking outputs. That split makes tests easier to write and faster to run — one more reason to keep business logic out of components.

Takeaway

You don't need to memorize every Jest/Vitest API to start testing. Pick the runner that matches your build tool, turn on coverage with a modest threshold, mock external dependencies, and write small, focused tests for functions and components separately. That's 90% of what you'll use day-to-day.