Testing React Server Components: RSC, Streaming, and Suspense Boundaries

Testing React Server Components: RSC, Streaming, and Suspense Boundaries

React Server Components (RSC) run exclusively on the server. They can read from databases, call internal APIs, and access the filesystem — none of which is available in a jsdom environment. This creates a fundamental mismatch: your existing client-side testing tools won't work out of the box.

This guide covers practical strategies for testing RSC: unit testing the server-side logic, integration testing rendered output, asserting streaming behaviour, and testing Suspense boundary fallbacks.

The Core Challenge

A React Server Component looks like this:

// app/dashboard/RecentOrders.tsx
import { db } from '@/lib/db'

interface Order {
  id: string
  customer: string
  total: number
  status: 'pending' | 'shipped' | 'delivered'
}

async function getRecentOrders(): Promise<Order[]> {
  return db.orders.findMany({
    orderBy: { createdAt: 'desc' },
    take: 5,
  })
}

export async function RecentOrders() {
  const orders = await getRecentOrders()

  return (
    <ul>
      {orders.map((order) => (
        <li key={order.id}>
          {order.customer} — ${order.total} — {order.status}
        </li>
      ))}
    </ul>
  )
}

You can't render this in jsdom. The async component pattern, the db call, and the server-only module boundaries all fail in a browser-like environment.

Strategy 1: Test the Data Layer Separately

The cleanest approach separates the data-fetching function from the component and tests it independently:

// app/dashboard/RecentOrders.ts (extracted data function)
import { db } from '@/lib/db'

export async function getRecentOrders() {
  return db.orders.findMany({
    orderBy: { createdAt: 'desc' },
    take: 5,
  })
}
// app/dashboard/RecentOrders.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { getRecentOrders } from './RecentOrders'

vi.mock('@/lib/db', () => ({
  db: {
    orders: {
      findMany: vi.fn(),
    },
  },
}))

import { db } from '@/lib/db'

describe('getRecentOrders', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('fetches the 5 most recent orders', async () => {
    const mockOrders = [
      { id: '1', customer: 'Alice', total: 99, status: 'delivered' },
      { id: '2', customer: 'Bob', total: 49, status: 'shipped' },
    ]
    vi.mocked(db.orders.findMany).mockResolvedValue(mockOrders)

    const result = await getRecentOrders()

    expect(db.orders.findMany).toHaveBeenCalledWith({
      orderBy: { createdAt: 'desc' },
      take: 5,
    })
    expect(result).toHaveLength(2)
    expect(result[0].customer).toBe('Alice')
  })

  it('returns empty array when no orders exist', async () => {
    vi.mocked(db.orders.findMany).mockResolvedValue([])

    const result = await getRecentOrders()

    expect(result).toHaveLength(0)
  })
})

This tests the business logic without needing to render the component.

Strategy 2: Use @testing-library/react with renderToString

For Next.js App Router components, you can test the rendered HTML using React's server rendering utilities:

npm install -D @testing-library/react react-dom
// app/dashboard/RecentOrders.test.tsx
import { renderToString } from 'react-dom/server'
import { describe, it, expect, vi } from 'vitest'
import { RecentOrders } from './RecentOrders'

vi.mock('@/lib/db', () => ({
  db: {
    orders: {
      findMany: vi.fn().mockResolvedValue([
        { id: '1', customer: 'Alice Chen', total: 120, status: 'delivered' },
        { id: '2', customer: 'Bob Lee', total: 45, status: 'pending' },
      ]),
    },
  },
}))

describe('RecentOrders RSC', () => {
  it('renders order list as HTML', async () => {
    const html = await renderToString(await RecentOrders())

    expect(html).toContain('Alice Chen')
    expect(html).toContain('$120')
    expect(html).toContain('delivered')
    expect(html).toContain('Bob Lee')
    expect(html).toContain('pending')
  })

  it('renders empty state when no orders', async () => {
    vi.mocked((await import('@/lib/db')).db.orders.findMany).mockResolvedValue([])

    const html = await renderToString(await RecentOrders())
    const parser = new DOMParser()
    const doc = parser.parseFromString(html, 'text/html')

    expect(doc.querySelectorAll('li')).toHaveLength(0)
  })
})

Note: await RecentOrders() calls the async function directly. The result is a React element you can pass to renderToString.

Strategy 3: Next.js Route Handler Testing

Server Components that fetch data via route handlers can be tested at the HTTP level. Next.js exposes route handlers as functions:

// app/api/orders/route.ts
import { db } from '@/lib/db'
import { NextResponse } from 'next/server'

export async function GET() {
  const orders = await db.orders.findMany({
    orderBy: { createdAt: 'desc' },
    take: 20,
  })
  return NextResponse.json(orders)
}
// app/api/orders/route.test.ts
import { describe, it, expect, vi } from 'vitest'
import { GET } from './route'

vi.mock('@/lib/db', () => ({
  db: {
    orders: {
      findMany: vi.fn().mockResolvedValue([
        { id: '1', customer: 'Alice', total: 99 },
      ]),
    },
  },
}))

