test(e2e): Playwright E2E 도입 — 로그인→강좌→퀴즈 + 멘토/감사로그
실제 브라우저로 '다 조립된 앱'(프론트+백엔드+DB)을 태우는 통합 테스트 6개: - 인증 가드(비로그인→로그인), 로그인 실패 안내, 학생 로그인→대시보드 - 학습 센터→강좌 열기, 퀴즈 풀기→제출→서버 채점 결과 - 멘토 로그인→멘토 화면+개인정보 접근 기록(SEC-02 감사로그까지 검증) 운영 무오염 설계: - deploy/docker-compose.e2e.yml — 별도 프로젝트(mirime2e)·네트워크·휘발성 DB(tmpfs) - deploy/Caddyfile.e2e — 평문 HTTP로 SPA+/api 프록시(운영과 동일 구조) - deploy/e2e.sh — 스택 기동→테스트 학생 API 생성→퀴즈 slug 자동조회→ Playwright(브라우저 내장 이미지) 실행→down -v(정리) - 백엔드 힙 256MB 제한(작은 인스턴스에서 운영과 공존) EC2에서 6/6 통과 확인. 러너가 운영과 같은 인스턴스라 push마다가 아닌 온디맨드(릴리스 전)로 운영 — 전용 러너 확보 시 CI 게이트로 승격. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
db54b9bc19
commit
1661591d8b
4
.gitignore
vendored
4
.gitignore
vendored
@ -3,6 +3,10 @@ backend/target/
|
||||
frontend/dist/
|
||||
frontend/node_modules/
|
||||
frontend/coverage/
|
||||
# E2E(Playwright) 산출물
|
||||
frontend/playwright-report/
|
||||
frontend/test-results/
|
||||
frontend/playwright/.cache/
|
||||
|
||||
# 비밀값 — 절대 커밋 금지
|
||||
deploy/.env
|
||||
|
||||
23
deploy/CI.md
23
deploy/CI.md
@ -113,9 +113,30 @@ CI가 통과해야 하는 관문이 아래처럼 늘었다. **로컬에서 먼
|
||||
학생 실습 흐름: `git switch -c feat/내작업` → 커밋 → push → Gitea에서 PR 생성 →
|
||||
CI 초록불 + 멘토 승인 → merge. (실무 표준 흐름을 그대로 경험)
|
||||
|
||||
## E2E 테스트 (`deploy/e2e.sh`)
|
||||
|
||||
단위/컴포넌트 테스트가 "부품"을 본다면, E2E는 **다 조립된 앱을 진짜 브라우저로** 눌러 본다 —
|
||||
프론트+백엔드+DB가 함께 도는 통합 경로를 태워 "연결 지점의 회귀"를 잡는다(Playwright).
|
||||
|
||||
```bash
|
||||
bash deploy/e2e.sh # 격리 임시 스택 기동 → 테스트 → down -v(정리)
|
||||
```
|
||||
|
||||
검사하는 여정(6): 인증 가드(비로그인→로그인) · 로그인 실패 안내 · 학생 로그인→대시보드 ·
|
||||
학습 센터→강좌 · **퀴즈 풀기→제출→채점** · 멘토 로그인→화면+감사로그.
|
||||
|
||||
**설계 — 운영 무오염**: 별도 프로젝트(`mirime2e`)·네트워크·**휘발성 DB(tmpfs)** 로 완전히 격리된
|
||||
임시 스택을 띄우고(호스트 포트 8899), 테스트 학생을 API로 만들고, 브라우저 내장 이미지
|
||||
(`mcr.microsoft.com/playwright`)로 돌린 뒤 통째로 내린다. 운영 DB/컨테이너는 건드리지 않는다.
|
||||
|
||||
> **왜 push마다 CI로 안 돌리나?** 러너가 운영과 같은 작은 인스턴스(3.7GB)에 있어, 매 push마다
|
||||
> 두 번째 풀스택+브라우저를 띄우면 운영 메모리를 압박할 수 있다. 그래서 E2E는 **릴리스 전/필요 시
|
||||
> 온디맨드**로 돌린다(백엔드 힙도 256MB로 제한). 전용 러너가 생기면 그때 CI 게이트로 승격한다.
|
||||
|
||||
## 다음 개선 (학생 티켓)
|
||||
|
||||
- Maven·npm 캐시로 CI 실행 시간 단축
|
||||
- 커버리지 바닥을 단계적으로 상향(30%→40%→…)
|
||||
- 스모크 테스트를 배포 스크립트에 자동 편입(deploy 후 자동 실행·실패 시 알림)
|
||||
- E2E 테스트(Playwright: 로그인→강좌→퀴즈)
|
||||
- React 컴포넌트 테스트(Testing Library) — 프론트 커버리지 확장
|
||||
- 전용 러너 확보 시 E2E를 CI 게이트로 승격(현재는 온디맨드 `deploy/e2e.sh`)
|
||||
|
||||
13
deploy/Caddyfile.e2e
Normal file
13
deploy/Caddyfile.e2e
Normal file
@ -0,0 +1,13 @@
|
||||
# E2E 전용 Caddy 설정 — 운영과 달리 HTTPS/도메인 없이 평문 HTTP로만 서빙한다.
|
||||
# (E2E는 격리된 임시 스택이라 인증서가 필요 없다.) 역할은 운영과 동일:
|
||||
# /api/* → 백엔드(backend:8080), 나머지 → SPA 정적 파일(+ 새로고침 대비 index.html 폴백).
|
||||
:80 {
|
||||
handle /api/* {
|
||||
reverse_proxy backend:8080
|
||||
}
|
||||
handle {
|
||||
root * /srv
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
59
deploy/docker-compose.e2e.yml
Normal file
59
deploy/docker-compose.e2e.yml
Normal file
@ -0,0 +1,59 @@
|
||||
# E2E 전용 임시 스택 — 운영과 완전히 격리(별도 프로젝트명 mirime2e, 별도 네트워크, 휘발성 DB).
|
||||
# 운영 DB/컨테이너는 절대 건드리지 않는다. E2E가 끝나면 통째로 내려간다(down -v).
|
||||
#
|
||||
# 학습 포인트: E2E는 "진짜 같은 환경"이되 "버려도 되는 환경"이라야 한다.
|
||||
# - Postgres 데이터는 tmpfs(메모리)에 둬 매 실행 깨끗하게 시작하고 흔적을 안 남긴다.
|
||||
# - 백엔드 힙을 256MB로 제한해(작은 인스턴스에서 운영과 공존) 자원을 아낀다.
|
||||
# - 멘토 초기 비밀번호를 알려진 값으로 고정해 E2E가 로그인할 수 있게 한다.
|
||||
name: mirime2e
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
POSTGRES_DB: mirim
|
||||
POSTGRES_USER: mirim
|
||||
POSTGRES_PASSWORD: e2epass
|
||||
tmpfs:
|
||||
- /var/lib/postgresql/data # 휘발성 — 매 실행 새 DB, 디스크에 안 남음
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U mirim"]
|
||||
interval: 4s
|
||||
timeout: 5s
|
||||
retries: 15
|
||||
|
||||
backend:
|
||||
build: ../backend
|
||||
environment:
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_NAME: mirim
|
||||
DB_USER: mirim
|
||||
DB_PASSWORD: e2epass
|
||||
COOKIE_SECURE: "false" # 평문 HTTP라 secure 쿠키는 꺼야 로그인된다
|
||||
LOG_LEVEL: INFO
|
||||
MENTOR_INITIAL_PASSWORD: e2e-mentor-pass
|
||||
JAVA_TOOL_OPTIONS: "-Xmx256m" # 작은 인스턴스에서 운영과 공존하도록 힙 제한
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
# 프론트 빌드 결과물을 webroot 볼륨에 복사(운영과 동일한 방식)
|
||||
frontend-build:
|
||||
build: ../frontend
|
||||
volumes:
|
||||
- webroot:/out
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
ports:
|
||||
- "8899:80" # 호스트 8899 → E2E 사이트(운영 80/443과 안 겹침)
|
||||
volumes:
|
||||
- ./Caddyfile.e2e:/etc/caddy/Caddyfile:ro
|
||||
- webroot:/srv:ro
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend-build
|
||||
|
||||
volumes:
|
||||
webroot:
|
||||
60
deploy/e2e.sh
Normal file
60
deploy/e2e.sh
Normal file
@ -0,0 +1,60 @@
|
||||
#!/bin/bash
|
||||
# 이 파일이 하는 일: 격리된 임시 스택을 띄우고 Playwright E2E를 돌린 뒤 통째로 정리한다.
|
||||
#
|
||||
# 흐름: 스택 기동(postgres+backend+frontend+caddy) → 백엔드 준비 대기 → 테스트 학생 생성
|
||||
# → 퀴즈 있는 코스 slug 조회 → Playwright(브라우저 내장 이미지) 실행 → down -v(정리).
|
||||
#
|
||||
# 사용법: bash deploy/e2e.sh
|
||||
# 운영과 완전히 격리된다(별도 프로젝트·네트워크·휘발성 DB, 호스트 포트 8899). 운영 DB 무접촉.
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")" # deploy/
|
||||
COMPOSE="docker compose -f docker-compose.e2e.yml"
|
||||
NET="mirime2e_default"
|
||||
BASE="http://localhost:8899"
|
||||
|
||||
cleanup() { echo "▶ E2E 스택 정리(down -v)..."; $COMPOSE down -v >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "▶ E2E 스택 빌드·기동..."
|
||||
$COMPOSE up -d --build
|
||||
|
||||
echo "▶ 백엔드 준비 대기(최대 ~3분)..."
|
||||
ready=0
|
||||
for i in $(seq 1 60); do
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/auth/me" 2>/dev/null || true)
|
||||
if [ "$code" = "401" ]; then ready=1; echo " 준비됨"; break; fi
|
||||
sleep 3
|
||||
done
|
||||
if [ "$ready" != "1" ]; then
|
||||
echo "🔴 백엔드가 뜨지 않았습니다. 최근 로그:"; $COMPOSE logs backend 2>&1 | tail -30; exit 1
|
||||
fi
|
||||
|
||||
echo "▶ 테스트 학생 생성(주소는 문자열 — Daum 팝업 우회)..."
|
||||
sc=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/auth/signup" \
|
||||
-H 'Content-Type: application/json' -d '{
|
||||
"username":"e2estudent","password":"E2ePass123!","name":"이투이","track":"DEV",
|
||||
"companyEmailLocal":"e2e.student","birthDate":"2007-03-15","phone":"010-1234-5678",
|
||||
"address":"서울특별시 강남구 테헤란로 1","school":"미림마이스터고","major":"소프트웨어과",
|
||||
"emergencyName":"이보호","emergencyRelation":"부","emergencyPhone":"010-9876-5432","privacyConsent":true
|
||||
}')
|
||||
echo " signup 응답: $sc (201=생성 / 409=이미 있음 — 둘 다 정상)"
|
||||
|
||||
echo "▶ 퀴즈가 있는 코스 slug 조회..."
|
||||
SLUG=$($COMPOSE exec -T postgres psql -U mirim -d mirim -tAc \
|
||||
"SELECT course_slug FROM quiz_question GROUP BY course_slug ORDER BY course_slug LIMIT 1" 2>/dev/null | tr -d '[:space:]')
|
||||
echo " quiz slug: ${SLUG:-(없음 — 퀴즈 테스트는 skip)}"
|
||||
|
||||
echo "▶ Playwright 실행(브라우저 내장 이미지)..."
|
||||
docker run --rm --network "$NET" \
|
||||
-v "$PWD/../frontend:/work" -w /work \
|
||||
-e E2E_BASE_URL=http://caddy \
|
||||
-e E2E_STUDENT_USER=e2estudent -e E2E_STUDENT_PASS='E2ePass123!' \
|
||||
-e E2E_MENTOR_USER=mentor1 -e E2E_MENTOR_PASS=e2e-mentor-pass \
|
||||
-e E2E_QUIZ_SLUG="$SLUG" \
|
||||
-e CI=true \
|
||||
mcr.microsoft.com/playwright:v1.49.1-jammy \
|
||||
sh -c "npm ci --no-audit --no-fund && npx playwright test"
|
||||
rc=$?
|
||||
|
||||
echo "▶ Playwright 종료 코드: $rc"
|
||||
exit $rc
|
||||
27
frontend/e2e/auth.spec.js
Normal file
27
frontend/e2e/auth.spec.js
Normal file
@ -0,0 +1,27 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { login, STUDENT } from './helpers.js'
|
||||
|
||||
// 인증 경계 — "로그인 전/후"가 올바로 갈리는지 실제 브라우저로 확인.
|
||||
|
||||
test('비로그인으로 보호 페이지 접근 → 로그인 화면으로 보낸다', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
await expect(page.locator('#username')).toBeVisible()
|
||||
})
|
||||
|
||||
test('잘못된 비밀번호는 로그인 실패 안내를 보여준다', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
await page.locator('#username').fill(STUDENT.username)
|
||||
await page.locator('#password').fill('wrong-password-xyz')
|
||||
await page.getByRole('button', { name: '로그인' }).click()
|
||||
await expect(page.getByText(/맞지 않아요|올바르지|너무 많/)).toBeVisible()
|
||||
// 실패했으니 여전히 로그인 화면에 남아 있어야 한다.
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
|
||||
test('학생 로그인 → 대시보드 진입', async ({ page }) => {
|
||||
await login(page, STUDENT)
|
||||
await expect(page).toHaveURL(/\/$/)
|
||||
// 로그인 화면 요소(#username)는 더 이상 없어야 한다.
|
||||
await expect(page.locator('#username')).toHaveCount(0)
|
||||
})
|
||||
23
frontend/e2e/helpers.js
Normal file
23
frontend/e2e/helpers.js
Normal file
@ -0,0 +1,23 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
// 테스트 계정·퀴즈 slug는 deploy/e2e.sh 가 임시 스택을 띄우며 env로 주입한다.
|
||||
// (기본값은 로컬에서 같은 값으로 스택을 띄웠을 때를 위한 폴백)
|
||||
export const STUDENT = {
|
||||
username: process.env.E2E_STUDENT_USER || 'e2estudent',
|
||||
password: process.env.E2E_STUDENT_PASS || 'E2ePass123!',
|
||||
}
|
||||
export const MENTOR = {
|
||||
username: process.env.E2E_MENTOR_USER || 'mentor1',
|
||||
password: process.env.E2E_MENTOR_PASS || 'e2e-mentor-pass',
|
||||
}
|
||||
export const QUIZ_SLUG = process.env.E2E_QUIZ_SLUG || ''
|
||||
|
||||
/** 로그인 폼을 채워 제출하고, 대시보드(/)로 이동할 때까지 기다린다. */
|
||||
export async function login(page, account) {
|
||||
await page.goto('/login')
|
||||
await page.locator('#username').fill(account.username)
|
||||
await page.locator('#password').fill(account.password)
|
||||
await page.getByRole('button', { name: '로그인' }).click()
|
||||
await page.waitForURL((url) => url.pathname === '/', { timeout: 15_000 })
|
||||
await expect(page.getByText('주차 진행 상황', { exact: false })).toBeVisible()
|
||||
}
|
||||
48
frontend/e2e/learn-quiz.spec.js
Normal file
48
frontend/e2e/learn-quiz.spec.js
Normal file
@ -0,0 +1,48 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { login, STUDENT, QUIZ_SLUG } from './helpers.js'
|
||||
|
||||
// 핵심 사용자 여정: 로그인 → 학습 센터 → 강좌 → 퀴즈 채점.
|
||||
// (프론트 SPA 라우팅 + 백엔드 API + DB가 함께 도는 통합 경로를 실제로 태운다.)
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page, STUDENT)
|
||||
})
|
||||
|
||||
test('학습 센터 → 카테고리 → 강좌 열기', async ({ page }) => {
|
||||
await page.goto('/learn')
|
||||
await expect(page.getByRole('heading', { name: '어썸데브 학습 센터' })).toBeVisible()
|
||||
|
||||
// 강좌 카드(링크)가 보이고, 클릭하면 강좌 페이지로 이동한다.
|
||||
const courseLink = page.locator('a[href^="/learn/"]').first()
|
||||
await expect(courseLink).toBeVisible()
|
||||
await courseLink.click()
|
||||
await expect(page).toHaveURL(/\/learn\/[^/]+$/)
|
||||
})
|
||||
|
||||
test('퀴즈 풀기 → 제출 → 채점 결과 표시', async ({ page }) => {
|
||||
test.skip(!QUIZ_SLUG, '퀴즈가 있는 코스 slug가 주입되지 않았음(E2E_QUIZ_SLUG)')
|
||||
|
||||
// "안 푼 문제가 있어요" 확인창이 뜨면 자동 수락(모두 풀 것이므로 보통 안 뜬다).
|
||||
page.on('dialog', (dialog) => dialog.accept())
|
||||
|
||||
await page.goto(`/learn/${QUIZ_SLUG}/quiz`)
|
||||
|
||||
// 제출 버튼이 보일 때까지 = 문항 로드 완료.
|
||||
const submit = page.getByRole('button', { name: '제출하고 채점받기' })
|
||||
await expect(submit).toBeVisible()
|
||||
|
||||
// 각 문항 카드에서 첫 번째 보기를 고른다.
|
||||
// 보기는 라디오를 감싼 <label>이므로, 라벨을 클릭하면 해당 보기가 선택된다.
|
||||
const questionCards = page.locator('.card').filter({ has: page.locator('input[type=radio]') })
|
||||
const count = await questionCards.count()
|
||||
expect(count).toBeGreaterThan(0)
|
||||
for (let i = 0; i < count; i++) {
|
||||
await questionCards.nth(i).locator('label').first().click()
|
||||
}
|
||||
|
||||
await submit.click()
|
||||
|
||||
// 서버 채점 결과가 표시된다(점수).
|
||||
await expect(page.getByText(/채점 완료/)).toBeVisible()
|
||||
await expect(page.locator('.stat-num')).toContainText('/')
|
||||
})
|
||||
20
frontend/e2e/mentor.spec.js
Normal file
20
frontend/e2e/mentor.spec.js
Normal file
@ -0,0 +1,20 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { login, MENTOR } from './helpers.js'
|
||||
|
||||
// 멘토 여정: 로그인 → 멘토 화면 → 인적사항 명단 + 개인정보 접근 기록(감사로그).
|
||||
// 명단을 조회하는 순간 백엔드가 감사기록(VIEW_STUDENT_ROSTER)을 남기고, 화면에 보인다 —
|
||||
// SEC-02 감사로그가 실제로 "쓰이고 보이는지"까지 한 번에 검증한다.
|
||||
|
||||
test('멘토 로그인 → 멘토 화면 + 감사로그 표시', async ({ page }) => {
|
||||
await login(page, MENTOR)
|
||||
|
||||
await page.goto('/mentor')
|
||||
await expect(page.getByRole('heading', { name: '멘토 리뷰' })).toBeVisible()
|
||||
|
||||
// 수습생 명단 섹션
|
||||
await expect(page.getByRole('heading', { name: '수습생 명단' })).toBeVisible()
|
||||
|
||||
// 개인정보 접근 기록(감사로그) 섹션 + 방금 명단 조회가 기록으로 남았는지
|
||||
await expect(page.getByRole('heading', { name: '개인정보 접근 기록' })).toBeVisible()
|
||||
await expect(page.getByText('명단 조회', { exact: false }).first()).toBeVisible()
|
||||
})
|
||||
@ -9,8 +9,8 @@ import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
|
||||
export default [
|
||||
// 검사에서 제외할 폴더 (빌드 산출물·커버리지·의존성)
|
||||
{ ignores: ['dist', 'coverage', 'node_modules'] },
|
||||
// 검사에서 제외할 폴더 (빌드 산출물·커버리지·의존성·E2E 리포트)
|
||||
{ ignores: ['dist', 'coverage', 'node_modules', 'playwright-report', 'test-results'] },
|
||||
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
|
||||
64
frontend/package-lock.json
generated
64
frontend/package-lock.json
generated
@ -16,6 +16,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@playwright/test": "1.49.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"eslint": "^9.17.0",
|
||||
@ -1032,6 +1033,22 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.49.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz",
|
||||
"integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.49.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@ -3171,6 +3188,53 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.49.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz",
|
||||
"integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.49.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.49.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz",
|
||||
"integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.19",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
|
||||
|
||||
@ -9,7 +9,8 @@
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"coverage": "vitest run --coverage",
|
||||
"lint": "eslint ."
|
||||
"lint": "eslint .",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
@ -20,6 +21,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@playwright/test": "1.49.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"eslint": "^9.17.0",
|
||||
|
||||
29
frontend/playwright.config.js
Normal file
29
frontend/playwright.config.js
Normal file
@ -0,0 +1,29 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
// 이 파일이 하는 일: E2E(End-to-End) 테스트 설정.
|
||||
//
|
||||
// 학습 포인트 — E2E는 무엇을 검증하나?
|
||||
// 단위/컴포넌트 테스트는 "부품 하나"를 본다. E2E는 "다 조립된 앱"을 진짜 브라우저로
|
||||
// 사용자처럼 눌러 본다 — 프론트+백엔드+DB가 함께 도는 통합 경로(로그인→강좌→퀴즈)를
|
||||
// 확인해, 부품별 테스트가 못 잡는 "연결 지점의 회귀"를 잡는다.
|
||||
//
|
||||
// 스택(프론트+백엔드+DB)은 deploy/e2e.sh 가 격리된 임시 환경으로 띄우고(운영 무오염),
|
||||
// 이 설정은 그 주소(E2E_BASE_URL)를 브라우저로 연다. 계정/퀴즈 slug도 env로 주입받는다.
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 10_000 },
|
||||
// 하나의 임시 DB를 공유하므로 순차 실행(계정/세션이 서로 안 얽히게).
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
// 네트워크 타이밍으로 한 번 실패할 수 있으니 1회 재시도(진짜 회귀는 재시도해도 실패).
|
||||
retries: 1,
|
||||
reporter: [['list'], ['html', { open: 'never' }]],
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL || 'http://localhost:8899',
|
||||
trace: 'on-first-retry', // 재시도 시 추적 파일 남겨 실패 원인 분석
|
||||
screenshot: 'only-on-failure',
|
||||
actionTimeout: 10_000,
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user