TestingNext.js Testing

Next.js Testing

Learn how to test Next.js applications, including pages, API routes, Server Components, Client Components, Server Actions, and middleware.

Overview

Next.js applications require testing strategies for both server and client code. This guide covers:

  • Testing App Router and Pages Router
  • Server Components and Client Components
  • API Routes and Server Actions
  • Navigation and routing
  • Middleware and authentication

Next.js provides different rendering strategies (SSR, SSG, ISR, Client-side). Each requires specific testing approaches covered in this guide.

Setup and Configuration

Installation

# Core testing dependencies
npm install --save-dev vitest @vitejs/plugin-react jsdom
 
# Testing utilities
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event
 
# API testing
npm install --save-dev supertest @types/supertest
 
# MSW for API mocking
npm install --save-dev msw

Vitest Configuration

vitest.config.ts

import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
 
export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./vitest.setup.ts'],
    include: ['**/*.{test,spec}.{ts,tsx}'],
    exclude: ['**/node_modules/**', '**/e2e/**'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'vitest.setup.ts',
        '**/*.d.ts',
        '**/*.config.*',
        '**/e2e/**',
      ],
    },
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
      '@/components': path.resolve(__dirname, './src/components'),
      '@/lib': path.resolve(__dirname, './src/lib'),
      '@/app': path.resolve(__dirname, './src/app'),
    },
  },
})

vitest.setup.ts

import '@testing-library/jest-dom'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
 
// Cleanup after each test
afterEach(() => {
  cleanup()
})
 
// Mock Next.js router
vi.mock('next/navigation', () => ({
  useRouter: () => ({
    push: vi.fn(),
    replace: vi.fn(),
    prefetch: vi.fn(),
    back: vi.fn(),
    pathname: '/',
    query: {},
    asPath: '/',
  }),
  usePathname: () => '/',
  useSearchParams: () => new URLSearchParams(),
  useParams: () => ({}),
  notFound: vi.fn(),
  redirect: vi.fn(),
}))
 
// Mock Next.js Image
vi.mock('next/image', () => ({
  default: (props: any) => {
    // eslint-disable-next-line jsx-a11y/alt-text
    return <img {...props} />
  },
}))
 
// Mock Next.js Link
vi.mock('next/link', () => ({
  default: ({ children, href }: any) => {
    return <a href={href}>{children}</a>
  },
}))
 
// Setup environment variables
process.env.NEXT_PUBLIC_API_URL = 'http://localhost:3000'

Testing Pages (Pages Router)

Static Pages

// pages/about.tsx
import { GetStaticProps } from 'next'
 
interface AboutProps {
  company: string
  year: number
}
 
export default function About({ company, year }: AboutProps) {
  return (
    <div>
      <h1>About Us</h1>
      <p>
        © {year} {company}
      </p>
    </div>
  )
}
 
export const getStaticProps: GetStaticProps<AboutProps> = async () => {
  return {
    props: {
      company: 'Acme Corp',
      year: new Date().getFullYear(),
    },
  }
}
// pages/about.test.tsx
import { render, screen } from '@testing-library/react'
import About from './about'
 
describe('About Page', () => {
  it('renders page title', () => {
    render(<About company="Acme Corp" year={2024} />)
    
    expect(screen.getByRole('heading', { name: /about us/i })).toBeInTheDocument()
  })
 
  it('displays company information', () => {
    render(<About company="Acme Corp" year={2024} />)
    
    expect(screen.getByText(/© 2024 acme corp/i)).toBeInTheDocument()
  })
})
 
describe('getStaticProps', () => {
  it('returns correct props', async () => {
    const { getStaticProps } = await import('./about')
    
    const result = await getStaticProps({} as any)
    
    expect(result).toEqual({
      props: {
        company: 'Acme Corp',
        year: expect.any(Number),
      },
    })
  })
})

Server-Side Rendered Pages

// pages/user/[id].tsx
import { GetServerSideProps } from 'next'
 
interface User {
  id: string
  name: string
  email: string
}
 
interface UserPageProps {
  user: User | null
  error?: string
}
 
export default function UserPage({ user, error }: UserPageProps) {
  if (error) {
    return <div role="alert">Error: {error}</div>
  }
 
  if (!user) {
    return <div>User not found</div>
  }
 
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  )
}
 