describe('GET /api/orders', () => {
  it('returns orders as JSON', async () => {
    const response = await GET()
    const data = await response.json()

    expect(response.status).toBe(200)
    expect(data).toHaveLength(1)
    expect(data[0].customer).toBe('Alice')
  })
})

Testing Suspense Boundaries

Suspense boundaries define what users see while Server Component data loads. Testing them requires simulating the async boundary:

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { RecentOrders } from './RecentOrders'

export default function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>
      <Suspense fallback={<p>Loading orders…</p>}>
        <RecentOrders />
      </Suspense>
    </main>
  )
}

Testing the fallback state:

import { render, screen } from '@testing-library/react'
import { Suspense } from 'react'
import { describe, it, expect, vi } from 'vitest'

// A component that suspends indefinitely
function SuspendForever() {
  throw new Promise<never>(() => {})
}

describe('DashboardPage Suspense', () => {
  it('shows loading fallback while orders are loading', () => {
    render(
      <main>
        <h1>Dashboard</h1>
        <Suspense fallback={<p>Loading orders…</p>}>
          <SuspendForever />
        </Suspense>
      </main>
    )

    expect(screen.getByText('Loading orders…')).toBeInTheDocument()
    expect(screen.getByRole('heading', { name: 'Dashboard' })).toBeInTheDocument()
  })
})

For testing the resolved state, use a component that returns data immediately:

describe('DashboardPage resolved state', () => {
  it('shows orders after loading completes', async () => {
    vi.mock('./RecentOrders', () => ({
      RecentOrders: async () => (
        <ul>
          <li>Alice — $99 — delivered</li>
        </ul>
      ),
    }))

    const { default: DashboardPage } = await import('./page')

    render(<DashboardPage />)

    expect(await screen.findByText('Alice — $99 — delivered')).toBeInTheDocument()
  })
})

Testing Streaming Responses

Streaming is an HTTP-level concern — individual chunks arrive before the full response completes. You can test streaming output using react-dom/server's renderToReadableStream:

import { renderToReadableStream } from 'react-dom/server'
import { Suspense } from 'react'
import { describe, it, expect } from 'vitest'

async function collectStream(stream: ReadableStream<Uint8Array>): Promise<string> {
  const reader = stream.getReader()
  const decoder = new TextDecoder()
  const chunks: string[] = []

  while (true) {
    const { done, value } = await reader.read()
    if (done) break
    chunks.push(decoder.decode(value, { stream: true }))
  }

  return chunks.join('')
}

describe('streaming RSC output', () => {
  it('includes shell content before Suspense resolves', async () => {
    function SlowContent() {
      const data = use(slowDataPromise)
      return <p>{data}</p>
    }

    const slowDataPromise = new Promise<string>((resolve) =>
      setTimeout(() => resolve('Loaded content'), 10)
    )

    const stream = await renderToReadableStream(
      <main>
        <h1>Shell content</h1>
        <Suspense fallback={<p>Loading…</p>}>
          <SlowContent />
        </Suspense>
      </main>
    )

    const html = await collectStream(stream)

    // Shell content is present
    expect(html).toContain('Shell content')
    // Fallback is included in initial stream
    expect(html).toContain('Loading…')
  })
})

Integration Testing with Playwright

Unit tests cover component logic, but end-to-end tests verify the full rendering pipeline. For RSC, Playwright is the most reliable way to assert what a real user sees:

// e2e/dashboard.spec.ts
import { test, expect } from '@playwright/test'

test('dashboard shows recent orders', async ({ page }) => {
  await page.goto('/dashboard')

  // Verify the page doesn't just show the loading state forever
  await expect(page.getByText('Loading orders…')).toBeVisible()
  await expect(page.getByRole('list')).toBeVisible({ timeout: 5000 })

  // At least one order item rendered
  const items = page.getByRole('listitem')
  await expect(items).not.toHaveCount(0)
})

test('error boundary catches failing RSC', async ({ page }) => {
  // Simulate a failing data fetch by intercepting the API
  await page.route('**/api/orders', (route) =>
    route.fulfill({ status: 500, body: 'Server Error' })
  )

  await page.goto('/dashboard')

  // Error boundary renders fallback
  await expect(page.getByText(/something went wrong/i)).toBeVisible()
})

What the Test Suite Doesn't Cover

Even with thorough unit and integration tests, RSC testing gaps remain:

  • Real database latency — mocked db calls resolve instantly; production queries may take 200ms+
  • Cold start streaming — the first request after a server restart has different timing
  • Cache invalidation — Next.js's fetch cache means repeated RSC renders may serve stale data
  • Concurrent users — load patterns that affect streaming order

HelpMeTest monitors your deployed Next.js app continuously. When an RSC silently returns empty data or streaming stalls, automated tests running against your live URL catch it — not your users.

Summary

Testing React Server Components requires a layered approach:

  1. Extract and unit test the data-fetching functions with mocked dependencies
  2. Use renderToString or direct function calls to assert rendered HTML
  3. Test route handlers as HTTP functions directly
  4. Test Suspense boundaries using components that suspend or resolve immediately
  5. Use Playwright for end-to-end streaming and full render pipeline validation

The split between server and client in RSC is a design boundary — your tests should respect it by testing each side with the right tools.

Read more