React Intermediate

Testing React Applications

CodingerWeb
CodingerWeb
20 views 65 min read

Testing React Applications

Learn comprehensive testing strategies for React applications using Jest, React Testing Library, and modern testing practices.

Setting Up Testing Environment

// package.json dependencies
{
  "devDependencies": {
    "@testing-library/react": "^13.0.0",
    "@testing-library/jest-dom": "^5.16.0",
    "@testing-library/user-event": "^14.0.0",
    "jest": "^27.0.0"
  }
}

// setupTests.js
import '@testing-library/jest-dom';

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['/src/setupTests.js'],
  moduleNameMapping: {
    '^@/(.*)$': '/src/$1'
  }
};

Basic Component Testing

// Button.js
function Button({ children, onClick, disabled = false, variant = 'primary' }) {
    return (
        
    );
}

// Button.test.js
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from './Button';

describe('Button Component', () => {
    test('renders button with text', () => {
        render();
        expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
    });

    test('calls onClick when clicked', async () => {
        const user = userEvent.setup();
        const handleClick = jest.fn();
        
        render();
        
        await user.click(screen.getByRole('button'));
        expect(handleClick).toHaveBeenCalledTimes(1);
    });

    test('is disabled when disabled prop is true', () => {
        render();
        expect(screen.getByRole('button')).toBeDisabled();
    });

    test('applies correct CSS class for variant', () => {
        render();
        expect(screen.getByRole('button')).toHaveClass('btn-secondary');
    });
});

Testing Components with State

// Counter.js
import { useState } from 'react';

function Counter({ initialValue = 0 }) {
    const [count, setCount] = useState(initialValue);

    return (
        

Count: {count}

); } // Counter.test.js import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import Counter from './Counter'; describe('Counter Component', () => { test('displays initial count', () => { render(); expect(screen.getByTestId('count')).toHaveTextContent('Count: 5'); }); test('increments count when increment button is clicked', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: /increment/i })); expect(screen.getByTestId('count')).toHaveTextContent('Count: 1'); }); test('decrements count when decrement button is clicked', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: /decrement/i })); expect(screen.getByTestId('count')).toHaveTextContent('Count: 4'); }); test('resets count to zero when reset button is clicked', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: /reset/i })); expect(screen.getByTestId('count')).toHaveTextContent('Count: 0'); }); });

Testing Forms and User Input

// LoginForm.js
import { useState } from 'react';

function LoginForm({ onSubmit }) {
    const [email, setEmail] = useState('');
    const [password, setPassword] = useState('');
    const [errors, setErrors] = useState({});

    const handleSubmit = (e) => {
        e.preventDefault();
        const newErrors = {};

        if (!email) newErrors.email = 'Email is required';
        if (!password) newErrors.password = 'Password is required';
        if (password.length < 6) newErrors.password = 'Password must be at least 6 characters';

        setErrors(newErrors);

        if (Object.keys(newErrors).length === 0) {
            onSubmit({ email, password });
        }
    };

    return (
        
setEmail(e.target.value)} /> {errors.email && {errors.email}}
setPassword(e.target.value)} /> {errors.password && {errors.password}}
); } // LoginForm.test.js import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import LoginForm from './LoginForm'; describe('LoginForm Component', () => { test('submits form with valid data', async () => { const user = userEvent.setup(); const mockSubmit = jest.fn(); render(); await user.type(screen.getByLabelText(/email/i), 'test@example.com'); await user.type(screen.getByLabelText(/password/i), 'password123'); await user.click(screen.getByRole('button', { name: /login/i })); expect(mockSubmit).toHaveBeenCalledWith({ email: 'test@example.com', password: 'password123' }); }); test('shows validation errors for empty fields', async () => { const user = userEvent.setup(); const mockSubmit = jest.fn(); render(); await user.click(screen.getByRole('button', { name: /login/i })); expect(screen.getByRole('alert', { name: /email is required/i })).toBeInTheDocument(); expect(screen.getByRole('alert', { name: /password is required/i })).toBeInTheDocument(); expect(mockSubmit).not.toHaveBeenCalled(); }); });