export const getServerSideProps: GetServerSideProps<UserPageProps> = async (context) => {
  const { id } = context.params!
 
  try {
    const res = await fetch(`https://api.example.com/users/${id}`)
    
    if (!res.ok) {
      return {
        props: {
          user: null,
          error: 'User not found',
        },
      }
    }
 
    const user = await res.json()
 
    return {
      props: {
        user,
      },
    }
  } catch (error) {
    return {
      props: {
        user: null,
        error: 'Failed to fetch user',
      },
    }
  }
}
// pages/user/[id].test.tsx
import { render, screen } from '@testing-library/react'
import UserPage from './[id]'
 
describe('UserPage', () => {
  const mockUser = {
    id: '1',
    name: 'John Doe',
    email: 'john@example.com',
  }
 
  it('renders user information', () => {
    render(<UserPage user={mockUser} />)
    
    expect(screen.getByRole('heading', { name: /john doe/i })).toBeInTheDocument()
    expect(screen.getByText(/john@example.com/i)).toBeInTheDocument()
  })
 
  it('displays error message', () => {
    render(<UserPage user={null} error="User not found" />)
    
    expect(screen.getByRole('alert')).toHaveTextContent('Error: User not found')
  })
 
  it('displays not found message', () => {
    render(<UserPage user={null} />)
    
    expect(screen.getByText(/user not found/i)).toBeInTheDocument()
  })
})
 
describe('getServerSideProps', () => {
  beforeEach(() => {
    global.fetch = vi.fn()
  })
 
  afterEach(() => {
    vi.restoreAllMocks()
  })
 
  it('fetches and returns user data', async () => {
    const mockUser = { id: '1', name: 'John Doe', email: 'john@example.com' }
    
    ;(global.fetch as any).mockResolvedValueOnce({
      ok: true,
      json: async () => mockUser,
    })
 
    const { getServerSideProps } = await import('./[id]')
    
    const result = await getServerSideProps({
      params: { id: '1' },
    } as any)
 
    expect(result).toEqual({
      props: {
        user: mockUser,
      },
    })
  })
 
  it('handles fetch errors', async () => {
    ;(global.fetch as any).mockResolvedValueOnce({
      ok: false,
    })
 
    const { getServerSideProps } = await import('./[id]')
    
    const result = await getServerSideProps({
      params: { id: '1' },
    } as any)
 
    expect(result).toEqual({
      props: {
        user: null,
        error: 'User not found',
      },
    })
  })
})

Testing API Routes

Basic API Route Testing

// pages/api/hello.ts
import type { NextApiRequest, NextApiResponse } from 'next'
 
type ResponseData = {
  message: string
}
 
export default function handler(
  req: NextApiRequest,
  res: NextApiResponse<ResponseData>
) {
  if (req.method !== 'GET') {
    return res.status(405).json({ message: 'Method not allowed' })
  }
 
  res.status(200).json({ message: 'Hello World' })
}
// pages/api/hello.test.ts
import { createMocks } from 'node-mocks-http'
import handler from './hello'
 
describe('/api/hello', () => {
  it('returns hello message', async () => {
    const { req, res } = createMocks({
      method: 'GET',
    })
 
    await handler(req, res)
 
    expect(res._getStatusCode()).toBe(200)
    expect(res._getJSONData()).toEqual({
      message: 'Hello World',
    })
  })
 
  it('returns 405 for non-GET requests', async () => {
    const { req, res } = createMocks({
      method: 'POST',
    })
 
    await handler(req, res)
 
    expect(res._getStatusCode()).toBe(405)
    expect(res._getJSONData()).toEqual({
      message: 'Method not allowed',
    })
  })
})

Advanced API Route Testing

// pages/api/users/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
 
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const { id } = req.query
 
  if (typeof id !== 'string') {
    return res.status(400).json({ error: 'Invalid user ID' })
  }
 
  switch (req.method) {
    case 'GET':
      return getUser(id, res)
    case 'PUT':
      return updateUser(id, req, res)
    case 'DELETE':
      return deleteUser(id, res)
    default:
      return res.status(405).json({ error: 'Method not allowed' })
  }
}
 
async function getUser(id: string, res: NextApiResponse) {
  try {
    const user = await prisma.user.findUnique({ where: { id } })
    
    if (!user) {
      return res.status(404).json({ error: 'User not found' })
    }
 
    return res.status(200).json(user)
  } catch (error) {
    return res.status(500).json({ error: 'Internal server error' })
  }
}
 
