Back to Blog
April 14, 2025

Component Testing Patterns for Next.js

The Next.js Component Testing Challenge

Testing components in Next.js applications presents unique challenges that don't exist in traditional React applications. With Server Components, Client Components, and the App Router, you need different testing strategies for different types of components.

Let's explore the patterns that work best for each component type and how to avoid common pitfalls.

Testing Server Components

Server Components are the default in Next.js App Router and run on the server. They can't use hooks, event handlers, or browser APIs.

Basic Server Component Testing

// ✅ DO: Test Server Components with real data
import { render } from '@testing-library/react';
import BlogList from '@/components/BlogList';
import { getAllPosts } from '@/lib/blog';

// Mock the data fetching function
jest.mock('@/lib/blog', () => ({
  getAllPosts: jest.fn()
}));

describe('BlogList', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('should render blog posts', () => {
    // Setup mock data
    const mockPosts = [
      {
        slug: 'test-post',
        title: 'Test Post',
        excerpt: 'This is a test post',
        date: '2024-01-01',
        tags: ['testing', 'nextjs']
      }
    ];
    
    (getAllPosts as jest.Mock).mockReturnValue(mockPosts);

    // Render the Server Component
    const { getByText, getByRole } = render(<BlogList />);
    
    // Test the rendered output
    expect(getByText('Test Post')).toBeInTheDocument();
    expect(getByText('This is a test post')).toBeInTheDocument();
    expect(getByRole('link', { name: /read more/i })).toBeInTheDocument();
  });

  it('should handle empty posts list', () => {
    (getAllPosts as jest.Mock).mockReturnValue([]);

    const { getByText } = render(<BlogList />);
    
    expect(getByText('No posts found')).toBeInTheDocument();
  });
});

Server Component with Async Data

// ✅ DO: Test async Server Components
import { render } from '@testing-library/react';
import UserProfile from '@/components/UserProfile';

// Mock the async data fetching
jest.mock('@/lib/users', () => ({
  getUserById: jest.fn()
}));

describe('UserProfile', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('should render user profile with data', async () => {
    const mockUser = {
      id: 1,
      name: 'John Doe',
      email: 'john@example.com',
      avatar: '/avatar.jpg'
    };
    
    (getUserById as jest.Mock).mockResolvedValue(mockUser);

    // For async Server Components, you might need to wait
    const { findByText, findByAltText } = render(<UserProfile userId={1} />);
    
    // Wait for async content to load
    await findByText('John Doe');
    await findByAltText('John Doe avatar');
    
    expect(getUserById).toHaveBeenCalledWith(1);
  });

  it('should handle user not found', async () => {
    (getUserById as jest.Mock).mockResolvedValue(null);

    const { findByText } = render(<UserProfile userId={999} />);
    
    await findByText('User not found');
  });
});

Testing Client Components

Client Components use the 'use client' directive and can use hooks, event handlers, and browser APIs.

Basic Client Component Testing

// ✅ DO: Test Client Components with user interactions
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import ContactForm from '@/components/ContactForm';

describe('ContactForm', () => {
  beforeEach(() => {
    // Mock fetch for form submission
    global.fetch = jest.fn();
  });

  it('should render form fields', () => {
    render(<ContactForm />);
    
    expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/message/i)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /send/i })).toBeInTheDocument();
  });

  it('should handle form submission', async () => {
    (global.fetch as jest.Mock).mockResolvedValue({
      ok: true,
      json: () => Promise.resolve({ message: 'Message sent successfully' })
    });

    render(<ContactForm />);
    
    // Fill out the form
    fireEvent.change(screen.getByLabelText(/name/i), {
      target: { value: 'John Doe' }
    });
    fireEvent.change(screen.getByLabelText(/email/i), {
      target: { value: 'john@example.com' }
    });
    fireEvent.change(screen.getByLabelText(/message/i), {
      target: { value: 'Hello, this is a test message' }
    });
    
    // Submit the form
    fireEvent.click(screen.getByRole('button', { name: /send/i }));
    
    // Wait for submission to complete
    await waitFor(() => {
      expect(screen.getByText('Message sent successfully')).toBeInTheDocument();
    });
    
    expect(global.fetch).toHaveBeenCalledWith('/api/contact', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        name: 'John Doe',
        email: 'john@example.com',
        message: 'Hello, this is a test message'
      })
    });
  });

  it('should show validation errors', async () => {
    render(<ContactForm />);
    
    // Try to submit empty form
    fireEvent.click(screen.getByRole('button', { name: /send/i }));
    
    await waitFor(() => {
      expect(screen.getByText('Name is required')).toBeInTheDocument();
      expect(screen.getByText('Email is required')).toBeInTheDocument();
      expect(screen.getByText('Message is required')).toBeInTheDocument();
    });
  });
});

Client Component with Custom Hooks

// ✅ DO: Test components that use custom hooks
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import PostEditor from '@/components/PostEditor';

// Mock the custom hook
jest.mock('@/hooks/usePost', () => ({
  usePost: jest.fn()
}));