Testing Components with Context

// test-utils.js - Custom render function
import { render } from '@testing-library/react';
import { ThemeProvider } from '../contexts/ThemeContext';

const AllTheProviders = ({ children }) => {
    return (
        
            {children}
        
    );
};

const customRender = (ui, options) =>
    render(ui, { wrapper: AllTheProviders, ...options });

export * from '@testing-library/react';
export { customRender as render };

// ThemedButton.test.js
import { render, screen } from '../test-utils';
import ThemedButton from './ThemedButton';

test('renders with theme context', () => {
    render(Themed Button);
    expect(screen.getByRole('button')).toHaveClass('btn-light'); // default theme
});

Testing Async Operations

// UserProfile.js
import { useState, useEffect } from 'react';

function UserProfile({ userId }) {
    const [user, setUser] = useState(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);

    useEffect(() => {
        fetch(`/api/users/${userId}`)
            .then(response => {
                if (!response.ok) throw new Error('Failed to fetch');
                return response.json();
            })
            .then(userData => {
                setUser(userData);
                setLoading(false);
            })
            .catch(err => {
                setError(err.message);
                setLoading(false);
            });
    }, [userId]);

    if (loading) return 
Loading...
; if (error) return
Error: {error}
; return (

{user.name}

{user.email}

); } // UserProfile.test.js import { render, screen, waitFor } from '@testing-library/react'; import UserProfile from './UserProfile'; // Mock fetch global.fetch = jest.fn(); describe('UserProfile Component', () => { beforeEach(() => { fetch.mockClear(); }); test('displays user data after loading', async () => { const mockUser = { name: 'John Doe', email: 'john@example.com' }; fetch.mockResolvedValueOnce({ ok: true, json: async () => mockUser }); render(); expect(screen.getByText(/loading/i)).toBeInTheDocument(); await waitFor(() => { expect(screen.getByText('John Doe')).toBeInTheDocument(); }); expect(screen.getByText('john@example.com')).toBeInTheDocument(); }); test('displays error message on fetch failure', async () => { fetch.mockRejectedValueOnce(new Error('Network error')); render(); await waitFor(() => { expect(screen.getByText(/error: network error/i)).toBeInTheDocument(); }); }); });

Testing Custom Hooks

// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';

describe('useCounter Hook', () => {
    test('initializes with default value', () => {
        const { result } = renderHook(() => useCounter());
        expect(result.current.count).toBe(0);
    });

    test('initializes with custom value', () => {
        const { result } = renderHook(() => useCounter(10));
        expect(result.current.count).toBe(10);
    });

    test('increments count', () => {
        const { result } = renderHook(() => useCounter());
        
        act(() => {
            result.current.increment();
        });
        
        expect(result.current.count).toBe(1);
    });

    test('decrements count', () => {
        const { result } = renderHook(() => useCounter(5));
        
        act(() => {
            result.current.decrement();
        });
        
        expect(result.current.count).toBe(4);
    });
});

Integration Testing

// App.test.js - Integration test
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App';

test('complete user flow', async () => {
    const user = userEvent.setup();
    
    render();
    
    // Navigate to login
    await user.click(screen.getByRole('link', { name: /login/i }));
    
    // Fill login form
    await user.type(screen.getByLabelText(/email/i), 'test@example.com');
    await user.type(screen.getByLabelText(/password/i), 'password123');
    await user.click(screen.getByRole('button', { name: /login/i }));
    
    // Verify dashboard is shown
    expect(screen.getByText(/welcome to dashboard/i)).toBeInTheDocument();
});

Best Practices

  • Test behavior, not implementation details
  • Use semantic queries (getByRole, getByLabelText)
  • Write tests that resemble how users interact with your app
  • Mock external dependencies and APIs
  • Test error states and edge cases
  • Keep tests simple and focused
  • Use descriptive test names

Practical Exercise

Create comprehensive tests for a Todo application including:

  • Adding new todos
  • Marking todos as complete
  • Filtering todos by status
  • Deleting todos
  • Error handling