DEV Community

Cover image for Testing & Debugging React Apps — Write Code You Can Actually Trust
Kushang Tailor
Kushang Tailor

Posted on

Testing & Debugging React Apps — Write Code You Can Actually Trust

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: ['<rootDir>/jest.setup.ts'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/$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 <p>Please log in to continue.</p>;
  }
  return <h1>Welcome back, {name}!</h1>;
}
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(<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();
  });
});
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 (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  );
}
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(<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();
  });
});
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 (
    <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>
  );
}
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(<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();
  });
});
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<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>
  );
}
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(<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.');
    });
  });
});
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<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;
  }
}
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 (
    <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>
  );
}
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


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

Top comments (0)