React Server Component Testing Strategies
React Server Components (RSC) are a genuinely new primitive — they run only on the server, can be async, and never ship JavaScript to the browser. They're also one of the hardest things to unit-test in a Next.js application.
The core problem: Jest runs in Node.js with jsdom, which simulates a browser. Server Components don't run in a browser context — they render to a stream on the server. The RSC payload format is not standard HTML. This mismatch means you can't simply render(<MyServerComponent />) in Jest and expect it to work the way Client Components do.
This guide explains why RSC testing is hard, what you can do with Jest, and when to reach for Playwright.
Why RSC Testing Is Hard
When you render a Server Component in Jest, a few things break:
- Async components — A Server Component can be
async. React's test renderer doesn't support async component functions yet (as of React 18 / early React 19). - Server-only imports — Server Components often import modules marked
server-only, which throw when imported in a browser or test environment. - Data fetching — Server Components fetch data during render. In tests, you'd need to mock
fetchat a low level, and the render itself may not await properly. - next/headers and next/cookies — These are server-only APIs that throw in jsdom.
The short version: the React team is working on better RSC testing primitives, but as of 2024, full RSC unit testing requires workarounds or integration tests.
Testing RSC Output with Render (Sync RSC)
Synchronous Server Components — those without async — work reasonably well in Jest. They don't fetch data and don't use server-only APIs, so they render like regular components.
// app/components/ProductCard.tsx
// This is a Server Component (no 'use client' directive)
interface Product {
id: string
name: string
price: number
currency: string
}
interface ProductCardProps {
product: Product
}
export function ProductCard({ product }: ProductCardProps) {
const formatted = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: product.currency,
}).format(product.price)
return (
<article className="product-card">
<h2>{product.name}</h2>
<p className="price">{formatted}</p>
</article>
)
}// app/components/ProductCard.test.tsx
import { render, screen } from '@testing-library/react'
import { ProductCard } from './ProductCard'
const mockProduct = {
id: 'prod_1',
name: 'Next.js In Action',
price: 39.99,
currency: 'USD',
}
describe('ProductCard', () => {
it('renders product name', () => {
render(<ProductCard product={mockProduct} />)
expect(screen.getByRole('heading', { name: 'Next.js In Action' })).toBeInTheDocument()
})
it('formats price with correct currency symbol', () => {
render(<ProductCard product={mockProduct} />)
expect(screen.getByText('$39.99')).toBeInTheDocument()
})
it('renders with EUR currency', () => {
render(<ProductCard product={{ ...mockProduct, price: 34.99, currency: 'EUR' }} />)
expect(screen.getByText('€34.99')).toBeInTheDocument()
})
})The ProductCard renders fine in Jest because it has no async logic and no server-only imports. The test verifies rendering logic and formatting.
Using next-router-mock for Router Context
Some Server Components import headers() or cookies() for reading request context, or their child Client Components use useRouter. The next-router-mock package provides a testable router implementation.
npm install --save-dev next-router-mockConfigure it in Jest by mapping the next/navigation module:
// jest.config.ts
const config: Config = {
moduleNameMapper: {
'^next/navigation$': '<rootDir>/node_modules/next-router-mock/dist/navigation.js',
'^@/(.*)$': '<rootDir>/src/$1',
},
}Or use it selectively in test files:
// __tests__/page.test.tsx
import { render, screen } from '@testing-library/react'
import mockRouter from 'next-router-mock'
import { MemoryRouterProvider } from 'next-router-mock/MemoryRouterProvider'
// Set up the initial route before testing
mockRouter.push('/products/123')
function renderWithRouter(ui: React.ReactElement) {
return render(ui, { wrapper: MemoryRouterProvider })
}For App Router specifically, next-router-mock has an experimental App Router adapter:
import { useRouter, usePathname } from 'next-router-mock/App'
// In tests:
import mockRouter from 'next-router-mock'
beforeEach(() => {
mockRouter.setCurrentUrl('/dashboard')
})Testing Async RSC (Practical Approach)
For async Server Components that fetch data, the cleanest strategy is to separate data fetching from rendering. Extract the data fetching into a function that you can mock, then test the rendering component with mock data.
// app/products/[id]/page.tsx
import { getProduct } from '@/lib/api'
import { ProductDetail } from '@/components/ProductDetail'
import { notFound } from 'next/navigation'
// Async Server Component — hard to test directly
export default async function ProductPage({
params,
}: {
params: { id: string }
}) {
const product = await getProduct(params.id)
if (!product) {
notFound()
}
return <ProductDetail product={product} />
}// app/components/ProductDetail.tsx
// Pure rendering — no async, no server APIs, easy to test
interface Product {
id: string
name: string
description: string
price: number
inStock: boolean
}
export function ProductDetail({ product }: { product: Product }) {
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>${product.price}</p>
{product.inStock ? (
<button>Add to Cart</button>
) : (
<p className="out-of-stock">Out of Stock</p>
)}
</div>
)
}// app/components/ProductDetail.test.tsx
import { render, screen } from '@testing-library/react'
import { ProductDetail } from './ProductDetail'
const mockProduct = {
id: '1',
name: 'Test Product',
description: 'A great product for testing',
price: 29.99,
inStock: true,
}
describe('ProductDetail', () => {
it('shows Add to Cart when in stock', () => {
render(<ProductDetail product={mockProduct} />)
expect(screen.getByRole('button', { name: 'Add to Cart' })).toBeInTheDocument()
expect(screen.queryByText('Out of Stock')).not.toBeInTheDocument()
})
it('shows Out of Stock when not in stock', () => {
render(<ProductDetail product={{ ...mockProduct, inStock: false }} />)
expect(screen.getByText('Out of Stock')).toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
})For the getProduct function itself, write unit tests that mock the HTTP layer:
// lib/api.test.ts
import { getProduct } from './api'
global.fetch = jest.fn()
describe('getProduct', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('returns product data on success', async () => {
const mockData = { id: '1', name: 'Test', price: 10, inStock: true }
;(fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockData,
})
const result = await getProduct('1')
expect(result).toEqual(mockData)
expect(fetch).toHaveBeenCalledWith('/api/products/1')
})
it('returns null when product is not found', async () => {
;(fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
status: 404,
})
const result = await getProduct('nonexistent')
expect(result).toBeNull()
})
})Mocking server-only Modules
If a component you want to test imports a server-only module (either explicitly or via a library), Jest will throw:
Error: This module cannot be imported from a Client Component module.Mock it out in your Jest setup or at the top of the test file:
// jest.setup.ts
jest.mock('server-only', () => ({}))Similarly, mock next/headers and next/cookies:
// __mocks__/next/headers.ts
export const headers = jest.fn(() => new Headers())
export const cookies = jest.fn(() => ({
get: jest.fn(),
getAll: jest.fn(() => []),
has: jest.fn(() => false),
}))Testing RSC with Playwright
For async Server Components that fetch data, Playwright integration tests are the most reliable approach. They test the full stack — data fetching, rendering, and hydration — against a running Next.js server.
// e2e/product-page.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Product page', () => {
test('displays product details from server', async ({ page }) => {
await page.goto('/products/1')
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
await expect(page.getByText('Add to Cart')).toBeVisible()
})
test('shows 404 page for missing product', async ({ page }) => {
const response = await page.goto('/products/nonexistent-id-99999')
expect(response?.status()).toBe(404)
await expect(page.getByText(/not found/i)).toBeVisible()
})
test('product data is rendered in the initial HTML (SSR)', async ({ page }) => {
const response = await page.goto('/products/1')
const html = await response?.text()
// The product name should be in the HTML before JS hydrates
expect(html).toContain('Product Name Here')
})
})Configure Playwright to start Next.js automatically:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
webServer: {
command: 'npm run build && npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
})For development, use npm run dev instead of build+start to get faster iteration:
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: true,
}Key Patterns
Separate data fetching from rendering. This is the single most important pattern for testable Server Components. Keep async data fetching in the page component, pass the data down to pure rendering components that are easy to test in isolation.
Mock server-only imports globally. Add jest.mock('server-only', () => ({})) to jest.setup.ts so any component that transitively imports it won't break in test.
Use Playwright for async RSC. Don't fight the testing framework. If a Server Component is async and fetches data, test it with Playwright against a running server. The unit test pyramid still applies — pure rendering logic in Jest, integration behavior in Playwright.
Test your data fetching functions separately. The getProduct, getUserProfile, or fetchDashboardData functions are plain async functions. Test them with mocked fetch in Jest, independent of any component.
React 19 and the future. The React team is actively working on better RSC testing support. Watch the React repository for updates to react-dom/test-utils and new react-server test utilities that will make async RSC testing in Jest practical.