[cs frontend] vitest로 된 Vitest + React Testing Library 기반 단위/통합 테스트 코드 작성

This commit is contained in:
2026-07-09 14:10:48 +09:00
parent 993a9f4ff2
commit 8b3fa32d1e
10 changed files with 1080 additions and 28 deletions
+9 -1
View File
@@ -8,6 +8,8 @@
"start": "next start",
"lint": "next lint",
"test:playwright": "playwright test",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:playwright:ui": "playwright test --ui"
},
"dependencies": {
@@ -54,17 +56,23 @@
"devDependencies": {
"@eslint/eslintrc": "^3.0.0",
"@playwright/test": "^1.61.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.13.1",
"@types/webpack": "^5.28.5",
"@vitejs/plugin-react": "^4.7.0",
"eslint": "^9.0.0",
"eslint-config-next": "15.3.8",
"eslint-plugin-react-hooks": "^5.2.0",
"jsdom": "^29.1.1",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"steiger": "^0.5.5",
"tailwindcss": "^3.4.14",
"typescript": "^5.7.2"
"typescript": "^5.7.2",
"vite-tsconfig-paths": "5.1.4",
"vitest": "^4.1.10"
}
}
File diff suppressed because one or more lines are too long
+78
View File
@@ -0,0 +1,78 @@
import { test, expect } from "@playwright/test";
const adminId = process.env.TEST_ADMIN_ID || "admin1gmail";
const adminPassword = process.env.TEST_ADMIN_PW || "test1234!";
test.describe("로그인 페이지 E2E 테스트", () => {
test.beforeEach(async ({ page }) => {
// 각 테스트 시작 전 로그인 페이지로 이동
await page.goto("/auth/login");
});
test("1. 로그인 화면 UI 요소가 올바르게 렌더링되는지 확인", async ({ page }) => {
// 메인 제목 및 하부 제목 검증
await expect(page.locator("h1")).toContainText("유역하수도통합플랫폼");
await expect(page.locator("h1 span")).toContainText("하수도플랫폼 통합관리(CMS)시스템");
// 입력 필드 및 버튼 검증
await expect(page.locator("input#id")).toBeVisible();
await expect(page.locator("input#password")).toBeVisible();
await expect(page.locator("button.login_btn")).toBeVisible();
await expect(page.locator("input#checkId")).toBeVisible();
});
test("2. 빈 입력으로 로그인 시도 시 에러 메시지 검증", async ({ page }) => {
// 값을 입력하지 않고 로그인 클릭
await page.click("button.login_btn");
// 에러 메시지 영역 표시 여부 및 텍스트 검증
const errorMessage = page.locator(".error_message");
await expect(errorMessage).toBeVisible();
await expect(errorMessage).not.toBeEmpty();
});
test("3. 잘못된 비밀번호로 로그인 시도 시 로그인 실패 검증", async ({ page }) => {
// 잘못된 자격 증명 입력
await page.fill("input#id", adminId);
await page.fill("input#password", "wrong_password_123");
// 로그인 시도
await page.click("button.login_btn");
// 에러 메시지가 화면에 노출되는지 확인
const errorMessage = page.locator(".error_message");
await expect(errorMessage).toBeVisible();
await expect(errorMessage).toContainText("아이디와 비밀번호가 일치하지 않습니다.");
// URL이 로그인 페이지에 그대로 머물러 있는지 확인
await expect(page).toHaveURL(/\/auth\/login/);
});
test("4. 올바른 계정 정보로 로그인 성공 시 대시보드로 이동", async ({ page }) => {
// 올바른 자격 증명 입력
await page.fill("input#id", adminId);
await page.fill("input#password", adminPassword);
// 로그인 시도
await page.click("button.login_btn");
// 대시보드 페이지로 리다이렉트 되는지 확인
await page.waitForURL("**/dashboard");
await expect(page).toHaveURL(/.*dashboard/);
});
test("5. 아이디 저장 체크박스 상태 전환 확인", async ({ page }) => {
const checkbox = page.locator("input#checkId");
// 기본적으로 체크되지 않은 상태인지 확인
await expect(checkbox).not.toBeChecked();
// 체크박스 클릭
await checkbox.check();
await expect(checkbox).toBeChecked();
// 체크박스 해제 클릭
await checkbox.uncheck();
await expect(checkbox).not.toBeChecked();
});
});
+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom";
+131
View File
@@ -0,0 +1,131 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest";
import { LoginPage } from "@/pages/auth/login";
import { authService } from "@/entities/auth";
// next/image 모킹
vi.mock("next/image", () => ({
default: (props: any) => {
// eslint-disable-next-line @next/next/no-img-element
return <img {...props} />;
},
}));
// @/entities/auth 모킹
vi.mock("@/entities/auth", () => ({
authService: {
login: vi.fn(),
},
AuthWrapper: ({ children }: { children: React.ReactNode }) => children,
tokenManager: {
getToken: vi.fn(),
},
}));
// @common/assets 모킹
vi.mock("@common/assets", () => ({
images: {
logo: "/logo.png",
},
}));
describe("LoginPage 단위 테스트 (Vitest)", () => {
const originalLocation = window.location;
beforeAll(() => {
// window.location.href 변경 감지를 위해 window.location 모킹
// @ts-ignore
delete window.location;
window.location = { ...originalLocation, href: "" } as any;
});
afterAll(() => {
window.location = originalLocation;
});
beforeEach(() => {
vi.clearAllMocks();
window.location.href = "";
});
it("1. 로그인 화면 UI 요소가 정상 렌더링되는지 검증", () => {
render(<LoginPage />);
// 타이틀 렌더링 확인
expect(screen.getByText("유역하수도통합플랫폼")).toBeInTheDocument();
expect(screen.getByText("하수도플랫폼 통합관리(CMS)시스템")).toBeInTheDocument();
// 입력 필드 존재 확인
const idInput = screen.getByTitle("아이디를 입력하세요.");
const passwordInput = screen.getByTitle("비밀번호를 입력하세요.");
expect(idInput).toBeInTheDocument();
expect(passwordInput).toBeInTheDocument();
// 체크박스 존재 확인
const saveIdCheckbox = screen.getByLabelText("아이디저장");
expect(saveIdCheckbox).toBeInTheDocument();
expect(saveIdCheckbox).not.toBeChecked();
// 로그인 버튼 존재 확인
const submitButton = screen.getByRole("button", { name: "LOGIN" });
expect(submitButton).toBeInTheDocument();
});
it("2. 로그인 실패 시 에러 메시지가 화면에 노출되는지 검증", async () => {
// authService.login이 에러를 던지도록 모크 구현
const errorMessage = "아이디와 비밀번호가 일치하지 않습니다.";
vi.mocked(authService.login).mockRejectedValueOnce(new Error(errorMessage));
render(<LoginPage />);
const idInput = screen.getByTitle("아이디를 입력하세요.");
const passwordInput = screen.getByTitle("비밀번호를 입력하세요.");
const submitButton = screen.getByRole("button", { name: "LOGIN" });
// 값 입력 및 제출
fireEvent.change(idInput, { target: { value: "testuser" } });
fireEvent.change(passwordInput, { target: { value: "wrongpassword" } });
fireEvent.click(submitButton);
// 에러 메시지 렌더링 대기 및 확인
await waitFor(() => {
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
// authService.login 호출 확인
expect(authService.login).toHaveBeenCalledWith({
userId: "testuser",
password: "wrongpassword",
});
// 리다이렉트가 발생하지 않아야 함
expect(window.location.href).toBe("");
});
it("3. 로그인 성공 시 /dashboard 로 리다이렉트되는지 검증", async () => {
// authService.login 성공 모크 구현
vi.mocked(authService.login).mockResolvedValueOnce(undefined as any);
render(<LoginPage />);
const idInput = screen.getByTitle("아이디를 입력하세요.");
const passwordInput = screen.getByTitle("비밀번호를 입력하세요.");
const submitButton = screen.getByRole("button", { name: "LOGIN" });
// 값 입력 및 제출
fireEvent.change(idInput, { target: { value: "admin1gmail" } });
fireEvent.change(passwordInput, { target: { value: "test1234!" } });
fireEvent.click(submitButton);
// 리다이렉트 주소값 변경 대기 및 확인
await waitFor(() => {
expect(window.location.href).toBe("/dashboard");
});
// authService.login 호출 확인
expect(authService.login).toHaveBeenCalledWith({
userId: "admin1gmail",
password: "test1234!",
});
});
});
@@ -0,0 +1,63 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { SampleCounter } from "./sample-counter";
describe("SampleCounter 컴포넌트 단위 및 통합 테스트", () => {
it("1. 기본 렌더링 상태를 정상적으로 표시하는지 확인", () => {
render(<SampleCounter />);
// 제목 텍스트 존재 확인
expect(screen.getByText("단위 테스트 샘플 카운터")).toBeInTheDocument();
// 카운터 초기값 0 확인
const counterVal = screen.getByTestId("counter-value");
expect(counterVal).toHaveTextContent("현재 값: 0");
// 경고 문구가 보이지 않아야 함
expect(screen.queryByTestId("warning-message")).not.toBeInTheDocument();
});
it("2. 증가 버튼 클릭 시 값이 1씩 올라가는지 확인 (상태 변경 및 이벤트 검증)", () => {
render(<SampleCounter />);
const increaseBtn = screen.getByRole("button", { name: "increase" });
const counterVal = screen.getByTestId("counter-value");
// 1회 클릭
fireEvent.click(increaseBtn);
expect(counterVal).toHaveTextContent("현재 값: 1");
// 2회 클릭
fireEvent.click(increaseBtn);
expect(counterVal).toHaveTextContent("현재 값: 2");
});
it("3. 감소 버튼 클릭 시 값이 1씩 내려가는지 확인", () => {
render(<SampleCounter />);
const decreaseBtn = screen.getByRole("button", { name: "decrease" });
const counterVal = screen.getByTestId("counter-value");
// 1회 클릭 (0 -> -1)
fireEvent.click(decreaseBtn);
expect(counterVal).toHaveTextContent("현재 값: -1");
});
it("4. 값이 5를 초과할 경우 경고 문구가 조건부로 렌더링되는지 확인 (통합 로직 검증)", () => {
render(<SampleCounter />);
const increaseBtn = screen.getByRole("button", { name: "increase" });
// 5번 클릭 -> 5 (경고 없음)
for (let i = 0; i < 5; i++) {
fireEvent.click(increaseBtn);
}
expect(screen.queryByTestId("warning-message")).not.toBeInTheDocument();
// 6번째 클릭 -> 6 (경고 노출되어야 함)
fireEvent.click(increaseBtn);
const warningMsg = screen.getByTestId("warning-message");
expect(warningMsg).toBeInTheDocument();
expect(warningMsg).toHaveTextContent("값이 5를 초과했습니다!");
});
});
+37
View File
@@ -0,0 +1,37 @@
"use client";
import { useState } from "react";
export function SampleCounter() {
const [count, setCount] = useState(0);
return (
<div style={{ padding: "20px", textAlign: "center" }}>
<h2>단위 테스트 샘플 카운터</h2>
<p data-testid="counter-value" style={{ fontSize: "24px", fontWeight: "bold" }}>
현재 값: {count}
</p>
<div style={{ display: "flex", gap: "10px", justifyContent: "center" }}>
<button
onClick={() => setCount((prev) => prev - 1)}
style={{ padding: "8px 16px" }}
aria-label="decrease"
>
감소 (-)
</button>
<button
onClick={() => setCount((prev) => prev + 1)}
style={{ padding: "8px 16px" }}
aria-label="increase"
>
증가 (+)
</button>
</div>
{count > 5 && (
<p data-testid="warning-message" style={{ color: "red", marginTop: "10px" }}>
값이 5를 초과했습니다!
</p>
)}
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
# cs 앱 단위(Unit) 및 통합(Integration) 테스트 구축 결과 보고서
cs 애플리케이션 내의 컴포넌트 로직 검증 및 렌더링 검사 기능을 수행할 수 있도록 **Vitest**와 **React Testing Library (RTL)** 설정을 완료하고 샘플 코드를 생성했습니다.
## 변경 및 추가 사항 요약
### 1. 의존성 설치 및 스크립트 추가
* `apps/cs/package.json`의 개발용 라이브러리로 다음 패키지들을 설치했습니다:
* `vitest`, `@testing-library/react`, `@testing-library/jest-dom`, `jsdom`, `@vitejs/plugin-react`, `vite-tsconfig-paths`
* `scripts` 항목에 단위 테스트 실행 스크립트를 정의했습니다:
* `"test:unit": "vitest run"` (1회 실행)
* `"test:unit:watch": "vitest"` (실시간 Watch 모드)
### 2. 설정 파일 정의
* **[vitest.config.ts](file:///Users/changwng/workspace.ona/frontend/apps/cs/vitest.config.ts)**:
* React 플러그인 및 tsconfig alias 경로 파싱을 연결했습니다.
* 테스트 환경으로 가상 브라우저 돔(jsdom)을 연결했습니다.
* Playwright E2E 테스트 스펙(`*.spec.ts`)과 충돌이 나지 않도록, 단위 테스트 범위를 **`tests/unit/**/*.{test,spec}.{ts,tsx}`** 경로로 제한했습니다.
* **[setup-tests.ts](file:///Users/changwng/workspace.ona/frontend/apps/cs/tests/setup-tests.ts)**:
* DOM 전역 단언식인 `@testing-library/jest-dom`을 확장했습니다.
### 3. 샘플 및 페이지 테스트 코드 생성
* **[sample-counter.tsx](file:///Users/changwng/workspace.ona/frontend/apps/cs/tests/unit/sample-counter.tsx)**:
* 상태값 관리(useState) 및 이벤트 처리, 5 초과 시의 경고문 조건부 렌더링 등이 구현된 React 컴포넌트입니다.
* **[sample-counter.test.tsx](file:///Users/changwng/workspace.ona/frontend/apps/cs/tests/unit/sample-counter.test.tsx)**:
* **기본 렌더링 검사** (초기 값 및 제목 텍스트 검증)
* **상태 변경 검사** (증가/감소 버튼 동작에 따른 값 업데이트 검증)
* **통합 조건부 검사** (5 초과 시의 경고 노출 여부 검증)
* **[login-page.test.tsx](file:///Users/changwng/workspace.ona/frontend/apps/cs/tests/unit/login-page.test.tsx)**:
* 실제 로그인 컴포넌트(`LoginPage`)를 대상으로 한 단위/통합 테스트 스위트입니다.
* **UI 요소 검증**: 타이틀 렌더링, ID/PW 입력 필드 및 로그인 버튼 존재 확인.
* **로그인 실패 케이스**: `authService.login` API가 에러를 발생시킬 때 화면에 한글 에러 문구(`아이디와 비밀번호가 일치하지 않습니다.`)가 정상 표출되는지 검증.
* **로그인 성공 케이스**: 정상적으로 로그인이 완료되었을 때 `window.location.href` 가 `"/dashboard"` 로 셋업되어 이동되는지 검증.
* *Next.js `next/image` 컴포넌트 및 글로벌 `window.location`을 가상 돔 환경에 맞추어 적절하게 모킹 처리하였습니다.*
---
## 검증 결과
터미널에서 `pnpm --filter cs run test:unit` 명령을 실행하여 모든 테스트(총 7개)가 정상 통과함을 검증하였습니다:
```bash
RUN v4.1.10 /Users/changwng/workspace.ona/frontend/apps/cs
✓ tests/unit/sample-counter.test.tsx (4 tests) 89ms
✓ tests/unit/login-page.test.tsx (3 tests) 148ms
Test Files 2 passed (2)
Tests 7 passed (7)
Start at 14:04:03
Duration 718ms (transform 160ms, setup 71ms, import 243ms, tests 237ms, environment 557ms)
```
속도 면에서 아주 빠르고 안정적으로 가상 돔 유닛 테스트 환경이 준비되었습니다.
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
environment: "jsdom",
globals: true,
setupFiles: "./tests/setup-tests.ts",
include: ["tests/unit/**/*.{test,spec}.{ts,tsx}"],
},
});
+694 -26
View File
File diff suppressed because it is too large Load Diff