포스트

[이제와서 시작하는 Next.js 마스터하기 #12] 테스팅으로 안정성 확보하기

[이제와서 시작하는 Next.js 마스터하기 #12] 테스팅으로 안정성 확보하기

“테스트 없는 코드는 레거시!” - Jest와 Playwright로 안정성을 확보하세요!

🎯 이 글에서 배울 내용

  • Jest로 단위 테스트
  • React Testing Library로 컴포넌트 테스트
  • Playwright E2E 테스트
  • 테스트 Best Practices

예상 소요 시간: 30분 난이도: 중급


🧪 Jest 설정

Next.js 테스트는 크게 세 층으로 나눠 생각하면 편합니다.

테스트 종류 도구 예시 확인하는 것
단위 테스트 Jest 또는 Vitest 함수, 작은 컴포넌트
컴포넌트 테스트 React Testing Library 사용자가 보는 텍스트와 상호작용
E2E 테스트 Playwright 실제 브라우저 흐름

Next.js 공식 가이드에서는 Jest와 Vitest 모두를 다루며, App Router에서도 React Testing Library와 함께 사용할 수 있습니다. 다만 async Server Component는 단위 테스트 도구가 직접 다루기 까다로우므로, 비동기 서버 컴포넌트 흐름은 E2E 테스트로 확인하는 편이 안정적입니다.

1
npm install -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// jest.config.js
const nextJest = require('next/jest');

const createJestConfig = nextJest({
  dir: './',
});

const customJestConfig = {
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  testEnvironment: 'jest-environment-jsdom',
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/$1',
  },
};

module.exports = createJestConfig(customJestConfig);
1
2
// jest.setup.js
import '@testing-library/jest-dom';

user-event는 실제 사용자의 입력 흐름에 더 가깝게 이벤트를 발생시킵니다. 단순 클릭은 fireEvent로도 충분하지만, 입력, 탭 이동, 키보드 조작이 섞이면 userEvent를 기본으로 두는 편이 좋습니다.

Vitest를 선호한다면 다음 조합도 많이 씁니다.

1
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom vite-tsconfig-paths
1
2
3
4
5
6
7
8
9
10
11
// vitest.config.mts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';

export default defineConfig({
  plugins: [tsconfigPaths(), react()],
  test: {
    environment: 'jsdom',
  },
});

Jest는 Next.js 통합 설정이 익숙한 팀에 좋고, Vitest는 빠른 실행 속도와 Vite 생태계에 익숙한 팀에 잘 맞습니다. 어느 쪽을 고르든 테스트 목표는 같습니다. 구현 세부사항보다 사용자가 보는 결과를 검증하세요.


✅ 컴포넌트 테스트

1
2
3
4
5
6
7
8
// components/Button.tsx
export function Button({ onClick, children, disabled }) {
  return (
    <button onClick={onClick} disabled={disabled}>
      {children}
    </button>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// components/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';

describe('Button', () => {
  it('renders correctly', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByText('Click me')).toBeInTheDocument();
  });

  it('calls onClick when clicked', () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>Click me</Button>);

    fireEvent.click(screen.getByText('Click me'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('is disabled when disabled prop is true', () => {
    render(<Button disabled>Click me</Button>);
    expect(screen.getByText('Click me')).toBeDisabled();
  });
});

위 테스트는 동작은 확인하지만, 접근성 관점에서는 getByRole을 쓰는 편이 더 좋습니다.

1
2
3
4
5
6
7
8
it('calls onClick when clicked', () => {
  const handleClick = jest.fn();
  render(<Button onClick={handleClick}>저장</Button>);

  fireEvent.click(screen.getByRole('button', { name: '저장' }));

  expect(handleClick).toHaveBeenCalledTimes(1);
});

getByRole은 실제 사용자가 스크린 리더나 키보드로 접근할 수 있는 요소를 기준으로 찾습니다. 테스트가 통과한다는 것은 단순히 DOM이 있다는 뜻을 넘어, 사용자가 찾을 수 있는 버튼이라는 의미도 됩니다.

입력이 있는 폼은 이렇게 검증할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
import userEvent from '@testing-library/user-event';

it('유효한 이메일을 입력하면 저장 버튼을 누를 수 있다', async () => {
  const user = userEvent.setup();
  render(<ProfileForm onSubmit={jest.fn()} />);

  await user.type(screen.getByLabelText('이메일'), '[email protected]');

  expect(screen.getByRole('button', { name: '저장' })).toBeEnabled();
});

테스트가 컴포넌트 내부 state 이름을 알고 있다면 너무 깊이 들어간 신호입니다. 버튼, 라벨, 에러 메시지처럼 사용자가 실제로 만나는 요소를 기준으로 검증하세요.


🧩 App Router 테스트 포인트

App Router에서는 파일 위치에 따라 테스트 방식이 조금 달라집니다.

대상 추천 검증 방식 이유
Client Component React Testing Library 클릭, 입력, 표시 상태 확인이 쉽다
순수 함수 Jest/Vitest 날짜 계산, 가격 계산, 권한 분기처럼 빠르게 검증 가능
Route Handler 요청/응답 단위 테스트 API 응답 코드와 JSON 구조를 바로 확인 가능
Server Component Playwright 중심 비동기 렌더링과 서버 데이터 흐름을 실제 페이지에서 확인하는 편이 안정적
Server Action 순수 로직 분리 후 단위 테스트 + E2E 폼 제출과 리다이렉트는 브라우저 흐름까지 봐야 한다

Route Handler는 Request 객체를 직접 만들어 테스트할 수 있습니다.

1
2
3
4
// app/api/health/route.ts
export async function GET() {
  return Response.json({ ok: true });
}
1
2
3
4
5
6
7
8
9
10
// app/api/health/route.test.ts
import { GET } from './route';

it('health check 응답을 반환한다', async () => {
  const response = await GET();
  const body = await response.json();

  expect(response.status).toBe(200);
  expect(body).toEqual({ ok: true });
});

cookies(), headers(), 데이터베이스 클라이언트처럼 런타임 의존성이 강한 코드는 테스트하기 쉬운 함수로 한 번 감싸세요.

1
2
3
4
// lib/auth.ts
export function canViewDashboard(role: string | null) {
  return role === 'admin' || role === 'member';
}

이렇게 분리해두면 Proxy, Server Action, Route Handler 어디에서 호출하든 핵심 권한 규칙은 빠른 단위 테스트로 지킬 수 있습니다.


🧪 Next.js 모킹 기준

next/navigation을 직접 쓰는 Client Component는 테스트에서 라우터 동작을 가짜로 넣어야 합니다.

1
2
3
4
5
6
7
const push = jest.fn();

jest.mock('next/navigation', () => ({
  useRouter: () => ({ push }),
  usePathname: () => '/settings',
  useSearchParams: () => new URLSearchParams('tab=profile'),
}));

모킹은 최소화하세요. 모든 API를 가짜로 만들기 시작하면 테스트가 실제 앱과 멀어집니다. 좋은 기준은 이렇습니다.

  • 브라우저 API와 라우터처럼 테스트 환경에 없는 것만 모킹한다.
  • 데이터 변환, 권한 판단, validation은 순수 함수로 빼서 그대로 테스트한다.
  • 네트워크 요청은 컴포넌트 테스트에서는 MSW 같은 도구로 응답만 흉내 내고, 최종 흐름은 Playwright에서 확인한다.
  • Server Component 렌더링 자체를 억지로 단위 테스트하기보다, 중요한 페이지는 E2E smoke test를 둔다.

🎭 Playwright E2E 테스트

1
2
npm install -D @playwright/test
npx playwright install
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  use: {
    baseURL: 'http://localhost:3000',
  },
  webServer: {
    command: 'npm run build && npm run start',
    port: 3000,
    reuseExistingServer: !process.env.CI,
  },
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Authentication', () => {
  test('should login successfully', async ({ page }) => {
    await page.goto('/login');

    await page.fill('input[name="email"]', '[email protected]');
    await page.fill('input[name="password"]', 'password123');
    await page.click('button[type="submit"]');

    await expect(page).toHaveURL('/dashboard');
    await expect(page.locator('text=Welcome')).toBeVisible();
  });

  test('should show error with invalid credentials', async ({ page }) => {
    await page.goto('/login');

    await page.fill('input[name="email"]', '[email protected]');
    await page.fill('input[name="password"]', 'wrongpassword');
    await page.click('button[type="submit"]');

    await expect(page.locator('text=Invalid credentials')).toBeVisible();
  });
});

개발 중에는 npm run dev로 빠르게 확인해도 됩니다. 하지만 CI에서는 npm run build && npm run start처럼 production build를 대상으로 돌리는 편이 실제 배포 환경에 더 가깝습니다.

테스트 스크립트는 이렇게 나눠두면 좋습니다.

1
2
3
4
5
6
7
8
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui"
  }
}

