dev.syw

서버 컴포넌트·서버 액션을 포함한 테스트와 디버깅 전략을 수립하고 실전에 적용한다.

테스트와 디버깅: 단위·E2E·서버 컴포넌트 검증

입문편에서 서버 액션과 라우트 핸들러를 직접 작성하는 법을 익혔다면, 이제 그 코드가 실제로 의도대로 동작하는지 검증하고, 문제가 생겼을 때 빠르게 원인을 찾아내는 능력이 필요합니다. Next.js App Router는 서버 컴포넌트(RSC)·서버 액션·스트리밍 등 기존 SPA와 다른 실행 모델을 갖고 있기 때문에, 전통적인 React 테스트 전략을 그대로 적용하면 예상치 못한 함정에 빠집니다.

이 강에서는 Vitest/Jest로 서버 사이드 로직을 단위 테스트하고, React Testing Library로 클라이언트 인터랙션을 검증하며, Playwright로 전체 플로우를 E2E 테스트하는 방법을 체계적으로 다룹니다. 또한 MSW로 서버 액션·API를 모킹하는 통합 테스트 경계 설정, 소스맵·DevTools·서버 로그를 활용한 SSR/RSC 디버깅, 그리고 프로덕션 에러 추적까지 심화 전략을 살펴봅니다.

학습 목표

  • Vitest로 서버 컴포넌트와 async 유틸 함수를 단위 테스트하는 설정과 패턴을 이해한다.
  • React Testing Library로 클라이언트 컴포넌트의 인터랙션을 검증하는 방법을 익힌다.
  • Playwright로 라우팅·서버 액션·폼 제출 플로우를 E2E로 검증하는 전략을 수립한다.
  • **MSW(Mock Service Worker)**로 서버 액션과 라우트 핸들러를 모킹하고 통합 테스트 경계를 설정한다.
  • 소스맵·React DevTools·서버 로그를 조합해 SSR/RSC 디버깅과 프로덕션 에러 추적을 수행한다.

Vitest로 서버 컴포넌트·유틸 단위 테스트

왜 Vitest인가

Next.js 팀은 공식 문서에서 Vitest를 단위 테스트 도구로 우선 권장합니다. Jest와 API가 거의 동일하지만, ESM 네이티브 지원과 Vite 트랜스파일 파이프라인 덕분에 next/server, next/headers 같은 서버 전용 모듈을 훨씬 적은 설정으로 처리할 수 있습니다.

npm install -D vitest @vitejs/plugin-react vite-tsconfig-paths
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';

export default defineConfig({
  plugins: [react(), tsconfigPaths()],
  test: {
    environment: 'node',      // 서버 컴포넌트·유틸은 node 환경
    globals: true,
    setupFiles: ['./vitest.setup.ts'],
  },
});

async 서버 컴포넌트 테스트

서버 컴포넌트는 async 함수이므로 테스트에서 await로 렌더 결과를 받아야 합니다. 또한 next/headerscookies(), headers() 등은 요청 컨텍스트가 없으면 예외를 던지므로 반드시 모킹이 필요합니다. Next.js 15부터 cookies()·headers()는 비동기 함수로 Promise를 반환하므로, 컴포넌트에서 await cookies()로 호출하고 모킹도 Promise를 반환하도록 작성해야 합니다.

// app/dashboard/page.tsx (테스트 대상)
import { cookies } from 'next/headers';
import { getUserData } from '@/lib/user';

