[이제와서 시작하는 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 전체 조합을 돌리는 방식도 현실적입니다.
🧭 무엇부터 테스트할까?
처음부터 모든 코드를 테스트하려고 하면 금방 지칩니다. 아래 순서로 시작하면 체감 효과가 큽니다.
- 결제, 로그인, 회원가입처럼 실패 비용이 큰 흐름
- 여러 조건이 섞인 순수 함수
- 공용 UI 컴포넌트
- 배포 전 반드시 깨지면 안 되는 페이지 이동
테스트 이름은 구현 방식보다 사용자 행동을 드러내야 합니다.
1
2
나쁜 이름: calls submitHandler
좋은 이름: 유효한 이메일과 비밀번호로 로그인하면 대시보드로 이동한다
이렇게 적으면 테스트가 문서 역할도 하게 됩니다.
🎯 오늘 배운 내용 정리
- 단위 테스트
- Jest 설정
- React Testing Library
- E2E 테스트
- Playwright
- 사용자 시나리오 테스트
- 테스트 전략
- 비동기 Server Component는 E2E로 확인
- CI에서는 production build 기준으로 검증
- 사용자 행동 중심 이름과 접근성 쿼리 사용
- Route Handler와 권한 규칙은 작고 빠른 단위 테스트로 분리
- Playwright 리포트와 trace는 실패할 때만 저장해 CI 시간을 관리
📚 시리즈 네비게이션
“테스트는 자신감입니다!” ✅
