diff --git a/.gitignore b/.gitignore index 2a7972e..d63d7aa 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/deploy/CI.md b/deploy/CI.md index c58e97c..a6ce321 100644 --- a/deploy/CI.md +++ b/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`) diff --git a/deploy/Caddyfile.e2e b/deploy/Caddyfile.e2e new file mode 100644 index 0000000..d94ee92 --- /dev/null +++ b/deploy/Caddyfile.e2e @@ -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 + } +} diff --git a/deploy/docker-compose.e2e.yml b/deploy/docker-compose.e2e.yml new file mode 100644 index 0000000..b78f2fb --- /dev/null +++ b/deploy/docker-compose.e2e.yml @@ -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: diff --git a/deploy/e2e.sh b/deploy/e2e.sh new file mode 100644 index 0000000..5031fc5 --- /dev/null +++ b/deploy/e2e.sh @@ -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 diff --git a/frontend/e2e/auth.spec.js b/frontend/e2e/auth.spec.js new file mode 100644 index 0000000..24e2b29 --- /dev/null +++ b/frontend/e2e/auth.spec.js @@ -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) +}) diff --git a/frontend/e2e/helpers.js b/frontend/e2e/helpers.js new file mode 100644 index 0000000..35b498f --- /dev/null +++ b/frontend/e2e/helpers.js @@ -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() +} diff --git a/frontend/e2e/learn-quiz.spec.js b/frontend/e2e/learn-quiz.spec.js new file mode 100644 index 0000000..99b0075 --- /dev/null +++ b/frontend/e2e/learn-quiz.spec.js @@ -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() + + // 각 문항 카드에서 첫 번째 보기를 고른다. + // 보기는 라디오를 감싼