test: Playwright E2E 자동화 도입 (14 스펙, 관리자/구성원 역할 커버)
All checks were successful
build-and-push / build (push) Successful in 36s

- playwright.config.ts: 로컬 스택(5380/8380) 대상 headless chromium
- e2e/spin.spec.ts: 대시보드·네비·쪽지함·캘린더 일정 생성·프로젝트 생성 모달·검증, 구성원 역할(관리자 메뉴 숨김·관리 라우트 리다이렉트·프로필)
- e2e/project-flow.spec.ts: 프로젝트 상세 개요/작업자/메일 탭, 타임라인 작업 추가→상세 모달→칸반 카드
- e2e/member-domains.spec.ts: 근무·휴가 신청 검증, 인센티브 게이지, 대시보드 내 이슈
- ?as=user 로 역할 전환(api.ts 포워딩 활용), afterAll API 정리
- npm run test:e2e 스크립트, test-results/·playwright-report/ gitignore

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
theorose49 2026-07-02 10:14:39 +09:00
parent bce5016387
commit f902fdb3ee
7 changed files with 289 additions and 1 deletions

3
.gitignore vendored
View File

@ -8,3 +8,6 @@ dist/
build/
*.tsbuildinfo
.DS_Store
test-results/
playwright-report/
playwright/.cache/

View File

@ -0,0 +1,36 @@
import { test, expect } from "@playwright/test";
// 일반 구성원 관점의 핵심 도메인: 근무(휴가 신청 검증) + 인센티브 게이지.
// ?as=user 로 로드하면 앱 전체가 비관리자(김주임)로 동작.
test("근무 페이지 + 휴가 신청 모달 검증", async ({ page }) => {
await page.goto("/attendance?as=user");
await expect(page.getByRole("heading", { name: "근무" })).toBeVisible();
await expect(page.getByText("이번 달 출근일")).toBeVisible();
// 휴가/공가 탭 → 휴가 신청 모달
await page.getByRole("button", { name: "휴가/공가" }).click();
await page.getByRole("button", { name: "휴가 신청" }).click();
const submit = page.getByRole("button", { name: "신청", exact: true });
// 시작일 없으면 신청 비활성
await expect(submit).toBeDisabled();
await page.locator('.fixed input[type="date"]').first().fill("2026-09-10");
await expect(submit).toBeEnabled();
// 제출하지 않고 닫기(영속 데이터 오염 방지)
await page.getByRole("dialog").getByRole("button", { name: "취소" }).click();
});
test("인센티브 페이지 게이지 렌더", async ({ page }) => {
const errs: string[] = [];
page.on("pageerror", (e) => errs.push(e.message));
await page.goto("/incentive?as=user");
await expect(page.getByRole("heading", { name: "내 인센티브" })).toBeVisible();
// 게이지/할당량 섹션 렌더(연도 선택 존재)
await expect(page.locator("select").first()).toBeVisible();
expect(errs, errs.join("\n")).toHaveLength(0);
});
test("대시보드 인센티브 게이지 + 내 이슈 보드", async ({ page }) => {
await page.goto("/?as=user");
await expect(page.getByText(/안녕하세요/)).toBeVisible();
await expect(page.getByText("내 이슈")).toBeVisible();
});

61
e2e/project-flow.spec.ts Normal file
View File