export default async function DashboardPage() {
  const cookieStore = await cookies();
  const token = cookieStore.get('auth_token')?.value;
  const user = token ? await getUserData(token) : null;

  return (
    <main>
      {user ? <h1>안녕하세요, {user.name}</h1> : <p>로그인이 필요합니다.</p>}
    </main>
  );
}
// __tests__/dashboard.test.tsx
import { vi, describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';

// ✅ next/headers 전체를 모킹 — 요청 컨텍스트 없이도 동작
//    Next 15+의 cookies()는 async이므로 Promise를 반환하도록 모킹
vi.mock('next/headers', () => ({
  cookies: vi.fn(async () => ({
    get: vi.fn((name: string) =>
      name === 'auth_token' ? { value: 'mock-token-123' } : undefined
    ),
  })),
  headers: vi.fn(async () => new Headers()),
}));

vi.mock('@/lib/user', () => ({
  getUserData: vi.fn().mockResolvedValue({ name: '홍길동', id: 1 }),
}));

// async 서버 컴포넌트 렌더링 헬퍼
async function renderAsync(Component: () => Promise<JSX.Element>) {
  const jsx = await Component();
  return render(jsx);
}

describe('DashboardPage', () => {
  it('토큰이 있으면 사용자 이름을 표시한다', async () => {
    const DashboardPage = (await import('@/app/dashboard/page')).default;
    await renderAsync(DashboardPage as any);
    expect(screen.getByText('안녕하세요, 홍길동')).toBeInTheDocument();
  });
});

⚠️ 주의 @testing-library/reactrender는 동기적입니다. async 컴포넌트를 바로 넘기면 Promise를 렌더하려 해 오류가 발생합니다. 반드시 await로 JSX를 먼저 resolve한 뒤 render에 전달하세요.

유틸 함수와 서버 액션 로직 단위 테스트

서버 액션의 핵심 비즈니스 로직은 별도의 순수 함수나 서비스 레이어로 분리하면 테스트가 훨씬 쉬워집니다.

// lib/validation.ts
export function validateEmail(email: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

export function sanitizeInput(input: string): string {
  return input.trim().replace(/<[^>]*>/g, '');
}
// __tests__/validation.test.ts
import { describe, it, expect } from 'vitest';
import { validateEmail, sanitizeInput } from '@/lib/validation';

describe('validateEmail', () => {
  it('유효한 이메일은 true를 반환한다', () => {
    expect(validateEmail('user@example.com')).toBe(true);
  });

  it('@ 없는 문자열은 false를 반환한다', () => {
    expect(validateEmail('not-an-email')).toBe(false);
  });
});

describe('sanitizeInput', () => {
  it('HTML 태그를 제거한다', () => {
    expect(sanitizeInput('<script>alert("xss")</script>hello')).toBe('hello');
  });

  it('앞뒤 공백을 제거한다', () => {
    expect(sanitizeInput('  hello  ')).toBe('hello');
  });
});

💡 TIP 서버 액션 함수 자체를 테스트할 때는 'use server' 지시어가 테스트 환경에서 무시되는 점을 활용하세요. 액션 파일을 직접 임포트해 함수를 호출하면 됩니다.


React Testing Library로 클라이언트 컴포넌트 인터랙션 테스트

클라이언트 컴포넌트('use client')는 브라우저 DOM 환경에서 동작하므로 jsdom 환경을 사용합니다. Vitest 설정에서 특정 파일 패턴에만 jsdom을 적용하거나, 별도 설정 파일로 분리할 수 있습니다.

// vitest.config.ts (클라이언트/서버 환경 분리)
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    projects: [
      {
        // 서버 컴포넌트·유틸
        include: ['**/__tests__/server/**'],
        environment: 'node',
        globals: true,
      },
      {
        // 클라이언트 컴포넌트
        include: ['**/__tests__/client/**'],
        environment: 'jsdom',
        globals: true,
        setupFiles: ['./vitest.setup.client.ts'],
      },
    ],
  },
});
// vitest.setup.client.ts
import '@testing-library/jest-dom';

폼 컴포넌트 인터랙션 테스트

// components/ContactForm.tsx
'use client';

import { useState } from 'react';

interface Props {
  onSubmit: (data: { name: string; message: string }) => Promise<void>;
}

export function ContactForm({ onSubmit }: Props) {
  const [status, setStatus] = useState<'idle' | 'loading' | 'success'>('idle');

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setStatus('loading');
    const form = e.currentTarget;
    const data = {
      name: (form.elements.namedItem('name') as HTMLInputElement).value,
      message: (form.elements.namedItem('message') as HTMLTextAreaElement).value,
    };
    await onSubmit(data);
    setStatus('success');
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" placeholder="이름" required />
      <textarea name="message" placeholder="메시지" required />
      <button type="submit" disabled={status === 'loading'}>
        {status === 'loading' ? '전송 중...' : '전송'}
      </button>
      {status === 'success' && <p role="status">전송 완료!</p>}
    </form>
  );
}
// __tests__/client/ContactForm.test.tsx
import { vi, describe, it, expect } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ContactForm } from '@/components/ContactForm';

