Testing & Debugging React Apps — Write Code You Can Actually Trust
Source: Dev.to
Read Time: ~14 minutes | Ship with confidence — because guessing is not a strategy
Prerequisites: React fundamentals, hooks, state management, Next.js basics (Parts 1–4)
📌 What You’ll Learn
By the end of this guide, you’ll be able to:
-
✅ Understand the three layers of testing and what each one covers
-
✅ Set up Jest and React Testing Library from scratch
-
✅ Write unit tests for components, hooks, and utility functions
-
✅ Write integration tests that test real user flows
-
✅ Test async behaviour — API calls, loading states, and errors
-
✅ Debug like a pro with React DevTools and browser tools
-
✅ Use Error Boundaries to catch crashes gracefully in production
🤔 Why Testing Feels Painful (And Why It Doesn’t Have To)
Let’s be real — most developers skip testing early on. Not because they don’t care about quality, but because they were introduced to testing the wrong way: abstract theory, complicated setup, and tests that take longer to write than the code itself.
Here’s the shift in mindset that makes it click:
You’re not writing tests for the computer. You’re writing tests for Future You.
Future You, at 2 AM, having just changed a utility function, needs to know if something broke without manually clicking through 40 screens. Tests are that safety net.
The other thing nobody tells you: you don’t need to test everything. You test the things that would hurt if they broke.
🏗️ The Three Layers of Testing
Think of testing as a pyramid. Wide base, narrow top.
/‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\
/ E2E Tests (few) \ → Cypress, Playwright
/‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\
/ Integration Tests (some) \ → React Testing Library
/‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\
/ Unit Tests (many) \ → Jest
/‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\
Enter fullscreen mode
Exit fullscreen mode
Layer What It Tests Speed Confidence
Unit One function or component in isolation Fast (~ms) Low–Medium
Integration Multiple parts working together Medium (~s) High
E2E Full app in a real browser Slow (~min) Highest
For most React projects, you want lots of unit tests, a solid set of integration tests, and a handful of E2E tests for critical flows like login and checkout. This article focuses on unit and integration — the layer that gives you the best return on time invested.
⚙️ Setup: Jest + React Testing Library
If you created your project with Create React App, Jest is already configured. For Vite or Next.js, here’s the setup.
For Next.js
npm install --save-dev jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
Enter fullscreen mode
Exit fullscreen mode
Create jest.config.ts in your project root:
// jest.config.ts
import type { Config } from 'jest';
import nextJest from 'next/jest.js';
const createJestConfig = nextJest({ dir: './' });
const config: Config = {
testEnvironment: 'jsdom',
setupFilesAfterFramework: ['/jest.setup.ts'],
moduleNameMapper: {
'^@/(.*)$': '/$1', // Resolve @ path aliases
},
};
export default createJestConfig(config);
Enter fullscreen mode
Exit fullscreen mode
Create jest.setup.ts:
// jest.setup.ts
import '@testing-library/jest-dom';
// This gives you matchers like .toBeInTheDocument(), .toHaveTextContent() etc.
Enter fullscreen mode
Exit fullscreen mode
Add the test script to package.json:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
Enter fullscreen mode
Exit fullscreen mode
Run your first test:
npm test
Enter fullscreen mode
Exit fullscreen mode
✅ Unit Testing: Components
The golden rule of React Testing Library: test what the user sees, not implementation details.
That means — test for text on screen, buttons, inputs, and form behaviour. Don’t test state variables, component internals, or CSS class names.
Testing a Simple Component
// components/Greeting.tsx
interface Props {
name: string;
isLoggedIn: boolean;
}
export default function Greeting({ name, isLoggedIn }: Props) {
if (!isLoggedIn) {
return
Please log in to continue.
;
}
return
## Welcome back, {name}!
;
}
Enter fullscreen mode
Exit fullscreen mode
// components/Greeting.test.tsx
import { render, screen } from '@testing-library/react';
import Greeting from './Greeting';
describe('Greeting', () => {
it('shows a welcome message when the user is logged in', () => {
render();
expect(screen.getByText('Welcome back, Kushang!')).toBeInTheDocument();
});
it('shows a login prompt when the user is not logged in', () => {
render();
expect(screen.getByText('Please log in to continue.')).toBeInTheDocument();
});
it('does not show the welcome message when logged out', () => {
render();
expect(screen.queryByText(/Welcome back/)).not.toBeInTheDocument();
});
});
Enter fullscreen mode
Exit fullscreen mode
Notice the pattern every test follows — Arrange, Act, Assert:
Arrange: render the component
Act: interact with it (if needed)
Assert: check what’s visible
Testing a Button Click
// components/Counter.tsx
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
Count: {count}
setCount(count + 1)}>Increment
setCount(0)}>Reset
);
}
Enter fullscreen mode
Exit fullscreen mode
// components/Counter.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';
describe('Counter', () => {
it('starts at zero', () => {
render();
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
it('increments the count when the button is clicked', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole('button', { name: 'Increment' }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Increment' }));
expect(screen.getByText('Count: 2')).toBeInTheDocument();
});
it('resets the count to zero', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole('button', { name: 'Increment' }));
await user.click(screen.getByRole('button', { name: 'Increment' }));
await user.click(screen.getByRole('button', { name: 'Reset' }));
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
});
Enter fullscreen mode
Exit fullscreen mode
userEvent simulates real user interactions — clicking, typing, tabbing. It’s more realistic than fireEvent and the recommended choice today.
🔗 Integration Testing: Real User Flows
Integration tests are where the real confidence comes from. Instead of testing one component, you test a complete flow — like filling out and submitting a form.
Testing a Login Form
// components/LoginForm.tsx
import { useState } from 'react';
interface Props {
onSubmit: (email: string, password: string) => void;
}
export default function LoginForm({ onSubmit }: Props) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password) {
setError('Both fields are required.');
return;
}
onSubmit(email, password);
};
return (
Email
setEmail(e.target.value)}
/>
Password
setPassword(e.target.value)}
/>
{error &&
{error}
}
Log In
);
}
Enter fullscreen mode
Exit fullscreen mode
// components/LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
describe('LoginForm', () => {
it('calls onSubmit with email and password when the form is valid', async () => {
const user = userEvent.setup();
const onSubmit = jest.fn(); // Mock function — tracks calls
render();
await user.type(screen.getByLabelText('Email'), 'hello@example.com');
await user.type(screen.getByLabelText('Password'), 'secret123');
await user.click(screen.getByRole('button', { name: 'Log In' }));
expect(onSubmit).toHaveBeenCalledWith('hello@example.com', 'secret123');
expect(onSubmit).toHaveBeenCalledTimes(1);
});
it('shows an error message when fields are empty', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole('button', { name: 'Log In' }));
expect(screen.getByRole('alert')).toHaveTextContent('Both fields are required.');
});
it('does not call onSubmit when fields are empty', async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render();
await user.click(screen.getByRole('button', { name: 'Log In' }));
expect(onSubmit).not.toHaveBeenCalled();
});
});
Enter fullscreen mode
Exit fullscreen mode
These three tests cover the happy path, the validation error, and the guard against bad calls. That’s most of what this form can do — and they take under a minute to run.
⏳ Testing Async Behaviour
Most real components talk to an API. Here’s how to test those flows without making actual network requests.
Mocking an API Call
// components/UserProfile.tsx
import { useEffect, useState } from 'react';
interface User {
id: number;
name: string;
email: string;
}
export default function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((r) => {
if (!r.ok) throw new Error('Failed to load user.');
return r.json();
})
.then((data) => { setUser(data); setLoading(false); })
.catch((err) => { setError(err.message); setLoading(false); });
}, [userId]);
if (loading) return
Loading profile...
;
if (error) return
{error}
;
return (
## {user?.name}
{user?.email}
);
}
Enter fullscreen mode
Exit fullscreen mode
// components/UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';
// Mock the global fetch API
global.fetch = jest.fn();
const mockUser = { id: 1, name: 'Kushang Tailor', email: 'kushang@example.com' };
describe('UserProfile', () => {
afterEach(() => jest.clearAllMocks()); // Clean up between tests
it('shows a loading state first', () => {
(fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
});
render();
expect(screen.getByText('Loading profile...')).toBeInTheDocument();
});
it('shows the user name and email after loading', async () => {
(fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
});
render();
await waitFor(() => {
expect(screen.getByText('Kushang Tailor')).toBeInTheDocument();
expect(screen.getByText('kushang@example.com')).toBeInTheDocument();
});
});
it('shows an error message when the API fails', async () => {
(fetch as jest.Mock).mockResolvedValueOnce({ ok: false });
render();
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Failed to load user.');
});
});
});
Enter fullscreen mode
Exit fullscreen mode
waitFor keeps polling the assertion until it passes (or times out). It’s how you deal with async state updates in tests.
🪝 Testing Custom Hooks
Custom hooks need their own tests because they hold logic that multiple components share. Use renderHook from React Testing Library:
// hooks/useCounter.ts
import { useState } from 'react';
export function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
return {
count,
increment: () => setCount((c) => c + 1),
decrement: () => setCount((c) => c - 1),
reset: () => setCount(initial),
};
}
Enter fullscreen mode
Exit fullscreen mode
// hooks/useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('starts with the initial value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('increments the count', () => {
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});
it('decrements the count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => result.current.decrement());
expect(result.current.count).toBe(4);
});
it('resets to the initial value', () => {
const { result } = renderHook(() => useCounter(3));
act(() => result.current.increment());
act(() => result.current.increment());
act(() => result.current.reset());
expect(result.current.count).toBe(3);
});
});
Enter fullscreen mode
Exit fullscreen mode
act wraps anything that causes state updates. It makes sure React processes all the state changes before your assertion runs.
🔥 Debugging: React DevTools Deep Dive
Testing catches bugs before they reach users. Debugging finds the ones that sneak through anyway.
Components Tab
Open DevTools → React tab → Components. Here’s what you can do:
Select any component in the tree → see:
├─ Props (current values)
├─ State (useState values)
├─ Hooks (all hook values in order)
└─ Rendered by (parent chain)
Enter fullscreen mode
Exit fullscreen mode
You can also edit props and state live in the panel — no code change needed. Incredibly useful for testing edge cases.
Profiler Tab (Performance Debugging)
Already covered in Part 3, but worth repeating the workflow:
1. Open React DevTools → Profiler tab
2. Click ● Record
3. Interact with the slow part of your app
4. Click ■ Stop
5. Look for wide bars (slow renders) and grey bars (unnecessary renders)
Enter fullscreen mode
Exit fullscreen mode
Grey bars mean a component re-rendered but produced identical output — a prime candidate for React.memo.
Highlight Updates
In React DevTools settings, enable “Highlight updates when components render”. Every re-render flashes a coloured outline on the component. If things are flashing that shouldn’t be, you’ve found your problem.
🚨 Error Boundaries: Catching Crashes in Production
Here’s something try/catch cannot do: catch errors thrown during rendering. Error Boundaries handle exactly that — they’re React’s safety net for when a component tree crashes.
// components/ErrorBoundary.tsx
import { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
// Send to your error tracking service (Sentry, Datadog, etc.)
console.error('Caught by ErrorBoundary:', error, info.componentStack);
}
render() {
if (this.state.hasError) {
return (
this.props.fallback ?? (
## Something went wrong.
We're looking into it — try refreshing the page.
this.setState({ hasError: false, error: null })}>
Try Again
)
);
}
return this.props.children;
}
}
Enter fullscreen mode
Exit fullscreen mode
Wrap it around sections that could fail independently:
// app/dashboard/page.tsx
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { SalesChart } from '@/components/SalesChart';
import { RecentOrders } from '@/components/RecentOrders';
export default function DashboardPage() {
return (
{/* If SalesChart crashes, only this section shows an error */}
{/* RecentOrders is unaffected by SalesChart crashing */}
);
}
Enter fullscreen mode
Exit fullscreen mode
The result: one section crashing doesn’t take down the entire page. Your users see a graceful fallback instead of a blank white screen.
🧪 Common Testing Queries — Which One to Use
React Testing Library gives you several ways to query the DOM. Here’s when to use each:
// Priority 1: Accessible roles (best — mirrors what screen readers see)
screen.getByRole('button', { name: 'Submit' })
screen.getByRole('heading', { name: 'Welcome' })
screen.getByRole('textbox', { name: 'Email' })
// Priority 2: Labels (great for form fields)
screen.getByLabelText('Email Address')
// Priority 3: Placeholder (acceptable for inputs)
screen.getByPlaceholderText('Search...')
// Priority 4: Text content (good for readable text)
screen.getByText('Loading...')
// Priority 5: Test IDs (last resort — add data-testid only if nothing else works)
screen.getByTestId('product-card')
Enter fullscreen mode
Exit fullscreen mode
The philosophy: if you’re querying by class name or component name, you’re testing implementation, not behaviour. A CSS refactor will break your tests for no good reason.
📊 What a Healthy Test Suite Looks Like
Here’s a realistic coverage target for a production React app:
Type Target Coverage Run Time
Utility functions 90–100% < 1s
Custom hooks 80–90% < 5s
Components (unit) 70–80% < 30s
User flows (integration) Key flows covered < 2 min
E2E Login, checkout, critical paths < 10 min
100% coverage is a myth worth ignoring. A well-tested login flow, cart checkout, and search filter give you far more confidence than 100% coverage on a heading component.
💡 Debugging Checklist (When Things Go Wrong)
Before spending an hour on a bug, run through this:
□ Check the browser console — is there an error message?
□ Check the Network tab — did the API call succeed? What did it return?
□ Add a console.log right before the broken code
□ Open React DevTools → Components → check the props and state
□ Is the component re-rendering when it shouldn't? (Highlight updates)
□ Is it an async timing issue? (Add a debugger statement in the useEffect)
□ Are you mutating state directly? (Should always use setState)
□ Is a dependency array in useEffect missing something?
□ Did a prop change shape or become undefined?
□ Is the issue only in production? (Check .env variables)
Enter fullscreen mode
Exit fullscreen mode
Nine times out of ten, the bug is in the console, the network tab, or a missing dependency array.
🔗 Quick Resources
React Testing Library: testing-library.com/react
Jest Docs: jestjs.io
React DevTools: Chrome Web Store → “React Developer Tools”
Vitest (fast alternative to Jest): vitest.dev
Cypress (E2E): cypress.io
Playwright (E2E): playwright.dev
💬 What’s Your Testing Philosophy?
Do you write tests before the code (TDD), after, or only when something breaks? No judgement either way — I’m genuinely curious what workflow actually sticks for people in the real world. Drop it in the comments!
Coming in Part 6:
-
Authentication flows (JWT, session, OAuth)
-
Real-world deployment patterns
-
Error monitoring with Sentry
-
CI/CD with GitHub Actions
-
Lessons from production React apps at scale
Happy testing! 🧪