describe('PostEditor', () => {
  const mockUsePost = usePost as jest.Mock;

  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('should render post editor with data', () => {
    mockUsePost.mockReturnValue({
      post: { title: 'Test Post', content: 'Test content' },
      isLoading: false,
      error: null,
      updatePost: jest.fn(),
      savePost: jest.fn()
    });

    render(<PostEditor postId="test-post" />);
    
    expect(screen.getByDisplayValue('Test Post')).toBeInTheDocument();
    expect(screen.getByDisplayValue('Test content')).toBeInTheDocument();
  });

  it('should handle post updates', async () => {
    const mockUpdatePost = jest.fn();
    const mockSavePost = jest.fn();
    
    mockUsePost.mockReturnValue({
      post: { title: '', content: '' },
      isLoading: false,
      error: null,
      updatePost: mockUpdatePost,
      savePost: mockSavePost
    });

    render(<PostEditor postId="test-post" />);
    
    // Update title
    fireEvent.change(screen.getByLabelText(/title/i), {
      target: { value: 'New Title' }
    });
    
    expect(mockUpdatePost).toHaveBeenCalledWith({ title: 'New Title' });
    
    // Save post
    fireEvent.click(screen.getByRole('button', { name: /save/i }));
    
    await waitFor(() => {
      expect(mockSavePost).toHaveBeenCalled();
    });
  });

  it('should show loading state', () => {
    mockUsePost.mockReturnValue({
      post: null,
      isLoading: true,
      error: null,
      updatePost: jest.fn(),
      savePost: jest.fn()
    });

    render(<PostEditor postId="test-post" />);
    
    expect(screen.getByText(/loading/i)).toBeInTheDocument();
  });
});

Testing Next.js Specific Components

Testing Navigation Components

// ✅ DO: Test navigation with Next.js Link
import { render, screen } from '@testing-library/react';
import Navigation from '@/components/Navigation';

// Mock Next.js router
jest.mock('next/navigation', () => ({
  usePathname: jest.fn(),
  useRouter: jest.fn()
}));

describe('Navigation', () => {
  const mockUsePathname = usePathname as jest.Mock;
  const mockUseRouter = useRouter as jest.Mock;

  beforeEach(() => {
    jest.clearAllMocks();
    mockUsePathname.mockReturnValue('/');
    mockUseRouter.mockReturnValue({
      push: jest.fn(),
      replace: jest.fn(),
      back: jest.fn()
    });
  });

  it('should render navigation links', () => {
    render(<Navigation />);
    
    expect(screen.getByRole('link', { name: /home/i })).toBeInTheDocument();
    expect(screen.getByRole('link', { name: /blog/i })).toBeInTheDocument();
    expect(screen.getByRole('link', { name: /contact/i })).toBeInTheDocument();
  });

  it('should highlight active page', () => {
    mockUsePathname.mockReturnValue('/blog');
    
    render(<Navigation />);
    
    const blogLink = screen.getByRole('link', { name: /blog/i });
    expect(blogLink).toHaveClass('active');
  });
});

Testing Layout Components

// ✅ DO: Test layout components
import { render, screen } from '@testing-library/react';
import RootLayout from '@/app/layout';

describe('RootLayout', () => {
  it('should render layout with children', () => {
    render(
      <RootLayout>
        <div data-testid="child-content">Child content</div>
      </RootLayout>
    );
    
    expect(screen.getByTestId('child-content')).toBeInTheDocument();
    expect(screen.getByRole('banner')).toBeInTheDocument(); // Header
    expect(screen.getByRole('contentinfo')).toBeInTheDocument(); // Footer
  });

  it('should include metadata', () => {
    render(
      <RootLayout>
        <div>Content</div>
      </RootLayout>
    );
    
    // Check for meta tags (if using next/head or similar)
    expect(document.title).toBe('My Blog');
  });
});

Testing Error Boundaries

// ✅ DO: Test error boundaries
import { render, screen } from '@testing-library/react';
import ErrorBoundary from '@/components/ErrorBoundary';

describe('ErrorBoundary', () => {
  const ThrowError = () => {
    throw new Error('Test error');
  };

  beforeEach(() => {
    // Suppress console.error for expected errors
    jest.spyOn(console, 'error').mockImplementation(() => {});
  });

  afterEach(() => {
    (console.error as jest.Mock).mockRestore();
  });

  it('should catch and display errors', () => {
    render(
      <ErrorBoundary>
        <ThrowError />
      </ErrorBoundary>
    );
    
    expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
  });

  it('should render children when no error', () => {
    render(
      <ErrorBoundary>
        <div>Normal content</div>
      </ErrorBoundary>
    );
    
    expect(screen.getByText('Normal content')).toBeInTheDocument();
  });
});

Testing with Providers and Context

// ✅ DO: Test components with context providers
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '@/contexts/ThemeContext';
import ThemeToggle from '@/components/ThemeToggle';