@ -0,0 +1,61 @@
import { test, expect } from "@playwright/test";
// 실제 프로젝트 상세 워크플로(개요/작업자/타임라인 칸반/메일 탭 + 작업 생성→상세 모달).
const API = "http://localhost:8380/api";
const stamp = Date.now().toString().slice(-6);
let projectId = "";
test.beforeAll(async () => {
const r = await fetch(`${API}/projects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: `E2E흐름-${stamp}`, clientDomain: "meditech.com" }),
});
const p = await r.json();
projectId = p.id;
// 작업자 1명 추가(담당자 옵션)
await fetch(`${API}/projects/${projectId}/members`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ memberEmail: "park@special-partners.com", portion: 100, role: "작업자" }),
});
});
test.afterAll(async () => {
if (projectId) await fetch(`${API}/projects/${projectId}`, { method: "DELETE" }).catch(() => {});
});
test("프로젝트 상세 — 개요 렌더", async ({ page }) => {
await page.goto(`/projects/${projectId}`);
await expect(page.getByRole("heading", { name: new RegExp(`E2E흐름-${stamp}`) })).toBeVisible();
await expect(page.getByText("컨설팅 종류")).toBeVisible();
await expect(page.getByPlaceholder(/주의할 점/)).toBeVisible();
});
test("탭 이동 — 작업자/메일", async ({ page }) => {
await page.goto(`/projects/${projectId}`);
await page.getByRole("button", { name: "작업자", exact: true }).click();
await expect(page.getByText(/기여도\(portion\)/)).toBeVisible();
await expect(page.getByText("park@special-partners.com")).toBeVisible();
await page.getByRole("button", { name: "메일", exact: true }).click();
// 로컬은 Google Workspace 연동 미설정 → 안내 empty state가 정상
await expect(page.getByText("메일 연동이 설정되지 않았습니다")).toBeVisible();
});
test("타임라인 — 작업 추가 → 상세 모달 → 칸반 카드", async ({ page }) => {
const errs: string[] = [];
page.on("pageerror", (e) => errs.push(e.message));
await page.goto(`/projects/${projectId}`);
await page.getByRole("button", { name: "타임라인", exact: true }).click();
await page.getByRole("button", { name: "작업 추가" }).click();
const title = `E2E작업-${stamp}`;
await page.getByPlaceholder("예: 기술문서 초안 작성").fill(title);
await page.getByRole("button", { name: "추가", exact: true }).click();
// 생성 후 상세 모달이 열림(댓글 입력창으로 확인)
await expect(page.getByPlaceholder("댓글 입력…")).toBeVisible();
// 모달 닫기 → 칸반 카드에 작업명 표시
await page.keyboard.press("Escape");
await expect(page.getByPlaceholder("댓글 입력…")).toBeHidden();
await expect(page.getByText(title).first()).toBeVisible();
expect(errs, errs.join("\n")).toHaveLength(0);
});

102
e2e/spin.spec.ts Normal file
View File

@ -0,0 +1,102 @@
import { test, expect, Page } from "@playwright/test";
// 로컬 DEV_AUTH: 기본 로드는 super-admin(관리자), '/?as=user' 로드는 비관리자(김주임).
const API = "http://localhost:8380/api";
const stamp = Date.now().toString().slice(-6);
// 콘솔/페이지 에러를 모아 테스트에서 검증(치명적 JS 에러 감지).
function trackErrors(page: Page): string[] {
const errs: string[] = [];
page.on("pageerror", (e) => errs.push("pageerror: " + e.message));
page.on("console", (m) => { if (m.type() === "error") errs.push("console: " + m.text()); });
return errs;
}
test.afterAll(async ({ request }) => {
// E2E로 만든 프로젝트/일정 정리
const projs = await (await fetch(`${API}/projects`)).json().catch(() => []);
for (const p of Array.isArray(projs) ? projs : []) {
if (typeof p.name === "string" && p.name.includes("E2E")) {
await fetch(`${API}/projects/${p.id}`, { method: "DELETE" }).catch(() => {});
}
}
const evs = await (await fetch(`${API}/calendar/events`)).json().catch(() => []);
for (const e of Array.isArray(evs) ? evs : []) {
if (typeof e.title === "string" && e.title.includes("E2E")) {
await fetch(`${API}/calendar/events/${e.id}`, { method: "DELETE" }).catch(() => {});
}
}
});
test.describe("관리자(super-admin)", () => {
test("대시보드 + 사이드바 네비", async ({ page }) => {
const errs = trackErrors(page);
await page.goto("/");
await expect(page.getByText(/안녕하세요/)).toBeVisible();
await expect(page.getByText("내 이슈")).toBeVisible();
// 사이드바: 일반 + 관리자 메뉴
await expect(page.getByRole("link", { name: "프로젝트", exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: "캘린더" })).toBeVisible();
await expect(page.getByRole("link", { name: "프로젝트 관리" })).toBeVisible();
await expect(page.getByRole("link", { name: "회계" })).toBeVisible();
expect(errs, errs.join("\n")).toHaveLength(0);
});
test("쪽지함 명칭", async ({ page }) => {
await page.goto("/inbox");
await expect(page.getByRole("heading", { name: "쪽지함" })).toBeVisible();
});
test("캘린더 페이지 + 일정 생성", async ({ page }) => {
const errs = trackErrors(page);
await page.goto("/calendar");
await expect(page.getByRole("heading", { name: "캘린더" })).toBeVisible();
await page.getByRole("button", { name: "일정 추가" }).click();
const title = `E2E일정-${stamp}`;
await page.getByPlaceholder("예: 고객 미팅").fill(title);
// 분류/시작일은 기본값(기타/오늘) 사용
await page.getByRole("button", { name: "추가", exact: true }).click();
await expect(page.getByTitle(new RegExp(title))).toBeVisible();
expect(errs, errs.join("\n")).toHaveLength(0);
});
test("프로젝트 생성(모달) → 목록 반영", async ({ page }) => {
await page.goto("/admin/projects");
await expect(page.getByRole("heading", { name: "프로젝트 관리" })).toBeVisible();
await page.getByRole("button", { name: "프로젝트 생성" }).click();
const name = `E2E프로젝트-${stamp}`;
await page.getByPlaceholder("예: CardioScan FDA 510(k)").fill(name);
// 첫 번째 select = 업체: 첫 실제 항목 선택(생성 버튼 활성 조건)
await page.locator(".fixed select").first().selectOption({ index: 1 });
await page.getByRole("button", { name: "생성", exact: true }).click();
await expect(page.getByRole("link", { name: new RegExp(name) })).toBeVisible();
});
test("검증: 프로젝트명 없으면 생성 비활성", async ({ page }) => {
await page.goto("/admin/projects");
await page.getByRole("button", { name: "프로젝트 생성" }).click();
await expect(page.getByRole("button", { name: "생성", exact: true })).toBeDisabled();
});
});
test.describe("일반 구성원(?as=user)", () => {
test("관리자 메뉴 숨김", async ({ page }) => {
await page.goto("/?as=user");
await expect(page.getByText(/안녕하세요/)).toBeVisible();
await expect(page.getByRole("link", { name: "캘린더" })).toBeVisible();
await expect(page.getByRole("link", { name: "프로젝트 관리" })).toHaveCount(0);
await expect(page.getByRole("link", { name: "회계" })).toHaveCount(0);
await expect(page.getByRole("link", { name: "승인 관리" })).toHaveCount(0);
});
test("관리자 라우트 접근 시 대시보드로 리다이렉트", async ({ page }) => {
await page.goto("/admin/accounting?as=user");
await expect(page).toHaveURL(/\/$|\/\?/); // '/' 로 리다이렉트
await expect(page.getByText(/안녕하세요/)).toBeVisible();
});
test("내 프로필 접근", async ({ page }) => {
await page.goto("/profile?as=user");
await expect(page.getByRole("heading", { name: "내 프로필" })).toBeVisible();
});
});

64
package-lock.json generated
View File

@ -20,6 +20,7 @@
"recharts": "^2.12.7"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@types/node": "^20.14.10",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
@ -867,6 +868,22 @@
"node": ">= 8"
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.3",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
@ -2645,6 +2662,53 @@
"node": ">= 6"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"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.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",

View File

@ -7,7 +7,8 @@
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --noEmit"
"typecheck": "tsc -b --noEmit",
"test:e2e": "playwright test"
},
"dependencies": {
"@dnd-kit/core": "^6.1.0",
@ -22,6 +23,7 @@
"recharts": "^2.12.7"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@types/node": "^20.14.10",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",

20
playwright.config.ts Normal file
View File

@ -0,0 +1,20 @@
import { defineConfig } from "@playwright/test";
// 로컬 스택(docker: frontend 5380 + backend 8380, DEV_AUTH)에 대한 E2E.
// 스택은 이미 떠 있다고 가정(docker compose up -d).
export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
expect: { timeout: 10_000 },
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [["list"]],
use: {
baseURL: "http://localhost:5380",
headless: true,
viewport: { width: 1280, height: 900 },
actionTimeout: 10_000,
ignoreHTTPSErrors: true,
},
});