async function updateUser(id: string, req: NextApiRequest, res: NextApiResponse) {
  try {
    const user = await prisma.user.update({
      where: { id },
      data: req.body,
    })
    
    return res.status(200).json(user)
  } catch (error) {
    return res.status(500).json({ error: 'Failed to update user' })
  }
}
 
async function deleteUser(id: string, res: NextApiResponse) {
  try {
    await prisma.user.delete({ where: { id } })
    return res.status(204).end()
  } catch (error) {
    return res.status(500).json({ error: 'Failed to delete user' })
  }
}
// pages/api/users/[id].test.ts
import { createMocks } from 'node-mocks-http'
import handler from './[id]'
import { prisma } from '@/lib/prisma'
 
vi.mock('@/lib/prisma', () => ({
  prisma: {
    user: {
      findUnique: vi.fn(),
      update: vi.fn(),
      delete: vi.fn(),
    },
  },
}))
 
describe('/api/users/[id]', () => {
  afterEach(() => {
    vi.clearAllMocks()
  })
 
  describe('GET', () => {
    it('returns user when found', async () => {
      const mockUser = { id: '1', name: 'John', email: 'john@example.com' }
      ;(prisma.user.findUnique as any).mockResolvedValue(mockUser)
 
      const { req, res } = createMocks({
        method: 'GET',
        query: { id: '1' },
      })
 
      await handler(req, res)
 
      expect(res._getStatusCode()).toBe(200)
      expect(res._getJSONData()).toEqual(mockUser)
    })
 
    it('returns 404 when user not found', async () => {
      ;(prisma.user.findUnique as any).mockResolvedValue(null)
 
      const { req, res } = createMocks({
        method: 'GET',
        query: { id: '999' },
      })
 
      await handler(req, res)
 
      expect(res._getStatusCode()).toBe(404)
      expect(res._getJSONData()).toEqual({ error: 'User not found' })
    })
 
    it('returns 500 on database error', async () => {
      ;(prisma.user.findUnique as any).mockRejectedValue(new Error('DB Error'))
 
      const { req, res } = createMocks({
        method: 'GET',
        query: { id: '1' },
      })
 
      await handler(req, res)
 
      expect(res._getStatusCode()).toBe(500)
    })
  })
 
  describe('PUT', () => {
    it('updates user', async () => {
      const mockUser = { id: '1', name: 'John Updated', email: 'john@example.com' }
      ;(prisma.user.update as any).mockResolvedValue(mockUser)
 
      const { req, res } = createMocks({
        method: 'PUT',
        query: { id: '1' },
        body: { name: 'John Updated' },
      })
 
      await handler(req, res)
 
      expect(res._getStatusCode()).toBe(200)
      expect(res._getJSONData()).toEqual(mockUser)
      expect(prisma.user.update).toHaveBeenCalledWith({
        where: { id: '1' },
        data: { name: 'John Updated' },
      })
    })
  })
 
  describe('DELETE', () => {
    it('deletes user', async () => {
      ;(prisma.user.delete as any).mockResolvedValue({})
 
      const { req, res } = createMocks({
        method: 'DELETE',
        query: { id: '1' },
      })
 
      await handler(req, res)
 
      expect(res._getStatusCode()).toBe(204)
      expect(prisma.user.delete).toHaveBeenCalledWith({ where: { id: '1' } })
    })
  })
})

Testing with Supertest

// __tests__/api/users.test.ts
import request from 'supertest'
import { createServer } from 'http'
import { apiResolver } from 'next/dist/server/api-utils/node'
import handler from '@/pages/api/users'
 
describe('Users API Integration', () => {
  let server: any
 
  beforeAll(() => {
    server = createServer((req, res) => {
      return apiResolver(
        req,
        res,
        undefined,
        handler,
        {
          previewModeEncryptionKey: '',
          previewModeId: '',
          previewModeSigningKey: '',
        },
        false
      )
    })
  })
 
  afterAll(() => {
    server.close()
  })
 
  it('GET /api/users returns users list', async () => {
    const response = await request(server)
      .get('/api/users')
      .expect('Content-Type', /json/)
      .expect(200)
 
    expect(response.body).toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          id: expect.any(String),
          name: expect.any(String),
        }),
      ])
    )
  })
 
  it('POST /api/users creates user', async () => {
    const newUser = {
      name: 'Jane Doe',
      email: 'jane@example.com',
    }
 
    const response = await request(server)
      .post('/api/users')
      .send(newUser)
      .expect('Content-Type', /json/)
      .expect(201)
 
    expect(response.body).toMatchObject(newUser)
    expect(response.body.id).toBeDefined()
  })
})

