VOOZH about

URL: https://dev.to/kushang_tailor/testing-debugging-react-apps-write-code-you-can-actually-trust-5334

⇱ Testing & Debugging React Apps — Write Code You Can Actually Trust - DEV Community


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
 /‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\
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

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: ['<rootDir>/jest.setup.ts'],
 moduleNameMapper: {
 '^@/(.*)$': '<rootDir>/$1', // Resolve @ path aliases
 },
};

export default createJestConfig(config);

Create jest.setup.ts:

// jest.setup.ts
import '@testing-library/jest-dom';
// This gives you matchers like .toBeInTheDocument(), .toHaveTextContent() etc.

Add the test script to package.json:

{"scripts":{"test":"jest","test:watch":"jest --watch","test:coverage":"jest --coverage"}}

Run your first test:

npm test

✅ 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 <p>Please log in to continue.</p>;
 }
 return <h1>Welcome back, {name}!</h1>;
}
// 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(<Greeting name="Kushang" isLoggedIn={true} />);

 expect(screen.getByText('Welcome back, Kushang!')).toBeInTheDocument();
 });

 it('shows a login prompt when the user is not logged in', () => {
 render(<Greeting name="Kushang" isLoggedIn={false} />);

 expect(screen.getByText('Please log in to continue.')).toBeInTheDocument();
 });

 it('does not show the welcome message when logged out', () => {
 render(<Greeting name="Kushang" isLoggedIn={false} />);

 expect(screen.queryByText(/Welcome back/)).not.toBeInTheDocument();
 });
});

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 (
 <div>
 <p>Count: {count}</p>
 <button onClick={() => setCount(count + 1)}>Increment</button>
 <button onClick={() => setCount(0)}>Reset</button>
 </div>
 );
}
// 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(<Counter />);
 expect(screen.getByText('Count: 0')).toBeInTheDocument();
 });

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

 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(<Counter />);

 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();
 });
});

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 (
 <form onSubmit={handleSubmit}>
 <label htmlFor="email">Email</label>
 <input
 id="email"
 type="email"
 value={email}
 onChange={(e) => setEmail(e.target.value)}
 />

 <label htmlFor="password">Password</label>
 <input
 id="password"
 type="password"
 value={password}
 onChange={(e) => setPassword(e.target.value)}
 />

 {error && <p role="alert">{error}</p>}

 <button type="submit">Log In</button>
 </form>
 );
}
// 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(<LoginForm onSubmit={onSubmit} />);

 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(<LoginForm onSubmit={jest.fn()} />);

 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(<LoginForm onSubmit={onSubmit} />);

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

 expect(onSubmit).not.toHaveBeenCalled();
 });
});

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<User | null>(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 <p>Loading profile...</p>;
 if (error) return <p role="alert">{error}</p>;

 return (
 <div>
 <h2>{user?.name}</h2>
 <p>{user?.email}</p>
 </div>
 );
}
// 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(<UserProfile userId={1} />);
 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(<UserProfile userId={1} />);

 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(<UserProfile userId={1} />);

 await waitFor(() => {
 expect(screen.getByRole('alert')).toHaveTextContent('Failed to load user.');
 });
 });
});

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),
 };
}
// 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);
 });
});

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)

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)

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<Props, State> {
 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 ?? (
 <div className="error-state">
 <h2>Something went wrong.</h2>
 <p>We're looking into it — try refreshing the page.</p>
 <button onClick={() => this.setState({ hasError: false, error: null })}>
 Try Again
 </button>
 </div>
 )
 );
 }

 return this.props.children;
 }
}

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 (
 <div className="dashboard">
 {/* If SalesChart crashes, only this section shows an error */}
 <ErrorBoundary fallback={<p>Chart unavailable  try again later.</p>}>
 <SalesChart />
 </ErrorBoundary>

 {/* RecentOrders is unaffected by SalesChart crashing */}
 <ErrorBoundary fallback={<p>Orders unavailable  try again later.</p>}>
 <RecentOrders />
 </ErrorBoundary>
 </div>
 );
}

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')

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)

Nine times out of ten, the bug is in the console, the network tab, or a missing dependency array.


🔗 Quick Resources


💬 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! 🧪