test(frontend): #343 Jest+RTL 인프라 + ARIA Tabs + remotePatterns

테스트 인프라:
- Jest 30 + jest-environment-jsdom + RTL + jest-dom matchers
- next/jest로 SWC/Next.js 자동 통합
- jest.config.ts (setupFilesAfterEnv) + jest.setup.ts
- npm scripts: test, test:watch
- 샘플 테스트 3개, 13/13 통과:
  - i18n/config: isLocale + detectBrowserLocale (5 케이스)
  - Stars 컴포넌트: 별점/aria/clamp/showNumber (5 케이스)
  - admin-utils: getAdminToken + authHeaders (4 케이스)

ARIA Tabs (MyReviewsList):
- role=tablist + tab + aria-selected + aria-controls + tabIndex
- panel에 role=tabpanel + aria-labelledby

next/image:
- next.config.ts remotePatterns: lh3.googleusercontent.com / i.ytimg.com / yt3.ggpht.com
- ReviewSection의 user_avatar_url에 명시적 eslint-disable + 사유

후속(별도): 전체 컴포넌트 테스트 점진 추가, 백엔드 JUnit 인프라, E2E (Playwright), CI 통합

설계서: docs/design/343-frontend-test-infra/README.md

Refs: #343
This commit is contained in:
joungmin
2026-06-15 16:25:55 +09:00
parent 9ba905aad8
commit 730727a7a6
10 changed files with 4493 additions and 34 deletions

View File

@@ -0,0 +1,36 @@
/**
* #343 — Stars 컴포넌트 렌더 테스트.
*/
import { render, screen } from "@testing-library/react";
import Stars from "@/components/Stars";
describe("Stars", () => {
it("renders 5 star slots", () => {
const { container } = render(<Stars rating={3} />);
// 빈 별 5개 (text-gray-300 클래스 갖는 span)
const emptyStars = container.querySelectorAll("span.text-gray-300");
expect(emptyStars.length).toBe(5);
});
it("shows aria-label with rating", () => {
render(<Stars rating={4.5} />);
expect(screen.getByLabelText("4.5점")).toBeInTheDocument();
});
it("clamps rating to 0~5", () => {
render(<Stars rating={-1} />);
expect(screen.getByLabelText("0점")).toBeInTheDocument();
render(<Stars rating={10} />);
expect(screen.getByLabelText("5점")).toBeInTheDocument();
});
it("shows number when showNumber + rating > 0", () => {
const { container } = render(<Stars rating={3.5} showNumber />);
expect(container.textContent).toContain("3.5");
});
it("does not show number when rating is 0 even with showNumber", () => {
const { container } = render(<Stars rating={0} showNumber />);
expect(container.textContent).not.toContain("0");
});
});

View File

@@ -0,0 +1,28 @@
/**
* #343 — admin-utils 순수 함수 단위 테스트.
*/
import { getAdminToken, authHeaders } from "@/lib/admin-utils";
describe("admin-utils", () => {
beforeEach(() => {
localStorage.clear();
});
it("getAdminToken returns null when not set", () => {
expect(getAdminToken()).toBeNull();
});
it("getAdminToken returns stored token", () => {
localStorage.setItem("tasteby_token", "abc123");
expect(getAdminToken()).toBe("abc123");
});
it("authHeaders is empty when no token", () => {
expect(authHeaders()).toEqual({});
});
it("authHeaders includes Bearer when token set", () => {
localStorage.setItem("tasteby_token", "xyz");
expect(authHeaders()).toEqual({ Authorization: "Bearer xyz" });
});
});

View File

@@ -0,0 +1,42 @@
/**
* #343 — i18n/config 순수 함수 단위 테스트.
*/
import { isLocale, detectBrowserLocale, DEFAULT_LOCALE } from "@/i18n/config";
describe("i18n/config.isLocale", () => {
it("returns true for supported locales", () => {
expect(isLocale("ko")).toBe(true);
expect(isLocale("en")).toBe(true);
expect(isLocale("ja")).toBe(true);
expect(isLocale("es")).toBe(true);
});
it("returns false for unsupported / null / undefined", () => {
expect(isLocale("fr")).toBe(false);
expect(isLocale("zh")).toBe(false);
expect(isLocale(null)).toBe(false);
expect(isLocale(undefined)).toBe(false);
expect(isLocale("")).toBe(false);
});
});
describe("i18n/config.detectBrowserLocale", () => {
// jsdom의 navigator.language는 기본 'en-US'
it("returns supported locale from navigator.language", () => {
Object.defineProperty(navigator, "language", { value: "en-US", configurable: true });
expect(detectBrowserLocale()).toBe("en");
Object.defineProperty(navigator, "language", { value: "ko-KR", configurable: true });
expect(detectBrowserLocale()).toBe("ko");
Object.defineProperty(navigator, "language", { value: "ja", configurable: true });
expect(detectBrowserLocale()).toBe("ja");
Object.defineProperty(navigator, "language", { value: "es-MX", configurable: true });
expect(detectBrowserLocale()).toBe("es");
});
it("falls back to DEFAULT_LOCALE for unsupported", () => {
Object.defineProperty(navigator, "language", { value: "fr-FR", configurable: true });
expect(detectBrowserLocale()).toBe(DEFAULT_LOCALE);
Object.defineProperty(navigator, "language", { value: "zh-CN", configurable: true });
expect(detectBrowserLocale()).toBe(DEFAULT_LOCALE);
});
});