For API route testing, consider using node-mocks-http for unit tests and supertest for integration tests. Both approaches are valuable.

Testing Server Components (App Router)

Server Components render on the server and don’t have client-side interactivity. Test them by rendering with props.

// app/posts/page.tsx
import { prisma } from '@/lib/prisma'
 
interface Post {
  id: string
  title: string
  content: string
}
 
async function getPosts(): Promise<Post[]> {
  return await prisma.post.findMany({
    orderBy: { createdAt: 'desc' },
  })
}
 
export default async function PostsPage() {
  const posts = await getPosts()
 
  if (posts.length === 0) {
    return <p>No posts yet.</p>
  }
 
  return (
    <div>
      <h1>Blog Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>
            <h2>{post.title}</h2>
            <p>{post.content}</p>
          </li>
        ))}
      </ul>
    </div>
  )
}
// app/posts/page.test.tsx
import { render, screen } from '@testing-library/react'
import PostsPage from './page'
import { prisma } from '@/lib/prisma'
 
vi.mock('@/lib/prisma', () => ({
  prisma: {
    post: {
      findMany: vi.fn(),
    },
  },
}))
 
describe('PostsPage', () => {
  it('displays posts', async () => {
    const mockPosts = [
      { id: '1', title: 'First Post', content: 'Content 1' },
      { id: '2', title: 'Second Post', content: 'Content 2' },
    ]
    
    ;(prisma.post.findMany as any).mockResolvedValue(mockPosts)
 
    // Server Components return promises, so await them
    const PostsPageResolved = await PostsPage()
    render(PostsPageResolved)
 
    expect(screen.getByRole('heading', { name: /blog posts/i })).toBeInTheDocument()
    expect(screen.getByText(/first post/i)).toBeInTheDocument()
    expect(screen.getByText(/second post/i)).toBeInTheDocument()
  })
 
  it('shows empty state when no posts', async () => {
    ;(prisma.post.findMany as any).mockResolvedValue([])
 
    const PostsPageResolved = await PostsPage()
    render(PostsPageResolved)
 
    expect(screen.getByText(/no posts yet/i)).toBeInTheDocument()
  })
})

Testing Server Components with Search Params

// app/search/page.tsx
interface SearchPageProps {
  searchParams: { q?: string }
}
 
export default async function SearchPage({ searchParams }: SearchPageProps) {
  const query = searchParams.q || ''
 
  if (!query) {
    return <p>Enter a search query</p>
  }
 
  const results = await searchProducts(query)
 
  return (
    <div>
      <h1>Search Results for "{query}"</h1>
      {results.length === 0 ? (
        <p>No results found</p>
      ) : (
        <ul>
          {results.map(result => (
            <li key={result.id}>{result.name}</li>
          ))}
        </ul>
      )}
    </div>
  )
}
 
async function searchProducts(query: string) {
  // Simulated search
  const allProducts = [
    { id: 1, name: 'React Book' },
    { id: 2, name: 'Next.js Guide' },
  ]
  
  return allProducts.filter(p => 
    p.name.toLowerCase().includes(query.toLowerCase())
  )
}
// app/search/page.test.tsx
import { render, screen } from '@testing-library/react'
import SearchPage from './page'
 
describe('SearchPage', () => {
  it('shows prompt when no query', async () => {
    const PageResolved = await SearchPage({ searchParams: {} })
    render(PageResolved)
 
    expect(screen.getByText(/enter a search query/i)).toBeInTheDocument()
  })
 
  it('displays search results', async () => {
    const PageResolved = await SearchPage({ searchParams: { q: 'react' } })
    render(PageResolved)
 
    expect(screen.getByRole('heading', { name: /search results for "react"/i }))
      .toBeInTheDocument()
    expect(screen.getByText(/react book/i)).toBeInTheDocument()
  })
 
  it('shows no results message', async () => {
    const PageResolved = await SearchPage({ searchParams: { q: 'xyz' } })
    render(PageResolved)
 
    expect(screen.getByText(/no results found/i)).toBeInTheDocument()
  })
})