CI에서는 최소한 npm run test, npm run build, npm run test:e2e를 분리해 실패 지점을 알아보기 쉽게 만드세요.

GitHub Actions에서는 브라우저 설치 캐시와 리포트를 남기면 디버깅이 쉬워집니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
name: nextjs-tests

on:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run test
      - run: npm run build
      - run: npx playwright install --with-deps chromium
      - run: npm run test:e2e
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report

처음부터 모든 브라우저를 돌릴 필요는 없습니다. PR마다 Chromium 1개로 핵심 흐름을 검증하고, nightly나 배포 전 job에서 Chromium/Firefox/WebKit 전체 조합을 돌리는 방식도 현실적입니다.


🧭 무엇부터 테스트할까?

처음부터 모든 코드를 테스트하려고 하면 금방 지칩니다. 아래 순서로 시작하면 체감 효과가 큽니다.

  1. 결제, 로그인, 회원가입처럼 실패 비용이 큰 흐름
  2. 여러 조건이 섞인 순수 함수
  3. 공용 UI 컴포넌트
  4. 배포 전 반드시 깨지면 안 되는 페이지 이동

테스트 이름은 구현 방식보다 사용자 행동을 드러내야 합니다.

1
2
나쁜 이름: calls submitHandler
좋은 이름: 유효한 이메일과 비밀번호로 로그인하면 대시보드로 이동한다

이렇게 적으면 테스트가 문서 역할도 하게 됩니다.


🎯 오늘 배운 내용 정리

  1. 단위 테스트
    • Jest 설정
    • React Testing Library
  2. E2E 테스트
    • Playwright
    • 사용자 시나리오 테스트
  3. 테스트 전략
    • 비동기 Server Component는 E2E로 확인
    • CI에서는 production build 기준으로 검증
    • 사용자 행동 중심 이름과 접근성 쿼리 사용
    • Route Handler와 권한 규칙은 작고 빠른 단위 테스트로 분리
    • Playwright 리포트와 trace는 실패할 때만 저장해 CI 시간을 관리

📚 시리즈 네비게이션


“테스트는 자신감입니다!” ✅

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.