describe('ContactForm', () => {
  it('제출 시 onSubmit이 올바른 데이터와 함께 호출된다', async () => {
    const user = userEvent.setup();
    const mockSubmit = vi.fn().mockResolvedValue(undefined);

    render(<ContactForm onSubmit={mockSubmit} />);

    await user.type(screen.getByPlaceholderText('이름'), '홍길동');
    await user.type(screen.getByPlaceholderText('메시지'), '안녕하세요');
    await user.click(screen.getByRole('button', { name: '전송' }));

    expect(mockSubmit).toHaveBeenCalledWith({
      name: '홍길동',
      message: '안녕하세요',
    });
  });

  it('제출 중에는 버튼이 비활성화된다', async () => {
    const user = userEvent.setup();
    // resolve되지 않는 Promise로 loading 상태 유지
    const mockSubmit = vi.fn(() => new Promise(() => {}));

    render(<ContactForm onSubmit={mockSubmit} />);
    await user.type(screen.getByPlaceholderText('이름'), '테스트');
    await user.type(screen.getByPlaceholderText('메시지'), '메시지');
    await user.click(screen.getByRole('button', { name: '전송' }));

    expect(screen.getByRole('button', { name: '전송 중...' })).toBeDisabled();
  });

  it('제출 완료 후 성공 메시지가 표시된다', async () => {
    const user = userEvent.setup();
    const mockSubmit = vi.fn().mockResolvedValue(undefined);

    render(<ContactForm onSubmit={mockSubmit} />);
    await user.type(screen.getByPlaceholderText('이름'), '테스트');
    await user.type(screen.getByPlaceholderText('메시지'), '메시지');
    await user.click(screen.getByRole('button'));

    await waitFor(() => {
      expect(screen.getByRole('status')).toHaveTextContent('전송 완료!');
    });
  });
});

💡 TIP userEvent는 실제 사용자의 키 입력·클릭·포커스 이벤트를 시뮬레이션합니다. fireEvent보다 현실적인 인터랙션을 재현하므로, 특히 접근성 속성(role, aria-*)과 연동되는 로직을 테스트할 때 차이가 두드러집니다.


Playwright E2E로 라우팅·서버 액션·폼 제출 플로우 검증

단위·통합 테스트로 로직을 검증했더라도, 실제 Next.js 서버가 올바르게 렌더링하고, 라우팅이 작동하며, 서버 액션이 DB와 연동되는지는 E2E 테스트에서만 확인할 수 있습니다.

npm install -D @playwright/test
npx playwright install
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',      // 실패 시 트레이스 저장
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],
  webServer: {
    // ✅ 테스트 전 Next.js 서버를 자동 시작
    command: 'npm run build && npm run start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});

서버 액션 폼 제출 플로우 E2E 테스트

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

test.describe('문의 폼 플로우', () => {
  test('폼 제출 후 성공 페이지로 리다이렉트된다', async ({ page }) => {
    await page.goto('/contact');

    // 폼 필드 입력
    await page.getByLabel('이름').fill('홍길동');
    await page.getByLabel('이메일').fill('hong@example.com');
    await page.getByLabel('메시지').fill('테스트 문의입니다.');

    // 서버 액션 응답 대기
    const responsePromise = page.waitForURL('/contact/success');
    await page.getByRole('button', { name: '문의 보내기' }).click();
    await responsePromise;

    await expect(page.getByRole('heading')).toContainText('문의가 접수되었습니다');
  });

  test('필수 필드 누락 시 에러 메시지가 표시된다', async ({ page }) => {
    await page.goto('/contact');
    await page.getByRole('button', { name: '문의 보내기' }).click();

    // 서버 액션의 유효성 검사 결과 확인
    await expect(page.getByText('이름을 입력해 주세요')).toBeVisible();
  });
});

인증이 필요한 페이지 테스트 — 스토리지 상태 재사용

E2E 테스트마다 로그인 플로우를 반복하면 느리고 불안정합니다. Playwright의 storageState를 활용해 인증 상태를 재사용하세요.

// e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import path from 'path';

const authFile = path.join(__dirname, '../.playwright/auth.json');

setup('관리자 로그인', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('이메일').fill('admin@example.com');
  await page.getByLabel('비밀번호').fill(process.env.TEST_ADMIN_PASSWORD!);
  await page.getByRole('button', { name: '로그인' }).click();
  await page.waitForURL('/dashboard');
  // 인증 상태(쿠키, localStorage) 저장
  await page.context().storageState({ path: authFile });
});
// playwright.config.ts 에 추가
projects: [
  { name: 'setup', testMatch: /auth\.setup\.ts/ },
  {
    name: 'authenticated',
    use: { storageState: '.playwright/auth.json' },
    dependencies: ['setup'],
  },
],