Testing Client Components (App Router)

Client Components use the 'use client' directive and support interactivity.

// app/components/Counter.tsx
'use client'
 
import { useState } from 'react'
 
export default function Counter({ initialValue = 0 }: { initialValue?: number }) {
  const [count, setCount] = useState(initialValue)
 
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(count - 1)}>Decrement</button>
      <button onClick={() => setCount(initialValue)}>Reset</button>
    </div>
  )
}
// app/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('renders with initial value', () => {
    render(<Counter initialValue={5} />)
    
    expect(screen.getByText(/count: 5/i)).toBeInTheDocument()
  })
 
  it('increments count', async () => {
    const user = userEvent.setup()
    render(<Counter />)
 
    await user.click(screen.getByRole('button', { name: /increment/i }))
    
    expect(screen.getByText(/count: 1/i)).toBeInTheDocument()
  })
 
  it('decrements count', async () => {
    const user = userEvent.setup()
    render(<Counter initialValue={5} />)
 
    await user.click(screen.getByRole('button', { name: /decrement/i }))
    
    expect(screen.getByText(/count: 4/i)).toBeInTheDocument()
  })
 
  it('resets to initial value', async () => {
    const user = userEvent.setup()
    render(<Counter initialValue={10} />)
 
    await user.click(screen.getByRole('button', { name: /increment/i }))
    expect(screen.getByText(/count: 11/i)).toBeInTheDocument()
 
    await user.click(screen.getByRole('button', { name: /reset/i }))
    expect(screen.getByText(/count: 10/i)).toBeInTheDocument()
  })
})

Testing Client Components with Forms

// app/components/ContactForm.tsx
'use client'
 
import { useState } from 'react'
 
interface FormData {
  name: string
  email: string
  message: string
}
 
export default function ContactForm({ onSubmit }: { onSubmit: (data: FormData) => void }) {
  const [formData, setFormData] = useState<FormData>({
    name: '',
    email: '',
    message: '',
  })
  const [errors, setErrors] = useState<Partial<FormData>>({})
  const [submitting, setSubmitting] = useState(false)
 
  const validate = (): boolean => {
    const newErrors: Partial<FormData> = {}
 
    if (!formData.name.trim()) {
      newErrors.name = 'Name is required'
    }
 
    if (!formData.email.trim()) {
      newErrors.email = 'Email is required'
    } else if (!/\S+@\S+\.\S+/.test(formData.email)) {
      newErrors.email = 'Email is invalid'
    }
 
    if (!formData.message.trim()) {
      newErrors.message = 'Message is required'
    }
 
    setErrors(newErrors)
    return Object.keys(newErrors).length === 0
  }
 
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
 
    if (!validate()) return
 
    setSubmitting(true)
    await onSubmit(formData)
    setSubmitting(false)
    
    // Reset form
    setFormData({ name: '', email: '', message: '' })
  }
 
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="name">Name</label>
        <input
          id="name"
          type="text"
          value={formData.name}
          onChange={e => setFormData({ ...formData, name: e.target.value })}
        />
        {errors.name && <span role="alert">{errors.name}</span>}
      </div>
 
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          value={formData.email}
          onChange={e => setFormData({ ...formData, email: e.target.value })}
        />
        {errors.email && <span role="alert">{errors.email}</span>}
      </div>
 
      <div>
        <label htmlFor="message">Message</label>
        <textarea
          id="message"
          value={formData.message}
          onChange={e => setFormData({ ...formData, message: e.target.value })}
        />
        {errors.message && <span role="alert">{errors.message}</span>}
      </div>
 
      <button type="submit" disabled={submitting}>
        {submitting ? 'Sending...' : 'Send Message'}
      </button>
    </form>
  )
}
// app/components/ContactForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ContactForm from './ContactForm'
 
