Files
2025-11-29 18:29:30 +08:00

11 KiB

Server Components Testing

Testing patterns for React 19 Server Components, Suspense, and async rendering.

Testing Async Server Components

Basic Async Component

// src/components/AsyncUserProfile.tsx
export async function AsyncUserProfile({ userId }: { userId: string }) {
  // Fetch data in Server Component
  const response = await fetch(`/api/users/${userId}`);
  const user = await response.json();

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

Test Suite

// src/components/AsyncUserProfile.test.tsx
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
import { render, screen } from '@testing-library/react';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { AsyncUserProfile } from './AsyncUserProfile';

const server = setupServer(
  http.get('/api/users/:userId', ({ params }) => {
    return HttpResponse.json({
      id: params.userId,
      name: 'Alice Johnson',
      email: 'alice@example.com',
    });
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe('AsyncUserProfile', () => {
  it('renders user data after loading', async () => {
    // Render async component
    const component = await AsyncUserProfile({ userId: '123' });
    render(component);

    expect(screen.getByText('Alice Johnson')).toBeInTheDocument();
    expect(screen.getByText('alice@example.com')).toBeInTheDocument();
  });
});

Testing with Suspense Boundaries

Component with Suspense

// src/app/users/[userId]/page.tsx
import { Suspense } from 'react';
import { AsyncUserProfile } from '@/components/AsyncUserProfile';

export default function UserProfilePage({ params }: { params: { userId: string } }) {
  return (
    <Suspense fallback={<div>Loading user profile...</div>}>
      <AsyncUserProfile userId={params.userId} />
    </Suspense>
  );
}

Test Suite

// src/app/users/[userId]/page.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import UserProfilePage from './page';

describe('UserProfilePage', () => {
  it('shows loading state initially', () => {
    render(<UserProfilePage params={{ userId: '123' }} />);

    expect(screen.getByText('Loading user profile...')).toBeInTheDocument();
  });

  it('renders user profile after loading', async () => {
    render(<UserProfilePage params={{ userId: '123' }} />);

    // Wait for async component to resolve
    expect(await screen.findByText('Alice Johnson')).toBeInTheDocument();
  });
});

Testing Streaming Rendering

Streaming Component

// src/components/StreamingUserList.tsx
export async function StreamingUserList() {
  const response = await fetch('/api/users', {
    // Enable streaming
    next: { revalidate: 60 },
  });
  const users = await response.json();

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>
          <Suspense fallback={<div>Loading {user.name}...</div>}>
            <UserCard userId={user.id} />
          </Suspense>
        </li>
      ))}
    </ul>
  );
}

Test Suite

// src/components/StreamingUserList.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { StreamingUserList } from './StreamingUserList';

describe('StreamingUserList', () => {
  it('progressively renders user cards', async () => {
    const component = await StreamingUserList();
    render(component);

    // All users should eventually appear
    expect(await screen.findByText('Alice Johnson')).toBeInTheDocument();
    expect(await screen.findByText('Bob Smith')).toBeInTheDocument();
  });

  it('shows loading placeholders while streaming', () => {
    render(<Suspense fallback={<div>Loading list...</div>}><StreamingUserList /></Suspense>);

    expect(screen.getByText('Loading list...')).toBeInTheDocument();
  });
});

Testing Server Actions

Server Action

// src/actions/createUser.ts
'use server';

import { revalidatePath } from 'next/cache';

export async function createUser(formData: FormData) {
  const name = formData.get('name') as string;
  const email = formData.get('email') as string;

  const response = await fetch('/api/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name, email }),
  });

  if (!response.ok) {
    throw new Error('Failed to create user');
  }

  const user = await response.json();

  // Revalidate users list
  revalidatePath('/users');

  return user;
}

Test Suite

// src/actions/createUser.test.ts
import { describe, it, expect, vi } from 'vitest';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { createUser } from './createUser';

// Mock revalidatePath
vi.mock('next/cache', () => ({
  revalidatePath: vi.fn(),
}));