⚠️ 주의 webServer.command에서 프로덕션 빌드(build && start)를 사용하면 서버 액션의 실제 동작을 검증할 수 있지만 빌드 시간이 깁니다. 개발 중에는 next dev를 사용하되, CI에서는 반드시 프로덕션 빌드로 실행하세요.


MSW로 서버 액션·라우트 핸들러 모킹과 통합 테스트 경계

단위 테스트와 E2E 테스트 사이에는 통합 테스트 영역이 있습니다. 여러 컴포넌트가 함께 동작하되, 외부 DB나 실제 API 없이 테스트하고 싶을 때 MSW가 적합합니다.

npm install -D msw
// mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  // 라우트 핸들러 모킹
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: '홍길동', email: 'hong@example.com' },
      { id: 2, name: '김철수', email: 'kim@example.com' },
    ]);
  }),

  // POST 요청 모킹 (서버 액션이 내부적으로 호출하는 API 포함)
  http.post('/api/contact', async ({ request }) => {
    const body = await request.json() as Record<string, unknown>;
    if (!body.email) {
      return HttpResponse.json({ error: '이메일 필수' }, { status: 400 });
    }
    return HttpResponse.json({ success: true, id: 'mock-id-123' });
  }),
];
// mocks/server.ts (Node.js 환경 — 단위·통합 테스트용)
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
// vitest.setup.ts (전역 설정)
import { beforeAll, afterAll, afterEach } from 'vitest';
import { server } from '@/mocks/server';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());  // 테스트별 핸들러 오버라이드 초기화
afterAll(() => server.close());

통합 테스트 경계 설정 원칙

┌─────────────────────────────────────────────────────┐
│  단위 테스트 (Vitest/Jest)                            │
│  - 순수 함수, 유틸, 컴포넌트 렌더링                  │
│  - 모든 외부 의존성 모킹                             │
├─────────────────────────────────────────────────────┤
│  통합 테스트 (Vitest + MSW)                          │
│  - 여러 컴포넌트 + 상태 + 라우터 조합               │
│  - fetch 코드 경로는 실행, HTTP 경계서 응답 가로챔  │
│    (실제 네트워크 전송은 없음)                       │
│  - DB·외부 서비스는 모킹                             │
├─────────────────────────────────────────────────────┤
│  E2E 테스트 (Playwright)                             │
│  - 실제 Next.js 서버 + 브라우저                      │
│  - 가능한 한 실제 환경에 가깝게                      │
│  - 테스트 DB 또는 시드 데이터 사용                   │
└─────────────────────────────────────────────────────┘

💡 TIP 서버 액션은 HTTP 요청을 직접 보내지 않고 서버 내부에서 실행됩니다. 따라서 서버 액션을 MSW로 직접 인터셉트할 수는 없습니다. 대신 서버 액션 내부에서 호출하는 DB 레이어나 외부 API 클라이언트를 모킹하거나, E2E 테스트에서 검증하세요.


소스맵·React DevTools·서버 로그로 SSR/RSC 디버깅

소스맵 설정

프로덕션에서도 디버깅이 필요할 때는 next.config.js에서 소스맵을 활성화합니다.

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  // ✅ 프로덕션 브라우저 번들의 소스맵 생성 (번들 크기 증가에 주의)
  productionBrowserSourceMaps: true,
};

module.exports = nextConfig;
JavaScript

서버 소스맵은 개발 모드(next dev)에서 Next.js가 자동으로 생성하므로 별도 옵션이 필요 없습니다. (experimental.serverSourceMaps 같은 키는 유효한 설정이 아니어서 무시되고 경고가 발생할 수 있습니다.) 프로덕션에서 서버 측 스택 트레이스까지 디버깅하려면 productionBrowserSourceMaps로 클라이언트 소스맵을 켜거나, Sentry 등 에러 추적 서비스에 소스맵을 업로드하는 방식으로 처리합니다.

⚠️ 주의 productionBrowserSourceMaps: true는 소스 코드가 공개될 수 있습니다. 민감한 비즈니스 로직이 있는 경우 내부 에러 추적 서비스에서만 소스맵을 사용하고, 공개 배포에서는 비활성화하거나 SOURCEMAP_UPLOAD 방식을 사용하세요.

서버 컴포넌트 디버깅 — 서버 로그 활용

// app/products/page.tsx
export default async function ProductsPage() {
  // ✅ console.log는 서버 터미널에 출력됨 (브라우저 콘솔 X)
  console.log('[ProductsPage] 렌더링 시작:', new Date().toISOString());

  const products = await fetch('https://api.example.com/products', {
    next: { revalidate: 60 },
  }).then(r => {
    console.log('[ProductsPage] 응답 상태:', r.status, r.headers.get('x-cache'));
    return r.json();
  });

  console.log('[ProductsPage] 상품 수:', products.length);

  return <ProductList products={products} />;
}

