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

Published: (June 18, 2026 at 12:26 AM EDT)
14 min read
Source: Dev.to

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

0 views
Back to Blog

Related posts

Read more »

The Model Doesn't Remember. You Do

Introduction Before I dug into how an LLM works, I assumed each chat stored its memory or context in its own. The moment I realized it was just an array with al...