describe('ContactForm', () => {
  it('submits form with valid data', async () => {
    const user = userEvent.setup()
    const handleSubmit = vi.fn().mockResolvedValue(undefined)
 
    render(<ContactForm onSubmit={handleSubmit} />)
 
    await user.type(screen.getByLabelText(/name/i), 'John Doe')
    await user.type(screen.getByLabelText(/email/i), 'john@example.com')
    await user.type(screen.getByLabelText(/message/i), 'Hello!')
 
    await user.click(screen.getByRole('button', { name: /send message/i }))
 
    await waitFor(() => {
      expect(handleSubmit).toHaveBeenCalledWith({
        name: 'John Doe',
        email: 'john@example.com',
        message: 'Hello!',
      })
    })
  })
 
  it('shows validation errors for empty fields', async () => {
    const user = userEvent.setup()
    const handleSubmit = vi.fn()
 
    render(<ContactForm onSubmit={handleSubmit} />)
 
    await user.click(screen.getByRole('button', { name: /send message/i }))
 
    expect(screen.getByText(/name is required/i)).toBeInTheDocument()
    expect(screen.getByText(/email is required/i)).toBeInTheDocument()
    expect(screen.getByText(/message is required/i)).toBeInTheDocument()
    expect(handleSubmit).not.toHaveBeenCalled()
  })
 
  it('validates email format', async () => {
    const user = userEvent.setup()
    const handleSubmit = vi.fn()
 
    render(<ContactForm onSubmit={handleSubmit} />)
 
    await user.type(screen.getByLabelText(/email/i), 'invalid-email')
    await user.click(screen.getByRole('button', { name: /send message/i }))
 
    expect(screen.getByText(/email is invalid/i)).toBeInTheDocument()
  })
 
  it('shows loading state while submitting', async () => {
    const user = userEvent.setup()
    const handleSubmit = vi.fn(() => new Promise(resolve => setTimeout(resolve, 100)))
 
    render(<ContactForm onSubmit={handleSubmit} />)
 
    await user.type(screen.getByLabelText(/name/i), 'John')
    await user.type(screen.getByLabelText(/email/i), 'john@example.com')
    await user.type(screen.getByLabelText(/message/i), 'Test')
 
    await user.click(screen.getByRole('button', { name: /send message/i }))
 
    expect(screen.getByRole('button', { name: /sending/i })).toBeDisabled()
 
    await waitFor(() => {
      expect(screen.getByRole('button', { name: /send message/i })).toBeInTheDocument()
    })
  })
})

Testing Server Actions

Server Actions are asynchronous functions that run on the server.

// app/actions/user-actions.ts
'use server'
 
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/prisma'
 
export async function createUser(formData: FormData) {
  const name = formData.get('name') as string
  const email = formData.get('email') as string
 
  if (!name || !email) {
    return { error: 'Name and email are required' }
  }
 
  try {
    const user = await prisma.user.create({
      data: { name, email },
    })
 
    revalidatePath('/users')
    return { success: true, user }
  } catch (error) {
    return { error: 'Failed to create user' }
  }
}
 
export async function deleteUser(userId: string) {
  try {
    await prisma.user.delete({
      where: { id: userId },
    })
 
    revalidatePath('/users')
    redirect('/users')
  } catch (error) {
    return { error: 'Failed to delete user' }
  }
}
// app/actions/user-actions.test.ts
import { createUser, deleteUser } from './user-actions'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
 
vi.mock('@/lib/prisma', () => ({
  prisma: {
    user: {
      create: vi.fn(),
      delete: vi.fn(),
    },
  },
}))
 
vi.mock('next/cache', () => ({
  revalidatePath: vi.fn(),
}))
 
vi.mock('next/navigation', () => ({
  redirect: vi.fn(),
}))
 
describe('User Actions', () => {
  describe('createUser', () => {
    it('creates user successfully', async () => {
      const mockUser = { id: '1', name: 'John', email: 'john@example.com' }
      ;(prisma.user.create as any).mockResolvedValue(mockUser)
 
      const formData = new FormData()
      formData.append('name', 'John')
      formData.append('email', 'john@example.com')
 
      const result = await createUser(formData)
 
      expect(result).toEqual({ success: true, user: mockUser })
      expect(prisma.user.create).toHaveBeenCalledWith({
        data: { name: 'John', email: 'john@example.com' },
      })
      expect(revalidatePath).toHaveBeenCalledWith('/users')
    })
 
    it('returns error for missing fields', async () => {
      const formData = new FormData()
      formData.append('name', 'John')
 
      const result = await createUser(formData)
 
      expect(result).toEqual({ error: 'Name and email are required' })
      expect(prisma.user.create).not.toHaveBeenCalled()
    })
 
    it('handles database errors', async () => {
      ;(prisma.user.create as any).mockRejectedValue(new Error('DB Error'))
 
      const formData = new FormData()
      formData.append('name', 'John')
      formData.append('email', 'john@example.com')
 
      const result = await createUser(formData)
 
      expect(result).toEqual({ error: 'Failed to create user' })
    })
  })
 
  describe('deleteUser', () => {
    it('deletes user and redirects', async () => {
      ;(prisma.user.delete as any).mockResolvedValue({})
 
      await deleteUser('1')
 
      expect(prisma.user.delete).toHaveBeenCalledWith({
        where: { id: '1' },
      })
      expect(revalidatePath).toHaveBeenCalledWith('/users')
      expect(redirect).toHaveBeenCalledWith('/users')
    })
 
    it('handles deletion errors', async () => {
      ;(prisma.user.delete as any).mockRejectedValue(new Error('Not found'))
 
      const result = await deleteUser('999')
 
      expect(result).toEqual({ error: 'Failed to delete user' })
    })
  })
})
⚠️