Next.js는 개발 모드에서 서버 컴포넌트의 console.log를 터미널에 출력합니다. [컴포넌트명] 접두사를 붙이면 어느 컴포넌트에서 로그가 찍혔는지 쉽게 추적할 수 있습니다.

React DevTools로 RSC 페이로드 확인

크롬 확장 React DevTools를 설치하면 Components 탭에서 서버 컴포넌트와 클라이언트 컴포넌트를 구분해 볼 수 있습니다. 서버 컴포넌트는 props만 표시되고 상태(state)나 훅(hooks) 항목이 없습니다.

네트워크 탭에서 RSC 내비게이션 요청을 확인하면 RSC 페이로드를 직접 볼 수 있습니다. 이런 요청은 RSC: 1 요청 헤더로 식별되며, URL에 붙는 ?_rsc= 쿼리 파라미터의 값은 라우터가 캐시를 무력화(cache busting)하기 위해 붙이는 토큰입니다. 응답은 JSON이 아닌 특수한 직렬화 포맷으로, 서버에서 클라이언트로 전달되는 컴포넌트 트리의 스냅샷입니다.

# 개발 모드에서 RSC 페이로드 직접 확인 — RSC: 1 헤더로 페이로드 응답을 요청
curl "http://localhost:3000/dashboard" \
  -H "RSC: 1"

Node.js 인스펙터로 서버 컴포넌트 브레이크포인트

# package.json scripts
"dev:debug": "NODE_OPTIONS='--inspect' next dev"

npm run dev:debug 실행 후 Chrome에서 chrome://inspect를 열면 서버 사이드 코드에 브레이크포인트를 설정할 수 있습니다. 서버 액션 내부의 복잡한 로직을 단계별로 추적할 때 유용합니다.


프로덕션 에러 추적과 error.tsx 경계 회복(reset) 동작 검증

error.tsx 경계 동작 이해

error.tsx는 클라이언트 컴포넌트여야 하며, errorreset 두 props를 받습니다. reset()은 에러 경계를 재시도시켜 컴포넌트 트리를 다시 렌더링합니다.

// app/dashboard/error.tsx
'use client';

import { useEffect } from 'react';

interface ErrorProps {
  error: Error & { digest?: string };
  reset: () => void;
}

export default function DashboardError({ error, reset }: ErrorProps) {
  useEffect(() => {
    // ✅ 프로덕션 에러 추적 서비스에 리포트
    console.error('[DashboardError]', error.message, error.digest);
    // Sentry.captureException(error);
  }, [error]);

  return (
    <div role="alert">
      <h2>대시보드를 불러오는 중 오류가 발생했습니다.</h2>
      <p>{process.env.NODE_ENV === 'development' ? error.message : '잠시 후 다시 시도해 주세요.'}</p>
      {error.digest && (
        <code>오류 코드: {error.digest}</code>
      )}
      <button onClick={reset}>다시 시도</button>
    </div>
  );
}

💡 TIP error.digest는 Next.js가 서버 에러에 자동으로 붙이는 해시값입니다. 실제 에러 메시지는 서버 로그에만 남고 클라이언트에는 digest만 전달되어 보안상 민감한 정보가 노출되지 않습니다.

error.tsx reset 동작을 Playwright로 검증

// e2e/error-boundary.spec.ts
import { test, expect } from '@playwright/test';

test('에러 경계 reset이 컴포넌트를 재시도한다', async ({ page }) => {
  // 첫 요청은 500 에러, 두 번째는 성공하도록 모킹
  let requestCount = 0;
  await page.route('/api/dashboard-data', route => {
    requestCount++;
    if (requestCount === 1) {
      route.fulfill({ status: 500, body: 'Internal Server Error' });
    } else {
      route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({ stats: { users: 100 } }),
      });
    }
  });

  await page.goto('/dashboard');

  // 에러 경계 표시 확인
  await expect(page.getByRole('alert')).toBeVisible();
  await expect(page.getByRole('alert')).toContainText('오류가 발생했습니다');

  // reset 버튼 클릭 후 정상 데이터 표시 확인
  await page.getByRole('button', { name: '다시 시도' }).click();
  await expect(page.getByRole('alert')).not.toBeVisible();
  await expect(page.getByText('100')).toBeVisible();
});