const server = setupServer(
  http.post('/api/users', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json(
      { id: '123', ...body },
      { status: 201 }
    );
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe('createUser', () => {
  it('creates user via server action', async () => {
    const formData = new FormData();
    formData.append('name', 'Charlie');
    formData.append('email', 'charlie@example.com');

    const user = await createUser(formData);

    expect(user).toEqual({
      id: '123',
      name: 'Charlie',
      email: 'charlie@example.com',
    });
  });

  it('revalidates path after creation', async () => {
    const { revalidatePath } = await import('next/cache');

    const formData = new FormData();
    formData.append('name', 'Diana');
    formData.append('email', 'diana@example.com');

    await createUser(formData);

    expect(revalidatePath).toHaveBeenCalledWith('/users');
  });
});

Testing Form Actions

Form with Server Action

// src/components/UserForm.tsx
import { createUser } from '@/actions/createUser';

export function UserForm() {
  return (
    <form action={createUser}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" required />

      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" required />

      <button type="submit">Create User</button>
    </form>
  );
}

Test Suite

// src/components/UserForm.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserForm } from './UserForm';

// Mock the server action
vi.mock('@/actions/createUser', () => ({
  createUser: vi.fn(),
}));

describe('UserForm', () => {
  it('submits form with server action', async () => {
    const { createUser } = await import('@/actions/createUser');
    const user = userEvent.setup();

    render(<UserForm />);

    await user.type(screen.getByLabelText('Name'), 'Alice');
    await user.type(screen.getByLabelText('Email'), 'alice@example.com');
    await user.click(screen.getByRole('button', { name: 'Create User' }));

    expect(createUser).toHaveBeenCalledWith(expect.any(FormData));

    const formData = (createUser as any).mock.calls[0][0] as FormData;
    expect(formData.get('name')).toBe('Alice');
    expect(formData.get('email')).toBe('alice@example.com');
  });
});

Testing Error Boundaries

Error Boundary Component

// src/components/AsyncUserError.tsx
export async function AsyncUserError({ userId }: { userId: string }) {
  const response = await fetch(`/api/users/${userId}`);

  if (!response.ok) {
    throw new Error(`User ${userId} not found`);
  }

  const user = await response.json();
  return <div>{user.name}</div>;
}

// src/app/users/[userId]/error.tsx
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div role="alert">
      <h2>Something went wrong!</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

Test Suite

// src/app/users/[userId]/error.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Error from './error';

describe('Error Boundary', () => {
  it('displays error message', () => {
    const error = new Error('User not found');
    const reset = vi.fn();

    render(<Error error={error} reset={reset} />);

    expect(screen.getByRole('alert')).toHaveTextContent('User not found');
  });

  it('calls reset on try again', async () => {
    const user = userEvent.setup();
    const error = new Error('User not found');
    const reset = vi.fn();

    render(<Error error={error} reset={reset} />);

    await user.click(screen.getByRole('button', { name: 'Try again' }));

    expect(reset).toHaveBeenCalled();
  });
});

Testing Parallel Data Fetching

Parallel Async Components

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

async function UserStats() {
  const response = await fetch('/api/stats/users');
  const stats = await response.json();
  return <div>{stats.count} users</div>;
}

async function PostStats() {
  const response = await fetch('/api/stats/posts');
  const stats = await response.json();
  return <div>{stats.count} posts</div>;
}

export default function Dashboard() {
  return (
    <div>
      <Suspense fallback={<div>Loading user stats...</div>}>
        <UserStats />
      </Suspense>
      <Suspense fallback={<div>Loading post stats...</div>}>
        <PostStats />
      </Suspense>
    </div>
  );
}

Test Suite

// src/app/dashboard/page.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import Dashboard from './page';

describe('Dashboard', () => {
  it('loads components in parallel', async () => {
    render(<Dashboard />);

    // Both should load independently
    expect(await screen.findByText('150 users')).toBeInTheDocument();
    expect(await screen.findByText('300 posts')).toBeInTheDocument();
  });

  it('shows individual loading states', () => {
    render(<Dashboard />);

    expect(screen.getByText('Loading user stats...')).toBeInTheDocument();
    expect(screen.getByText('Loading post stats...')).toBeInTheDocument();
  });
});

Key Takeaways

  1. Async Components: Await component render before passing to render()
  2. Suspense: Test both fallback and resolved states
  3. Server Actions: Mock server actions and verify FormData
  4. Error Boundaries: Test error display and reset functionality
  5. Parallel Fetching: Each Suspense boundary loads independently

Next: Common Patterns | Previous: Best Practices