Server Actions run on the server and can’t access browser APIs. Mock next/cache and next/navigation utilities in your tests.

Testing Middleware

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
 
export function middleware(request: NextRequest) {
  const token = request.cookies.get('token')
  const { pathname } = request.nextUrl
 
  // Redirect to login if accessing protected route without token
  if (pathname.startsWith('/dashboard') && !token) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
 
  // Redirect to dashboard if accessing login with token
  if (pathname === '/login' && token) {
    return NextResponse.redirect(new URL('/dashboard', request.url))
  }
 
  // Add custom header
  const response = NextResponse.next()
  response.headers.set('x-custom-header', 'my-value')
 
  return response
}
 
export const config = {
  matcher: ['/dashboard/:path*', '/login'],
}
// middleware.test.ts
import { NextRequest, NextResponse } from 'next/server'
import { middleware } from './middleware'
 
describe('Middleware', () => {
  it('redirects to login for protected routes without token', () => {
    const request = new NextRequest(
      new URL('http://localhost:3000/dashboard')
    )
 
    const response = middleware(request)
 
    expect(response.status).toBe(307)
    expect(response.headers.get('location')).toBe('http://localhost:3000/login')
  })
 
  it('allows access to protected routes with token', () => {
    const request = new NextRequest(
      new URL('http://localhost:3000/dashboard')
    )
    request.cookies.set('token', 'valid-token')
 
    const response = middleware(request)
 
    expect(response.headers.get('x-custom-header')).toBe('my-value')
  })
 
  it('redirects to dashboard when accessing login with token', () => {
    const request = new NextRequest(
      new URL('http://localhost:3000/login')
    )
    request.cookies.set('token', 'valid-token')
 
    const response = middleware(request)
 
    expect(response.status).toBe(307)
    expect(response.headers.get('location')).toBe('http://localhost:3000/dashboard')
  })
 
  it('allows access to login without token', () => {
    const request = new NextRequest(
      new URL('http://localhost:3000/login')
    )
 
    const response = middleware(request)
 
    expect(response.headers.get('x-custom-header')).toBe('my-value')
  })
})

Testing Navigation

Mocking useRouter

// components/Navigation.tsx
'use client'
 
import { useRouter, usePathname } from 'next/navigation'
 
export default function Navigation() {
  const router = useRouter()
  const pathname = usePathname()
 
  return (
    <nav>
      <button
        onClick={() => router.push('/home')}
        className={pathname === '/home' ? 'active' : ''}
      >
        Home
      </button>
      <button
        onClick={() => router.push('/about')}
        className={pathname === '/about' ? 'active' : ''}
      >
        About
      </button>
      <button onClick={() => router.back()}>
        Go Back
      </button>
    </nav>
  )
}
// components/Navigation.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useRouter, usePathname } from 'next/navigation'
import Navigation from './Navigation'
 
vi.mock('next/navigation', () => ({
  useRouter: vi.fn(),
  usePathname: vi.fn(),
}))
 