describe('ThemeToggle', () => {
  const renderWithTheme = (component: React.ReactElement) => {
    return render(
      <ThemeProvider>
        {component}
      </ThemeProvider>
    );
  };

  it('should render theme toggle button', () => {
    renderWithTheme(<ThemeToggle />);
    
    expect(screen.getByRole('button', { name: /toggle theme/i })).toBeInTheDocument();
  });

  it('should toggle theme when clicked', () => {
    renderWithTheme(<ThemeToggle />);
    
    const toggleButton = screen.getByRole('button', { name: /toggle theme/i });
    fireEvent.click(toggleButton);
    
    // Check if theme changed (implementation dependent)
    expect(document.documentElement).toHaveClass('dark');
  });
});

Testing Async Components

// ✅ DO: Test async components properly
import { render, screen, waitFor } from '@testing-library/react';
import AsyncComponent from '@/components/AsyncComponent';

describe('AsyncComponent', () => {
  beforeEach(() => {
    global.fetch = jest.fn();
  });

  it('should show loading state initially', () => {
    (global.fetch as jest.Mock).mockImplementation(() => 
      new Promise(() => {}) // Never resolves
    );

    render(<AsyncComponent />);
    
    expect(screen.getByText(/loading/i)).toBeInTheDocument();
  });

  it('should render data when loaded', async () => {
    const mockData = { name: 'Test Data', value: 42 };
    (global.fetch as jest.Mock).mockResolvedValue({
      ok: true,
      json: () => Promise.resolve(mockData)
    });

    render(<AsyncComponent />);
    
    await waitFor(() => {
      expect(screen.getByText('Test Data')).toBeInTheDocument();
      expect(screen.getByText('42')).toBeInTheDocument();
    });
  });

  it('should show error when request fails', async () => {
    (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));

    render(<AsyncComponent />);
    
    await waitFor(() => {
      expect(screen.getByText(/error/i)).toBeInTheDocument();
    });
  });
});

Common Anti-Patterns to Avoid

❌ DON'T: Test Implementation Details

// ❌ WRONG: Testing internal state
it('should update internal state', () => {
  render(<Counter />);
  
  // Don't test internal state directly
  expect(component.state.count).toBe(0); // Implementation detail
});

❌ DON'T: Mock Everything

// ❌ WRONG: Over-mocking
jest.mock('@/components/Button');
jest.mock('@/components/Input');
jest.mock('@/components/Label');

// This tests nothing meaningful
const { getByText } = render(<ContactForm />);
expect(getByText('Submit')).toBeInTheDocument(); // Always passes

❌ DON'T: Test Third-Party Libraries

// ❌ WRONG: Testing library behavior
it('should use React Hook Form correctly', () => {
  render(<Form />);
  
  // Don't test that React Hook Form works
  expect(useForm).toHaveBeenCalled(); // Testing the library, not your code
});

Best Practices Summary

1. Test User Behavior, Not Implementation

// ✅ DO: Test what users see and do
const { getByRole, getByText } = render(<ContactForm />);
fireEvent.click(getByRole('button', { name: /submit/i }));
expect(getByText('Form submitted')).toBeInTheDocument();

2. Use Semantic Queries

// ✅ DO: Use accessible queries
getByRole('button', { name: /submit/i });
getByLabelText(/email address/i);
getByText(/welcome/i);

// ❌ DON'T: Use implementation queries
getByTestId('submit-button');
getByClassName('btn-primary');

3. Test Error States

// ✅ DO: Test error handling
it('should show error message on validation failure', async () => {
  render(<Form />);
  fireEvent.click(getByRole('button', { name: /submit/i }));
  await waitFor(() => {
    expect(getByText('Email is required')).toBeInTheDocument();
  });
});

4. Use Custom Render Functions

// ✅ DO: Create custom render for common providers
const renderWithProviders = (component: React.ReactElement) => {
  return render(
    <ThemeProvider>
      <AuthProvider>
        {component}
      </AuthProvider>
    </ThemeProvider>
  );
};

The Journey Continues

Mastering component testing in Next.js requires understanding the different component types and their testing needs. In the next post, we'll explore:

  • Post 5: Advanced Mocking Strategies and Best Practices

Quick Testing Checklist

For Server Components:

  • [ ] Mock data fetching functions
  • [ ] Test rendered output
  • [ ] Handle async data loading
  • [ ] Test error states

For Client Components:

  • [ ] Test user interactions
  • [ ] Mock external dependencies
  • [ ] Test state changes
  • [ ] Verify API calls

For All Components:

  • [ ] Use semantic queries
  • [ ] Test error boundaries
  • [ ] Avoid testing implementation details
  • [ ] Test accessibility

Remember: Test behavior, not implementation.


Next up: We'll explore advanced mocking strategies and best practices for complex testing scenarios.

testingnextjsreactcomponent-testingserver-componentsclient-componentsjesttesting-libraryuser-interactionsform-testingasync-testingerror-boundariescontext-testingaccessibility-testingbest-practicesjavascripttypescripttest-patterns