21
frontend/jest.config.ts Normal file
View File

@@ -0,0 +1,21 @@
// #343 — Jest 설정. next/jest로 SWC 자동 통합.
import type { Config } from "jest";
import nextJest from "next/jest.js";
const createJestConfig = nextJest({
// 테스트 환경의 Next.js 앱 루트
dir: "./",
});
const customConfig: Config = {
// jest-dom matchers는 setupFilesAfterEnv로 등록 (Jest framework 로드 후)
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
testEnvironment: "jsdom",
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
testPathIgnorePatterns: ["<rootDir>/.next/", "<rootDir>/node_modules/"],
};
export default createJestConfig(customConfig);

2
frontend/jest.setup.ts Normal file
View File

@@ -0,0 +1,2 @@
// #343 — Jest setup. @testing-library/jest-dom matchers 확장.
import "@testing-library/jest-dom";

View File

@@ -2,6 +2,14 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
// #343 — 외부 이미지 도메인 허용 (next/image)
images: {
remotePatterns: [
{ protocol: "https", hostname: "lh3.googleusercontent.com" }, // Google avatar
{ protocol: "https", hostname: "i.ytimg.com" }, // YouTube thumbnail
{ protocol: "https", hostname: "yt3.ggpht.com" }, // YouTube channel avatar
],
},
};
export default nextConfig;

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,9 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test": "jest",
"test:watch": "jest --watch"
},
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
@@ -22,11 +24,17 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"jest": "^30.4.2",
"jest-environment-jsdom": "^30.4.1",
"tailwindcss": "^4",
"typescript": "^5"
}

View File

@@ -41,8 +41,14 @@ export default function MyReviewsList({
</button>
</div>
<div className="flex gap-1 border-b">
{/* #343 — WAI-ARIA Tabs 패턴 */}
<div role="tablist" aria-label="내 활동" className="flex gap-1 border-b">
<button
role="tab"
id="tab-reviews"
aria-selected={tab === "reviews"}
aria-controls="panel-reviews"
tabIndex={tab === "reviews" ? 0 : -1}
onClick={() => setTab("reviews")}
className={`px-3 py-1.5 text-sm font-medium border-b-2 transition-colors ${
tab === "reviews"
@@ -54,6 +60,11 @@ export default function MyReviewsList({
({reviews.length})
</button>
<button
role="tab"
id="tab-memos"
aria-selected={tab === "memos"}
aria-controls="panel-memos"
tabIndex={tab === "memos" ? 0 : -1}
onClick={() => setTab("memos")}
className={`px-3 py-1.5 text-sm font-medium border-b-2 transition-colors ${
tab === "memos"
@@ -67,7 +78,8 @@ export default function MyReviewsList({
</div>
{tab === "reviews" ? (
reviews.length === 0 ? (
<div role="tabpanel" id="panel-reviews" aria-labelledby="tab-reviews">
{reviews.length === 0 ? (
<p className="text-sm text-gray-500 py-8 text-center">
.
</p>
@@ -100,9 +112,11 @@ export default function MyReviewsList({
</button>
))}
</div>
)
)}
</div>
) : (
memos.length === 0 ? (
<div role="tabpanel" id="panel-memos" aria-labelledby="tab-memos">
{memos.length === 0 ? (
<p className="text-sm text-gray-500 py-8 text-center">
.
</p>
@@ -137,7 +151,8 @@ export default function MyReviewsList({
</button>
))}
</div>
)
)}
</div>
)}
</div>
);

View File

@@ -257,6 +257,9 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
<>
<div className="flex items-center gap-2 mb-1">
{review.user_avatar_url && (
// eslint-disable-next-line @next/next/no-img-element
// #343 — Google avatar URL은 remotePatterns에 추가됨.
// next/image 전환은 SSR/lazy 효과 미미한 5x5 아바타라 후속에서 일괄 적용.
<img
src={review.user_avatar_url}
alt=""