프로덕션 에러 추적 통합 패턴

// lib/error-tracker.ts
export function trackError(error: Error, context?: Record<string, unknown>) {
  if (typeof window === 'undefined') {
    // 서버 사이드 에러
    console.error('[Server Error]', {
      message: error.message,
      stack: error.stack,
      context,
      timestamp: new Date().toISOString(),
    });
  } else {
    // 클라이언트 사이드 에러
    console.error('[Client Error]', {
      message: error.message,
      digest: (error as any).digest,
      context,
      url: window.location.href,
    });
  }
  // 실제 서비스: Sentry.captureException(error, { extra: context });
}
// app/global-error.tsx — 루트 레이아웃 자체의 에러 처리
'use client';

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <html>
      <body>
        <h1>예상치 못한 오류가 발생했습니다.</h1>
        <button onClick={reset}>앱 재시작</button>
      </body>
    </html>
  );
}
파일적용 범위특징
app/error.tsx루트 이하 모든 세그먼트layout.tsx는 포함 안 됨
app/[segment]/error.tsx해당 세그먼트만상위 layout은 유지됨
app/global-error.tsx루트 layout까지 포함<html>, <body> 직접 렌더링 필요

요약

  • Vitest는 Next.js 환경에서 ESM·서버 전용 모듈을 네이티브로 처리하므로 단위 테스트에 적합하다. async 서버 컴포넌트는 await로 JSX를 resolve한 뒤 render에 전달해야 한다.
  • React Testing Libraryjsdom 환경에서 클라이언트 컴포넌트의 인터랙션을 userEvent로 검증하며, waitFor로 비동기 상태 변화를 확인한다.
  • Playwright는 실제 Next.js 서버 위에서 라우팅·서버 액션·폼 제출 플로우를 E2E로 검증하며, storageState로 인증 상태를 재사용해 속도를 높인다.
  • MSW는 HTTP 경계에서 외부 의존성을 모킹해 통합 테스트를 가능하게 한다. 단, 서버 액션 자체는 HTTP 요청이 아니므로 내부 서비스 레이어 모킹이나 E2E로 검증한다.
  • 서버 컴포넌트 디버깅은 서버 터미널 로그, React DevTools Components 탭, RSC 페이로드 확인, Node.js 인스펙터 조합으로 접근한다.
  • error.tsxreset()은 에러 경계를 재시도시키며, error.digest로 클라이언트에 민감 정보를 노출하지 않고 서버 로그와 연결한다.

연습문제

  1. 아래 서버 컴포넌트를 Vitest로 단위 테스트하세요. next/headerscookies()를 모킹해 토큰이 없을 때 "로그인 필요" 텍스트가 렌더링되는지 확인하세요.

    // app/profile/page.tsx
    import { cookies } from 'next/headers';
    export default async function ProfilePage() {
      const token = (await cookies()).get('token')?.value;
      if (!token) return <p>로그인 필요</p>;
      return <p>프로필 페이지</p>;
    }
    

    힌트 Next 15+의 cookies()는 async이므로 모킹도 Promise를 반환해야 합니다. vi.mock('next/headers', () => ({ cookies: vi.fn(async () => ({ get: vi.fn(() => undefined) })) }))

  2. useCounter라는 커스텀 훅이 있습니다. increment 버튼을 3번 클릭하면 count가 3이 되는지 React Testing Library로 테스트하는 코드를 작성하세요.

    힌트 renderHookact를 조합하거나, 훅을 사용하는 컴포넌트를 직접 렌더링하는 방법 모두 가능합니다.

  3. Playwright로 /login 페이지에서 잘못된 비밀번호를 입력했을 때 에러 메시지가 표시되는지 검증하는 E2E 테스트를 작성하세요. 서버 액션에서 반환하는 에러 상태를 폼이 표시하는 시나리오를 가정합니다.

    힌트 page.waitForSelector보다 expect(locator).toBeVisible(){ timeout } 옵션 조합이 더 안정적입니다.

  4. MSW를 사용해 /api/products GET 요청을 모킹하고, ProductList 컴포넌트가 모킹된 데이터를 올바르게 렌더링하는지 통합 테스트를 작성하세요. 빈 배열이 반환될 때 "상품이 없습니다" 메시지가 표시되는 케이스도 포함하세요.

    힌트 server.use(http.get(...)) 으로 특정 테스트에서만 핸들러를 오버라이드할 수 있습니다.


💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

Next.js 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.