describe('Navigation', () => {
  const mockPush = vi.fn()
  const mockBack = vi.fn()
 
  beforeEach(() => {
    ;(useRouter as any).mockReturnValue({
      push: mockPush,
      back: mockBack,
    })
    ;(usePathname as any).mockReturnValue('/')
  })
 
  afterEach(() => {
    vi.clearAllMocks()
  })
 
  it('navigates to home', async () => {
    const user = userEvent.setup()
    render(<Navigation />)
 
    await user.click(screen.getByRole('button', { name: /home/i }))
 
    expect(mockPush).toHaveBeenCalledWith('/home')
  })
 
  it('navigates to about', async () => {
    const user = userEvent.setup()
    render(<Navigation />)
 
    await user.click(screen.getByRole('button', { name: /about/i }))
 
    expect(mockPush).toHaveBeenCalledWith('/about')
  })
 
  it('goes back', async () => {
    const user = userEvent.setup()
    render(<Navigation />)
 
    await user.click(screen.getByRole('button', { name: /go back/i }))
 
    expect(mockBack).toHaveBeenCalled()
  })
 
  it('highlights active route', () => {
    ;(usePathname as any).mockReturnValue('/home')
    
    render(<Navigation />)
 
    expect(screen.getByRole('button', { name: /home/i })).toHaveClass('active')
    expect(screen.getByRole('button', { name: /about/i })).not.toHaveClass('active')
  })
})
// components/ProductCard.tsx
import Link from 'next/link'
 
interface Product {
  id: string
  name: string
  price: number
}
 
export default function ProductCard({ product }: { product: Product }) {
  return (
    <div>
      <h3>{product.name}</h3>
      <p>${product.price}</p>
      <Link href={`/products/${product.id}`}>
        View Details
      </Link>
    </div>
  )
}
// components/ProductCard.test.tsx
import { render, screen } from '@testing-library/react'
import ProductCard from './ProductCard'
 
describe('ProductCard', () => {
  const mockProduct = {
    id: '123',
    name: 'Test Product',
    price: 29.99,
  }
 
  it('renders product information', () => {
    render(<ProductCard product={mockProduct} />)
 
    expect(screen.getByText(/test product/i)).toBeInTheDocument()
    expect(screen.getByText(/\$29.99/i)).toBeInTheDocument()
  })
 
  it('renders link to product details', () => {
    render(<ProductCard product={mockProduct} />)
 
    const link = screen.getByRole('link', { name: /view details/i })
    expect(link).toHaveAttribute('href', '/products/123')
  })
})

Best Practices

1. Separate Server and Client Component Tests

// ✅ Good - Clear separation
// app/components/ServerComponent.test.tsx
describe('ServerComponent (Server)', () => {
  // Test server-side logic, data fetching
})
 
// app/components/ClientComponent.test.tsx
describe('ClientComponent (Client)', () => {
  // Test interactivity, state, events
})

2. Mock External Dependencies

// ✅ Good - Mock Prisma, fetch, and external APIs
vi.mock('@/lib/prisma')
vi.mock('@/lib/api-client')
 
beforeEach(() => {
  global.fetch = vi.fn()
})

3. Test Data Fetching Separately

// ✅ Good - Test data fetching logic in isolation
// lib/api.test.ts
describe('fetchUserData', () => {
  it('fetches user data', async () => {
    // Test the function directly
  })
})
 
// components/UserProfile.test.tsx
describe('UserProfile', () => {
  it('renders user data', () => {
    // Pass mock data as props
  })
})

4. Use MSW for API Mocking

import { setupServer } from 'msw/node'
import { rest } from 'msw'
 
const server = setupServer(
  rest.get('/api/users', (req, res, ctx) => {
    return res(ctx.json([{ id: '1', name: 'John' }]))
  })
)
 
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

5. Test Error States

it('handles API errors gracefully', async () => {
  ;(global.fetch as any).mockRejectedValueOnce(new Error('Network error'))
 
  const Component = await MyPage()
  render(Component)
 
  expect(screen.getByText(/error/i)).toBeInTheDocument()
})

6. Test Loading States

it('shows loading state', () => {
  const { rerender } = render(<MyComponent loading={true} />)
  
  expect(screen.getByText(/loading/i)).toBeInTheDocument()
  
  rerender(<MyComponent loading={false} data={mockData} />)
  
  expect(screen.queryByText(/loading/i)).not.toBeInTheDocument()
})

7. Test Redirects and Rewrites

it('redirects after successful action', async () => {
  const mockRedirect = vi.fn()
  vi.mocked(redirect).mockImplementation(mockRedirect)
 
  await myAction()
 
  expect(mockRedirect).toHaveBeenCalledWith('/success')
})

For comprehensive testing strategies, see Testing Fundamentals and Best Practices.