Skip to content

Commit 8b23095

Browse files
committed
test(e2e): UI E2E 테스트 구현
test-targets.md 기반 8개 테스트 시나리오 구현: - 로딩 상태 표시 (네트워크 지연 시 로딩 인디케이터) - API 에러 상태 표시 (에러 메시지 및 복구) - 브라우저 히스토리 네비게이션 (뒤로/앞으로 동작) - 다중 필터 조합 (컨텐츠 종류 + 관문 분리 등) - 검색 필터링 (하이라이팅 미구현 확인) - 탭 데이터 독립성 (탭별 상태 분리) - 접근성 (ARIA 속성, 키보드 네비게이션)
1 parent 5ecdf19 commit 8b23095

7 files changed

+1143
-0
lines changed

e2e/accessibility.spec.ts

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
test.describe("접근성 - 스크린 리더 지원", () => {
4+
test.use({ storageState: { cookies: [], origins: [] } });
5+
6+
test.beforeEach(async ({ page }) => {
7+
await page.goto("/");
8+
await page.locator("table tbody tr").first().waitFor();
9+
});
10+
11+
test("테이블에 적절한 role 속성이 있음", async ({ page }) => {
12+
// 테이블 요소 확인
13+
const table = page.getByRole("table");
14+
await expect(table).toBeVisible();
15+
16+
// 테이블 구조 확인 (thead, tbody)
17+
const thead = page.locator("table thead");
18+
await expect(thead).toBeVisible();
19+
20+
const tbody = page.locator("table tbody");
21+
await expect(tbody).toBeVisible();
22+
23+
// 테이블 행 확인
24+
const rows = tbody.locator("tr");
25+
await expect(rows).not.toHaveCount(0);
26+
});
27+
28+
test("다이얼로그에 aria-modal 및 role 속성이 있음", async ({ page }) => {
29+
// 테이블 행 클릭하여 다이얼로그 열기
30+
await page.locator("table tbody tr").first().click();
31+
32+
// 다이얼로그 확인
33+
const dialog = page.getByRole("dialog");
34+
await expect(dialog).toBeVisible();
35+
36+
// aria-modal 확인
37+
await expect(dialog).toHaveAttribute("aria-modal", "true");
38+
39+
// 다이얼로그 제목 확인
40+
const heading = dialog.getByRole("heading", { name: "컨텐츠 상세 정보" });
41+
await expect(heading).toBeVisible();
42+
});
43+
44+
test("버튼에 접근 가능한 이름이 있음", async ({ page }) => {
45+
// 네비게이션 버튼들 확인
46+
await expect(
47+
page.getByRole("button", { name: "컨텐츠별 시급" })
48+
).toBeVisible();
49+
await expect(
50+
page.getByRole("button", { name: "컨텐츠별 보상" })
51+
).toBeVisible();
52+
await expect(
53+
page.getByRole("button", { name: "아이템 시세" })
54+
).toBeVisible();
55+
56+
// 필터 버튼 확인
57+
const filterButton = page.getByRole("button", { name: //i });
58+
await expect(filterButton.first()).toBeVisible();
59+
});
60+
61+
test("링크에 접근 가능한 이름이 있음", async ({ page }) => {
62+
// 로고 링크 확인
63+
const logoLink = page.getByRole("link", { name: "로직장 홈으로 이동" });
64+
await expect(logoLink).toBeVisible();
65+
66+
// 설명서 링크 확인
67+
const guideButton = page.getByRole("button", { name: //i });
68+
await expect(guideButton).toBeVisible();
69+
});
70+
71+
test("폼 컨트롤에 레이블이 있음", async ({ page }) => {
72+
// 검색 입력창 확인
73+
const searchInput = page.getByRole("textbox", { name: //i });
74+
await expect(searchInput).toBeVisible();
75+
});
76+
77+
test("필터 다이얼로그의 라디오 그룹에 적절한 레이블이 있음", async ({
78+
page,
79+
}) => {
80+
// 테이블 행 클릭하여 상세 다이얼로그 열기
81+
await page.locator("table tbody tr").first().click();
82+
83+
const dialog = page.getByRole("dialog");
84+
await expect(dialog).toBeVisible();
85+
86+
// 필터 버튼 클릭
87+
await dialog.getByRole("button", { name: //i }).first().click();
88+
89+
// 필터 팝오버 확인
90+
const filterPopover = dialog.getByRole("dialog");
91+
await expect(filterPopover).toBeVisible();
92+
93+
// 라디오 그룹 확인
94+
const radioGroups = filterPopover.getByRole("group");
95+
await expect(radioGroups).not.toHaveCount(0);
96+
97+
// 라디오 버튼 확인
98+
const radioButtons = filterPopover.getByRole("radio");
99+
await expect(radioButtons).not.toHaveCount(0);
100+
});
101+
102+
test("다이얼로그에서 Escape 키로 닫기 가능", async ({ page }) => {
103+
// 다이얼로그 열기
104+
await page.locator("table tbody tr").first().click();
105+
106+
const dialog = page.getByRole("dialog");
107+
await expect(dialog).toBeVisible();
108+
109+
// Escape 키로 닫기
110+
await page.keyboard.press("Escape");
111+
await expect(dialog).not.toBeVisible();
112+
});
113+
114+
test("포커스 가능한 요소에 시각적 포커스 표시가 있음", async ({ page }) => {
115+
// 첫 번째 탭 가능한 요소로 이동
116+
await page.keyboard.press("Tab");
117+
118+
// 포커스된 요소 확인
119+
const focusedElement = page.locator(":focus");
120+
await expect(focusedElement).toBeVisible();
121+
122+
// 포커스 스타일이 있는지 확인 (outline 또는 box-shadow)
123+
const outlineStyle = await focusedElement.evaluate((el) => {
124+
const style = window.getComputedStyle(el);
125+
return style.outline !== "none" || style.boxShadow !== "none";
126+
});
127+
expect(outlineStyle).toBe(true);
128+
});
129+
130+
test("콘텐츠에 이미지가 있을 경우 확인 가능", async ({ page }) => {
131+
// 페이지에 이미지가 있는지 확인
132+
const images = page.locator("img");
133+
const imageCount = await images.count();
134+
135+
// 이미지가 없으면 패스 (홈페이지는 테이블 데이터만 있음)
136+
if (imageCount === 0) {
137+
// 대신 SVG 아이콘이나 다른 시각적 요소 확인
138+
const svgIcons = page.locator("svg");
139+
const svgCount = await svgIcons.count();
140+
expect(svgCount).toBeGreaterThanOrEqual(0);
141+
return;
142+
}
143+
144+
// 이미지가 있다면 접근성 속성 확인
145+
let processedCount = 0;
146+
for (let i = 0; i < imageCount; i++) {
147+
const img = images.nth(i);
148+
const alt = await img.getAttribute("alt");
149+
const role = await img.getAttribute("role");
150+
const ariaHidden = await img.getAttribute("aria-hidden");
151+
152+
if (
153+
alt !== null ||
154+
role === "presentation" ||
155+
role === "none" ||
156+
ariaHidden === "true"
157+
) {
158+
processedCount++;
159+
}
160+
}
161+
162+
// 이미지 처리 현황 로그
163+
console.log(`Images: ${imageCount}, Accessible: ${processedCount}`);
164+
});
165+
166+
test("아이템 시세 페이지의 탭에 적절한 ARIA 속성이 있음", async ({
167+
page,
168+
}) => {
169+
await page.goto("/item-price-list");
170+
await page.locator("table tbody tr").first().waitFor();
171+
172+
// 탭 목록 확인
173+
const tablist = page.getByRole("tablist");
174+
await expect(tablist).toBeVisible();
175+
176+
// 탭 확인
177+
const tabs = page.getByRole("tab");
178+
await expect(tabs).not.toHaveCount(0);
179+
180+
// 선택된 탭 확인
181+
const selectedTab = page.getByRole("tab", { selected: true });
182+
await expect(selectedTab).toBeVisible();
183+
184+
// aria-selected 속성 확인
185+
await expect(selectedTab).toHaveAttribute("aria-selected", "true");
186+
});
187+
188+
test("아코디언에 적절한 ARIA 속성이 있음", async ({ page }) => {
189+
await page.goto("/item-price-list");
190+
await page.locator("table tbody tr").first().waitFor();
191+
192+
// 아코디언 버튼 확인
193+
const accordionButton = page
194+
.getByRole("button", { name: / | /i })
195+
.first();
196+
await expect(accordionButton).toBeVisible();
197+
198+
// aria-expanded 속성 확인
199+
const isExpanded = await accordionButton.getAttribute("aria-expanded");
200+
expect(isExpanded === "true" || isExpanded === "false").toBe(true);
201+
});
202+
});

e2e/api-error-state.spec.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
test.describe("API 에러 상태 표시", () => {
4+
test.use({ storageState: { cookies: [], origins: [] } });
5+
6+
test("홈 페이지에서 GraphQL 에러 발생 시 에러 메시지가 표시됨", async ({
7+
page,
8+
}) => {
9+
// GraphQL 요청에 에러 응답 반환
10+
await page.route("**/graphql", async (route) => {
11+
await route.fulfill({
12+
status: 200,
13+
contentType: "application/json",
14+
body: JSON.stringify({
15+
errors: [
16+
{
17+
message: "서버 오류가 발생했습니다.",
18+
extensions: { code: "INTERNAL_SERVER_ERROR" },
19+
},
20+
],
21+
data: null,
22+
}),
23+
});
24+
});
25+
26+
await page.goto("/");
27+
28+
// 에러 메시지 또는 에러 상태 확인
29+
// ErrorBoundary가 error.message를 표시함
30+
const errorMessage = page.getByText(/|error|Error/i);
31+
await expect(errorMessage.first()).toBeVisible({ timeout: 10000 });
32+
33+
// 콘솔 에러 확인
34+
const consoleMessages: string[] = [];
35+
page.on("console", (msg) => {
36+
if (msg.type() === "error") {
37+
consoleMessages.push(msg.text());
38+
}
39+
});
40+
41+
// 앱이 크래시하지 않았는지 확인 (페이지가 여전히 응답함)
42+
await expect(page).toHaveTitle(//);
43+
});
44+
45+
test("아이템 시세 페이지에서 네트워크 에러 발생 시 에러 메시지가 표시됨", async ({
46+
page,
47+
}) => {
48+
// 네트워크 에러 시뮬레이션
49+
await page.route("**/graphql", async (route) => {
50+
await route.abort("failed");
51+
});
52+
53+
await page.goto("/item-price-list");
54+
55+
// 에러 상태 또는 에러 메시지 확인
56+
const errorIndicator = page.getByText(/|error|Error|failed|/i);
57+
await expect(errorIndicator.first()).toBeVisible({ timeout: 10000 });
58+
});
59+
60+
test("컨텐츠별 보상 페이지에서 서버 에러 발생 시 에러 메시지가 표시됨", async ({
61+
page,
62+
}) => {
63+
// 서버 에러 (500) 응답
64+
await page.route("**/graphql", async (route) => {
65+
await route.fulfill({
66+
status: 500,
67+
contentType: "application/json",
68+
body: JSON.stringify({
69+
errors: [{ message: "Internal Server Error" }],
70+
}),
71+
});
72+
});
73+
74+
await page.goto("/content-reward-list");
75+
76+
// 에러 상태 확인 - "Response not successful: Received status code 500" 메시지 포함
77+
const errorIndicator = page.getByText(/|error|Error|Response|status|500/i);
78+
await expect(errorIndicator.first()).toBeVisible({ timeout: 10000 });
79+
});
80+
81+
test("에러 발생 후 재시도 가능 여부 확인", async ({ page }) => {
82+
let requestCount = 0;
83+
84+
// 첫 번째 요청은 실패, 이후 요청은 성공
85+
await page.route("**/graphql", async (route) => {
86+
requestCount++;
87+
if (requestCount === 1) {
88+
await route.fulfill({
89+
status: 200,
90+
contentType: "application/json",
91+
body: JSON.stringify({
92+
errors: [{ message: "일시적 오류" }],
93+
data: null,
94+
}),
95+
});
96+
} else {
97+
// 이후 요청은 정상 처리
98+
await route.continue();
99+
}
100+
});
101+
102+
await page.goto("/");
103+
104+
// 에러 메시지 확인
105+
const errorMessage = page.getByText(/|error/i);
106+
await expect(errorMessage.first()).toBeVisible({ timeout: 10000 });
107+
108+
// 페이지 새로고침으로 재시도
109+
await page.reload();
110+
111+
// 정상 데이터 로드 확인
112+
const tableRows = page.locator("table tbody tr");
113+
await expect(tableRows).not.toHaveCount(0, { timeout: 10000 });
114+
});
115+
});

0 commit comments

Comments
 (0)