테스팅
테스트 전략 개요
Lumie 프론트엔드는 두 계층의 테스 트를 운영합니다.
| 계층 | 도구 | 위치 | 실행 명령 |
|---|---|---|---|
| 유닛/컴포넌트 | Vitest + React Testing Library | src/**/*.{test,spec}.{ts,tsx} | npm run test:unit |
| E2E | Playwright | e2e/ | npm run test:e2e |
유닛/컴포넌트 테스트 (Vitest + RTL)
vitest.config.ts 기반으로 실행됩니다. 테스트 파일은 해당 소스 파일 옆(src/entities/student/model/schema.test.ts 등)에 위치합니다. FSD 레이어 규칙의 예외로, 테스트 파일(*.test.ts(x) / *.spec.ts(x))은 계약·통합 검증을 위해 레이어 간 import를 허용합니다.
npm run test:unit # 전체 유닛 테스트
E2E 테스트 구조 (Playwright)
e2e/
├── api/ # API 테스트 (백엔드 직접 호출)
│ ├── auth/
│ │ ├── login.spec.ts
│ │ ├── register.spec.ts
│ │ └── refresh.spec.ts
│ └── exam/
│ ├── exam.spec.ts
│ └── result.spec.ts
├── flows/ # 사용자 플로우 테스트 (MonolithApiClient)
│ ├── exam-lifecycle.spec.ts
│ ├── public-pages.spec.ts
│ └── student-detail-orval.spec.ts
├── ui/ # 브라우저 UI 테스트 (Chromium)
├── ssr/ # SSR 회귀 테스트 (CSR 렌더링 검증 포함)
│ ├── auth.setup.ts # owner.json + student.json 인증 저장
│ └── ssr-regression.spec.ts
├── config/
│ ├── environments.ts
│ ├── global-setup.ts
│ └── global-teardown.ts
├── fixtures/
│ └── test-data.ts
└── utils/
├── api-client.ts # 레거시 LumieApiClient (MSA 토폴로지용)
└── monolith-client.ts # MonolithApiClient (현재 모놀리스용)
Playwright 프로젝트 구성
playwright.config.ts에 5개의 프로젝트가 정의되어 있습니다.
projects: [
{ name: 'api', testDir: './e2e/api' }, // 백엔드 API 직접 호출
{ name: 'flows', testDir: './e2e/flows' }, // 사용자 플로우
{ name: 'chromium', testDir: './e2e/ui', // 브라우저 UI (PW_UI=1 필요)
use: { ...devices['Desktop Chrome'], baseURL: 'http://localhost:3000' } },
{ name: 'ssr-setup', testDir: './e2e/ssr', // 인증 한 번만 수행
testMatch: '**/auth.setup.ts' },
{ name: 'ssr', testDir: './e2e/ssr', // SSR/CSR 회귀 테스트
testMatch: '**/*.spec.ts', dependencies: ['ssr-setup'] },
]
UI/SSR 프로젝트는 실행 중인 Next.js 앱이 필요합니다. api / flows 프로젝트는 브라우저 없이 백엔드에 직접 요청합니다.
모놀리스 API 클라이언트 (MonolithApiClient)
백엔드는 단일 Spring Boot 모놀리스입니다. flows/ 테스트는 MonolithApiClient를 사용합니다.
- 단일 베이스 URL (
LUMIE_API_URL또는 기본값http://localhost:8080) - 쿠키 기반 인증(httpOnly) — Bearer 토큰 아님. Playwright
APIRequestContext가 쿠키 저장소를 자동 관리 - 테넌트 격리는
X-Tenant-Slug헤더로 처리 academy개념은 제거됨. 학생은 테넌트 단위로 범위가 지정됨
// e2e/utils/monolith-client.ts (발췌)
export class MonolithApiClient {
private context: APIRequestContext;
private baseUrl: string;
constructor() {
this.baseUrl = getMonolithBaseUrl();
}
async init(): Promise<void> {
this.context = await request.newContext({
baseURL: this.baseUrl,
extraHTTPHeaders: { 'X-Tenant-Slug': TEST_TENANT_SLUG },
});
}
async login(payload: { userLoginId: string; password: string }) {
return this.context.post('/v1/login', { data: payload });
}
async createStudent(payload: StudentPayload) {
return this.context.post('/v1/students', { data: payload });
}
}
SSR 회귀 테스트 (CSR 검증)
e2e/ssr/ssr-regression.spec.ts는 auth 라우트(admin/dashboard)가 올바르게 CSR로 동작하는지 검증합니다.
검증 항목:
- 클라이언트 패치 발생: 페이지 탐색 + hydration 중 브라우저에서
GET /api/v1/<resource>가 ≥1회 호출됨 - 데이터 렌더링: 클라이언트 패치 완료 후 엔티티 데이터 또는 빈 상태 메시지가 DOM에 존재
- Hydration 불일치 없음: 콘솔에 "did not match" / "Hydration failed" 경고 없음
- URL 상태 라운드트립: 필터/정렬/페이지 상태가 브라우저 앞뒤로 탐색 후 유지됨
- 미인증 안전 분기: 쿠키 없이 접근 시 충돌이나 데이터 유출 없이 로그인으로 리다이렉트
실행 방법:
TEST_CREDS_LOGIN_ID=<owner-login-id> \
TEST_CREDS_PASSWORD='<password>' \
TEST_TENANT_SLUG=demo \
BASE_URL=http://localhost:3000 \
npx playwright test --project=ssr
환경별 테스트 설정
지원 환경
| 환경 | 설명 |
|---|---|
local | 로컬 개발 서버 (포트 기반) |
docker | Docker Compose 환경 |
k3s | Kubernetes 클러스터 |
mirrord | Mirrord로 클러스터 연결 |
ci | CI 환경 자동 감지 |
TEST_ENV 환경 변수로 선택합니다.
테스트 실행
# 유닛/컴포넌트 테스트 (Vitest + RTL)
npm run test:unit
# 전체 E2E 테스트
npm run test:e2e
# API 테스트만
npm run test:e2e:api
# 플로우 테스트만
npm run test:e2e:flows
# 로컬 개발 서버 대상
npm run test:e2e:local
# Kubernetes 클러스터 대상
npm run test:e2e:k3s
# Mirrord 환경
npm run test:e2e:mirrord
# HTML 리포트 확인
npm run test:e2e:report
# SSR/CSR 회귀 테스트 (실행 중인 FE + BE 필요)
npx playwright test --project=ssr
# 특정 파일만
npx playwright test e2e/api/auth/login.spec.ts
# 디버그 모드
npx playwright test --debug
테스트 데이터 팩토리
// e2e/fixtures/test-data.ts
export const TEST_TENANT_SLUG = 'test-academy';
export function generateUniqueEmail(prefix = 'test'): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}@example.com`;
}
export function createTestStudent(overrides = {}) {
return {
name: `Test Student ${Date.now()}`,
phone: '010-1234-5678',
...overrides,
};
}
export function createTestExam(overrides = {}) {
return {
name: `Test Exam ${Date.now()}`,
category: 'GRADED' as const,
totalQuestions: 5,
passScore: 60,
...overrides,
};
}