From e4b6aa4cfc485ae43c78963babe10b4c694f0d58 Mon Sep 17 00:00:00 2001 From: celine Date: Thu, 16 Apr 2026 17:04:38 +0900 Subject: [PATCH 01/11] =?UTF-8?q?refactor(ui/ReviewModal):=20=EC=A0=88?= =?UTF-8?q?=EB=8C=80=EA=B2=BD=EB=A1=9C=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: #286 --- features/shared/components/ReviewModal/index.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/shared/components/ReviewModal/index.test.tsx b/features/shared/components/ReviewModal/index.test.tsx index 44e86733..43c5d24c 100644 --- a/features/shared/components/ReviewModal/index.test.tsx +++ b/features/shared/components/ReviewModal/index.test.tsx @@ -21,7 +21,7 @@ jest.mock("@smastrom/react-rating", () => ({ ), })); // HeadlessUI 테스트 경고로 인해 단순 모달 UI로 대체 -jest.mock("../../../../components/ui/Modals", () => ({ +jest.mock("@/components/ui/Modals", () => ({ Modal: ({ isOpen, title, From e56c61b4f616e8b8052dce7f4cedbcf55b164217 Mon Sep 17 00:00:00 2001 From: celine Date: Thu, 16 Apr 2026 17:14:43 +0900 Subject: [PATCH 02/11] =?UTF-8?q?test(ui/ErrorPage):=20error.tsx=20?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: #286 --- components/common/ErrorPage/index.test.tsx | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 components/common/ErrorPage/index.test.tsx diff --git a/components/common/ErrorPage/index.test.tsx b/components/common/ErrorPage/index.test.tsx new file mode 100644 index 00000000..9078af24 --- /dev/null +++ b/components/common/ErrorPage/index.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from "@testing-library/react"; +import ErrorPage from "."; +import userEvent from "@testing-library/user-event"; + +describe("ErrorPage", () => { + test("prefix, title, description을 전달하면 해당 문구와 다시시도 버튼이 렌더링된다 ", () => { + render( + {}} + prefix="목록을 " + title="불러오지 못했습니다." + description="커스텀 설명입니다." + />, + ); + const title = screen.getByText("목록을 불러오지 못했습니다."); + const description = screen.getByText("커스텀 설명입니다."); + const button = screen.getByRole("button", { name: "다시 시도" }); + + expect(title).toBeInTheDocument(); + expect(description).toBeInTheDocument(); + expect(button).toBeInTheDocument(); + }); + + test("다시 시도 버튼 클릭 시 onRetryAction이 호출된다", async () => { + const user = userEvent.setup(); + const onRetryAction = jest.fn(); + render(); + + const button = screen.getByRole("button", { name: "다시 시도" }); + await user.click(button); + + expect(onRetryAction).toHaveBeenCalledTimes(1); + }); +}); From d4524f4b0044e7940bb9154a4dc830d1532d2854 Mon Sep 17 00:00:00 2001 From: celine Date: Thu, 16 Apr 2026 17:27:24 +0900 Subject: [PATCH 03/11] =?UTF-8?q?test(ui/QueryErrorBoundary):=20queryError?= =?UTF-8?q?Boundary=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: #286 --- .../common/QueryErrorBoundary/index.test.tsx | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 components/common/QueryErrorBoundary/index.test.tsx diff --git a/components/common/QueryErrorBoundary/index.test.tsx b/components/common/QueryErrorBoundary/index.test.tsx new file mode 100644 index 00000000..a0576674 --- /dev/null +++ b/components/common/QueryErrorBoundary/index.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from "@testing-library/react"; +import QueryErrorBoundary from "."; +import userEvent from "@testing-library/user-event"; + +function ThrowError(): never { + throw new Error("테스트 에러"); +} +describe("QueryErrorBoundary", () => { + const consoleError = jest.spyOn(console, "error").mockImplementation(() => {}); + + afterEach(() => { + consoleError.mockClear(); + }); + + afterAll(() => { + consoleError.mockRestore(); + }); + + test("자식 컴포넌트에서 에러가 발생하면 fallback UI가 렌더되는지 확인", () => { + render( + + + , + ); + + const title = screen.getByText("목록을 불러오지 못했습니다."); + const description = screen.getByText("잠시 후 다시 시도해주세요."); + const button = screen.getByRole("button", { name: "다시 시도" }); + + expect(title).toBeInTheDocument(); + expect(description).toBeInTheDocument(); + expect(button).toBeInTheDocument(); + }); + test("다시 시도 버튼 클릭시 reset 후 자식컴포넌트를 리렌더링하는지 확인 ", async () => { + const user = userEvent.setup(); + + let isError = true; + + function TestComponent() { + if (isError) { + throw new Error("초기 에러"); + } + return
복구된 콘텐츠
; + } + + render( + + + , + ); + + const title = screen.getByText("목록을 불러오지 못했습니다."); + expect(title).toBeInTheDocument(); + + isError = false; + + const button = screen.getByRole("button", { name: "다시 시도" }); + await user.click(button); + + const content = await screen.findByText("복구된 콘텐츠"); + expect(content).toBeInTheDocument(); + }); +}); From e52849d2c1f216a8dddc2e9af094c20e09c9387d Mon Sep 17 00:00:00 2001 From: celine Date: Thu, 16 Apr 2026 17:37:18 +0900 Subject: [PATCH 04/11] =?UTF-8?q?docs(ui/QueryErrorBoundary):=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=84=A4=EC=A0=95=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: #286 --- components/common/QueryErrorBoundary/index.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/components/common/QueryErrorBoundary/index.test.tsx b/components/common/QueryErrorBoundary/index.test.tsx index a0576674..40bbeddd 100644 --- a/components/common/QueryErrorBoundary/index.test.tsx +++ b/components/common/QueryErrorBoundary/index.test.tsx @@ -6,12 +6,15 @@ function ThrowError(): never { throw new Error("테스트 에러"); } describe("QueryErrorBoundary", () => { + // 테스트 로그 쌓임 방지 const consoleError = jest.spyOn(console, "error").mockImplementation(() => {}); + // 테스트 끝날때마다 호출기록 초기화ㅏ afterEach(() => { consoleError.mockClear(); }); + // 테스트 종료시 원래 콘솔로 돌리기 afterAll(() => { consoleError.mockRestore(); }); From 0020f70ed0e5d95c46073f3ea36f673d45376403 Mon Sep 17 00:00:00 2001 From: celine Date: Thu, 16 Apr 2026 22:49:25 +0900 Subject: [PATCH 05/11] =?UTF-8?q?test(ui/modal):=20describe=20=EB=AA=85?= =?UTF-8?q?=ED=99=95=ED=95=98=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: #286 --- .../ui/Modals/AlertModal/index.test.tsx | 12 +++++------ .../components/ReviewModal/index.test.tsx | 20 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/components/ui/Modals/AlertModal/index.test.tsx b/components/ui/Modals/AlertModal/index.test.tsx index c943259b..4206cf07 100644 --- a/components/ui/Modals/AlertModal/index.test.tsx +++ b/components/ui/Modals/AlertModal/index.test.tsx @@ -22,7 +22,7 @@ jest.mock("..", () => ({ })); describe("AlertModal", () => { - describe("isOpen 상태에 따라 모달이 열리고 닫히는지 확인", () => { + describe("모달이 열리고 닫히는지 확인한다", () => { test("isOpen이 true면 모달이 열리고 메세지가 보인다", () => { render( {}} handleConfirmButton={() => {}}> @@ -32,7 +32,7 @@ describe("AlertModal", () => { expect(screen.getByText("Alert 메세지")).toBeInTheDocument(); }); - test("isOpen이 false면 모달이 렌더링되지 않는다.", () => { + test("isOpen이 false면 메세지가 보이지 않는다.", () => { render( {}} handleConfirmButton={() => {}}> Alert 메세지 @@ -43,8 +43,8 @@ describe("AlertModal", () => { }); }); - describe("onClose 확인", () => { - test("취소 버튼 클릭 시 onClose가 호출되는지 확인", async () => { + describe("취소 버튼으로 모달을 닫을 수 있는지 확인한다", () => { + test("취소 버튼 클릭 시 onClose가 호출되며 모달이 닫긴다.", async () => { const handleClose = jest.fn(); render( @@ -62,7 +62,7 @@ describe("AlertModal", () => { }); }); - describe("handleConfirmButton 확인", () => { + describe("확인 버튼으로 동작을 실행할 수 있는지 확인한다", () => { test("확인 버튼 클릭 시 handleConfirm이 호출되는지 확인", async () => { const handleConfirm = jest.fn(); render( @@ -79,7 +79,7 @@ describe("AlertModal", () => { }); }); - describe("confirmLabel 확인", () => { + describe("버튼 문구가 올바르게 보이는지 확인한다", () => { test("confirmLabel='삭제'면 버튼에 삭제가 보이는지 확인", async () => { render( {}} handleConfirmButton={() => {}}> diff --git a/features/shared/components/ReviewModal/index.test.tsx b/features/shared/components/ReviewModal/index.test.tsx index 43c5d24c..0f5f2a3b 100644 --- a/features/shared/components/ReviewModal/index.test.tsx +++ b/features/shared/components/ReviewModal/index.test.tsx @@ -63,7 +63,7 @@ function renderReviewModal(props = {}) { } describe("ReviewModal", () => { - describe("isOpen 상태에 따라 모달이 열리고 닫히는지 확인", () => { + describe("모달이 열리고 닫히는지 확인한다", () => { test("isOpen이 true면 모달이 열리고 메시지가 보인다", () => { renderReviewModal(); @@ -76,7 +76,7 @@ describe("ReviewModal", () => { }); }); - describe("onClose 확인", () => { + describe("취소 버튼으로 모달을 닫을 수 있는지 확인한다", () => { test("취소 버튼 클릭 시 onClose가 호출되는지 확인", async () => { const { onClose } = renderReviewModal(); @@ -89,7 +89,7 @@ describe("ReviewModal", () => { }); }); - describe("mode 분기 확인", () => { + describe("작성모드와 수정모드를 확인한다", () => { test("create 모드면 '리뷰 작성' 타이틀과 '작성 완료' 버튼이 보인다", () => { renderReviewModal({ mode: "create" }); @@ -111,7 +111,7 @@ describe("ReviewModal", () => { }); }); - describe("초기값 반영 확인", () => { + describe("리뷰 초기값이 반영 되는지 확인한다", () => { test("edit 모드에서 초기 score와 comment가 반영된다", () => { renderReviewModal({ mode: "edit", initialValue: { score: 4, comment: "리뷰 내용" } }); @@ -124,7 +124,7 @@ describe("ReviewModal", () => { }); }); - describe("form 제출 확인", () => { + describe("별점과 리뷰 제출 되는지 확인한다", () => { test("별점과 리뷰를 제출하면 handleFormSubmit이 호출된다", async () => { const { handleFormSubmit } = renderReviewModal(); const user = userEvent.setup(); @@ -145,7 +145,7 @@ describe("ReviewModal", () => { }); }); - describe("validation 확인", () => { + describe("별점과 리뷰를 입력하지 않고 제출했을때 유효성메시지가 뜨는지 확인한다", () => { test("별점과 리뷰를 입력하지 않고 제출하면 validation 메시지가 표시된다", async () => { const { handleFormSubmit } = renderReviewModal(); const user = userEvent.setup(); @@ -162,7 +162,7 @@ describe("ReviewModal", () => { }); }); - describe("dirty 상태 확인", () => { + describe("작성 중 취소할 때 경고창이 보이는지 확인한다", () => { test("변경된 내용이 없으면 취소 클릭 시 바로 onClose가 호출된다", async () => { const { onClose } = renderReviewModal({ mode: "edit", @@ -175,8 +175,8 @@ describe("ReviewModal", () => { expect(onClose).toHaveBeenCalledTimes(1); }); - describe("Alert 동작 확인", () => { - test("작성 중 취소시 dirty 상태면 Alert이 열리는지 확인", async () => { + describe("작성 중 취소시 Alert이 열리는지 확인", () => { + test("create 모드일 때 취소시 Alert이 열리는지 확인", async () => { renderReviewModal(); const user = userEvent.setup(); @@ -187,7 +187,7 @@ describe("ReviewModal", () => { expect(alert).toBeInTheDocument(); }); - test("수정 중 취소시 dirty 상태면 Alert이 열리는지 확인", async () => { + test("edit 모드일 때 취소시 Alert이 열리는지 확인", async () => { renderReviewModal({ mode: "edit", initialValue: { score: 4, comment: "기존 리뷰" } }); const user = userEvent.setup(); From a387c02fd3820575b4d2be19dfe4ddedec6fb4ae Mon Sep 17 00:00:00 2001 From: celine Date: Wed, 29 Apr 2026 12:25:31 +0900 Subject: [PATCH 06/11] =?UTF-8?q?test(ui/ReviewModal):=20renderReviewModal?= =?UTF-8?q?=20=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: #286 --- features/shared/components/ReviewModal/index.test.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/features/shared/components/ReviewModal/index.test.tsx b/features/shared/components/ReviewModal/index.test.tsx index 0f5f2a3b..4f9c0de7 100644 --- a/features/shared/components/ReviewModal/index.test.tsx +++ b/features/shared/components/ReviewModal/index.test.tsx @@ -46,7 +46,7 @@ function renderReviewModal(props = {}) { const onClose = jest.fn(); const handleFormSubmit = jest.fn(); - const utils = render( + render( , ); return { - ...utils, onClose, handleFormSubmit, }; From bc0d773bab1cb4e3b0c15deee2190d7e66c6e1c8 Mon Sep 17 00:00:00 2001 From: celine Date: Wed, 29 Apr 2026 17:13:17 +0900 Subject: [PATCH 07/11] =?UTF-8?q?refactor(header/Notification):=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=EC=84=B1=EC=9D=84=20=EC=9C=84=ED=95=B4=20but?= =?UTF-8?q?ton=EA=B5=AC=EC=A1=B0=20=EB=B0=8F=20aria=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves:286 --- .../components/NotificationCard/index.tsx | 83 +++++++++---------- 1 file changed, 37 insertions(+), 46 deletions(-) diff --git a/features/header/components/NotificationCard/index.tsx b/features/header/components/NotificationCard/index.tsx index 55c42f32..399fc802 100644 --- a/features/header/components/NotificationCard/index.tsx +++ b/features/header/components/NotificationCard/index.tsx @@ -5,11 +5,12 @@ import Thumbnail from "@/components/ui/Thumbnail"; import RelativeTime from "@/components/ui/RelativeTime"; const NOTIFICATION_STYLE = { - card: "px-5 py-3 w-full block text-left hover:bg-purple-50 cursor-pointer", + card: "block w-full cursor-pointer px-5 py-3 text-left hover:bg-purple-50", cardContent: "flex items-start gap-4", cardDot: "size-1 shrink-0 rounded-full bg-linear-to-r from-purple-400 to-purple-700", cardType: "flex items-center text-xs font-semibold text-gray-800", - cardDeleteBtn: "flex size-4 cursor-pointer items-center justify-center rounded-full bg-gray-700", + cardDeleteBtn: + "absolute top-3 right-5 z-1 flex size-4 cursor-pointer items-center justify-center rounded-full bg-gray-700", cardMessage: "pt-1 text-sm text-gray-600 break-keep", cardDate: "flex items-center justify-end gap-1 text-xs text-gray-400", }; @@ -50,58 +51,48 @@ export default function NotificationCard({ const typeUi = NOTIFICATION_TYPE_UI[item.type as keyof typeof NOTIFICATION_TYPE_UI] ?? DEFAULT_NOTIFICATION_TYPE_UI; - // 알림 클릭 시 - function handleCardClick() { - handleReadAction(); - } - // 알림 키보드 활성화 - function handleKeyDown(event: React.KeyboardEvent) { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - handleReadAction(); - } - } - function handleDeleteClick(event: React.MouseEvent) { - event.stopPropagation(); - handleDeleteAction(); - } return ( -
-
- -
-
+
+ -
-

{item.message}

-
- {!item.isRead &&
-
+ + +
); } From 7b4f3438efe0370229e85c89a096fcb03bc0d1a3 Mon Sep 17 00:00:00 2001 From: celine Date: Wed, 29 Apr 2026 17:13:52 +0900 Subject: [PATCH 08/11] =?UTF-8?q?test(header/Notification):=20=EC=83=81?= =?UTF-8?q?=EB=8B=A8=20=EC=95=8C=EB=A6=BC=20=EC=98=81=EC=97=AD=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: #286 --- .../components/Notification/index.test.tsx | 48 ++++ .../NotificationCard/index.test.tsx | 91 +++++++ .../NotificationPanel/index.test.tsx | 244 ++++++++++++++++++ 3 files changed, 383 insertions(+) create mode 100644 features/header/components/Notification/index.test.tsx create mode 100644 features/header/components/NotificationCard/index.test.tsx create mode 100644 features/header/components/NotificationPanel/index.test.tsx diff --git a/features/header/components/Notification/index.test.tsx b/features/header/components/Notification/index.test.tsx new file mode 100644 index 00000000..721a6156 --- /dev/null +++ b/features/header/components/Notification/index.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; + +import Notification from "."; + +jest.mock("@/components/ui/icons", () => ({ + IcBellOutline: () => 읽은 알림 아이콘, + IcBellUnreadOutline: () => 읽지 않은 알림 아이콘, +})); + +jest.mock("@/features/header/queries", () => ({ + useGetNotificationsCount: jest.fn(), +})); + +jest.mock("@/features/header/components/NotificationPanel", () => { + return function MockNotificationPanel() { + return
알림 패널
; + }; +}); + +const { useGetNotificationsCount } = jest.requireMock("@/features/header/queries"); + +describe("상단 알림 아이콘 컴포넌트 ", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("알림 상태를 확인한다", () => { + test("읽지 않은 알림이 없으면 기본 알림 아이콘이 보인다", () => { + useGetNotificationsCount.mockReturnValue({ + data: { count: 0 }, + }); + render(); + + const icon = screen.getByText("읽은 알림 아이콘"); + expect(icon).toBeInTheDocument(); + }); + + test("읽지 않은 알림이 있으면 읽지 않은 알림 아이콘이 보인다", () => { + useGetNotificationsCount.mockReturnValue({ + data: { count: 3 }, + }); + render(); + + const icon = screen.getByText("읽지 않은 알림 아이콘"); + expect(icon).toBeInTheDocument(); + }); + }); +}); diff --git a/features/header/components/NotificationCard/index.test.tsx b/features/header/components/NotificationCard/index.test.tsx new file mode 100644 index 00000000..d88e6889 --- /dev/null +++ b/features/header/components/NotificationCard/index.test.tsx @@ -0,0 +1,91 @@ +import { render, screen } from "@testing-library/react"; +import NotificationCard from "."; +import userEvent from "@testing-library/user-event"; + +const mockItem = { + id: 1, + type: "COMMENT", + message: "새 댓글이 달렸습니다", + image: "", + isRead: false, + createdAt: "2026-05-01T00:00:00.000Z", + postId: 10, +}; + +jest.mock("@/components/ui/Thumbnail", () => { + return function MockThumbnail() { + return
; + }; +}); + +jest.mock("@/components/ui/RelativeTime", () => { + return function MockRelativeTime({ date }: { date: string }) { + return {date}; + }; +}); + +function renderNotificationCard(props = {}) { + const handleDeleteAction = jest.fn(); + const handleReadAction = jest.fn(); + const user = userEvent.setup(); + + render( + , + ); + return { + user, + handleDeleteAction, + handleReadAction, + }; +} + +describe("상단 알림 카드 컴포넌트", () => { + describe("알림 내용을 확인한다", () => { + test("알림 타입과 메세지가 렌더링된다", () => { + renderNotificationCard(); + + const notificationType = screen.getByText("새로운 댓글"); + const comment = screen.getByText("새 댓글이 달렸습니다"); + + expect(notificationType).toBeInTheDocument(); + expect(comment).toBeInTheDocument(); + }); + + test("읽은 알림이면 읽지 않음 표시가 렌더링되지 않는다", () => { + renderNotificationCard({ + item: { + ...mockItem, + isRead: true, + }, + }); + + const card = screen.queryByTestId("unread-indicator"); + expect(card).not.toBeInTheDocument(); + }); + }); + + describe("알림을 읽고 삭제 할 수 있는지 확인한다", () => { + test("카드를 클릭하면 읽음 액션이 호출된다", async () => { + const { handleReadAction, user } = renderNotificationCard(); + + const card = screen.getByRole("button", { name: "읽지 않은 알림" }); + await user.click(card); + + expect(handleReadAction).toHaveBeenCalledTimes(1); + }); + + test("삭제 버튼을 클릭하면 삭제 액션이 호출된다", async () => { + const { handleDeleteAction, user } = renderNotificationCard(); + + const deleteButton = screen.getByRole("button", { name: "알림 삭제" }); + await user.click(deleteButton); + + expect(handleDeleteAction).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/features/header/components/NotificationPanel/index.test.tsx b/features/header/components/NotificationPanel/index.test.tsx new file mode 100644 index 00000000..982f288c --- /dev/null +++ b/features/header/components/NotificationPanel/index.test.tsx @@ -0,0 +1,244 @@ +import userEvent from "@testing-library/user-event"; +import NotificationPanel from "."; +import { render, screen } from "@testing-library/react"; + +const mockCommentItem = { + id: 1, + type: "COMMENT", + message: "새 댓글이 달렸습니다.", + image: "", + isRead: false, + createdAt: "2026-05-01T00:00:00.000Z", + postId: 10, +}; + +const mockMeetingItem = { + id: 2, + type: "MEETING_CONFIRMED", + message: "모임이 확정됐습니다.", + image: "", + isRead: true, + createdAt: "2026-05-01T00:00:00.000Z", + meetingId: 20, +}; + +const mockFns = { + push: jest.fn(), + fetchNextPage: jest.fn(), + putRead: jest.fn(), + putReadAll: jest.fn(), + deleteOne: jest.fn(), + deleteAll: jest.fn(), + close: jest.fn(), +}; +jest.mock("@/components/ui/Loading", () => { + return function MockLoading() { + return
로딩 중
; + }; +}); + +jest.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockFns.push }), +})); + +jest.mock("@/hooks/useIntersectionObserver", () => ({ + useIntersectionObserver: jest.fn(), +})); + +jest.mock("@/features/header/queries", () => ({ + useGetNotifications: jest.fn(), +})); + +jest.mock("@/features/header/mutations", () => ({ + usePutNotificationsRead: () => ({ mutate: mockFns.putRead }), + usePutNotificationsReadAll: () => ({ mutate: mockFns.putReadAll, isPending: false }), + useDeleteNotifications: () => ({ mutate: mockFns.deleteOne }), + useDeleteNotificationsAll: () => ({ mutate: mockFns.deleteAll, isPending: false }), +})); + +const { useGetNotifications } = jest.requireMock("@/features/header/queries"); +const { useIntersectionObserver } = jest.requireMock("@/hooks/useIntersectionObserver"); + +function mockNotifications(overrides = {}) { + useGetNotifications.mockReturnValue({ + data: { + pages: [{ data: [mockCommentItem, mockMeetingItem] }], + }, + fetchNextPage: mockFns.fetchNextPage, + hasNextPage: false, + isFetchingNextPage: false, + isPending: false, + ...overrides, + }); +} + +function renderNotificationPanel(props = {}) { + const user = userEvent.setup(); + + render(); + return { + user, + }; +} + +describe("상단 알림 패널 컴포넌트", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockNotifications(); + }); + + describe("알림 내역을 확인한다", () => { + test("로딩 중이면 로딩 UI가 보인다", () => { + mockNotifications({ + isPending: true, + data: undefined, + }); + renderNotificationPanel({ unreadCount: 0 }); + + const loading = screen.getByText("로딩 중"); + + expect(loading).toBeInTheDocument(); + }); + + test("알림이 없으면 빈 상태 메시지가 보인다", () => { + mockNotifications({ + data: { pages: [{ data: [] }] }, + }); + renderNotificationPanel({ unreadCount: 0 }); + + const empty = screen.getByText("아직 알림이 없어요"); + + expect(empty).toBeInTheDocument(); + }); + + test("알림이 있으면 알림 메시지가 렌더링된다", () => { + renderNotificationPanel(); + + const comment = screen.getByText("새 댓글이 달렸습니다."); + const meeting = screen.getByText("모임이 확정됐습니다."); + expect(comment).toBeInTheDocument(); + expect(meeting).toBeInTheDocument(); + }); + }); + + describe("알림을 읽을 수 있는지 확인한다", () => { + test("읽지 않은 댓글 알림을 클릭하면 읽음 처리 후 해당 게시글로 이동한다", async () => { + const { user } = renderNotificationPanel(); + + const notifications = screen.getByText("새 댓글이 달렸습니다."); + await user.click(notifications); + + expect(mockFns.putRead).toHaveBeenCalledWith({ notificationId: 1 }); + expect(mockFns.close).toHaveBeenCalledTimes(1); + expect(mockFns.push).toHaveBeenCalledWith("/connect/10"); + }); + + test("이미 읽은 모임 알림을 클릭하면 읽음 처리는 하지 않고 모임 상세로 이동한다", async () => { + const { user } = renderNotificationPanel(); + + const notifications = screen.getByText("모임이 확정됐습니다."); + await user.click(notifications); + + expect(mockFns.putRead).not.toHaveBeenCalledWith({ notificationId: 2 }); + expect(mockFns.close).toHaveBeenCalledTimes(1); + expect(mockFns.push).toHaveBeenCalledWith("/meetup/20"); + }); + + test("모두 읽기 버튼을 클릭하면 전체 읽음 액션이 호출된다", async () => { + const { user } = renderNotificationPanel(); + + const readButton = screen.getByRole("button", { name: "모두 읽기" }); + await user.click(readButton); + + expect(mockFns.putReadAll).toHaveBeenCalledTimes(1); + }); + + test("읽지 않은 알림이 없으면 모두 읽기 버튼이 비활성화된다", () => { + mockNotifications({ + data: { + pages: [ + { + data: [ + { ...mockCommentItem, isRead: true }, + { ...mockMeetingItem, isRead: true }, + ], + }, + ], + }, + }); + renderNotificationPanel({ unreadCount: 0 }); + + const readButton = screen.getByRole("button", { name: "모두 읽기" }); + + expect(readButton).toBeDisabled(); + }); + }); + + describe("알림을 삭제할 수 있는지 확인한다", () => { + test("알림 삭제 버튼을 클릭하면 개별 삭제 액션이 호출된다", async () => { + const { user } = renderNotificationPanel(); + + const deleteButtons = screen.getAllByRole("button", { name: "알림 삭제" }); + await user.click(deleteButtons[0]); + + expect(mockFns.deleteOne).toHaveBeenCalledWith({ notificationId: 1 }); + }); + test("알림 전체 삭제 버튼을 클릭하면 전체 삭제 액션이 호출된다", async () => { + const { user } = renderNotificationPanel(); + + const deleteButton = screen.getByRole("button", { name: "알림 전체 삭제" }); + await user.click(deleteButton); + + expect(mockFns.deleteAll).toHaveBeenCalledTimes(1); + }); + }); + + describe("무한스크롤이 올바르게 작동하는지 확인한다", () => { + test("다음 페이지가 있고 가져오는 중이 아니면 무한 스크롤 감시가 활성화된다", () => { + mockNotifications({ + hasNextPage: true, + }); + + renderNotificationPanel(); + + expect(useIntersectionObserver).toHaveBeenCalledWith( + expect.objectContaining({ + onIntersect: mockFns.fetchNextPage, + isEnabled: true, + }), + ); + }); + + test("다음 페이지가 없으면 무한 스크롤 감시가 비활성화된다", () => { + mockNotifications({ + hasNextPage: false, + isFetchingNextPage: false, + }); + + renderNotificationPanel(); + + expect(useIntersectionObserver).toHaveBeenCalledWith( + expect.objectContaining({ + onIntersect: mockFns.fetchNextPage, + isEnabled: false, + }), + ); + }); + + test("다음 페이지를 가져오는 중이면 무한 스크롤 감시가 비활성화된다", () => { + mockNotifications({ + hasNextPage: true, + isFetchingNextPage: true, + }); + + renderNotificationPanel(); + + expect(useIntersectionObserver).toHaveBeenCalledWith( + expect.objectContaining({ + onIntersect: mockFns.fetchNextPage, + isEnabled: false, + }), + ); + }); + }); +}); From 11f0f62584116a22b1e99bf8c9020de7c2d36fb3 Mon Sep 17 00:00:00 2001 From: celine Date: Wed, 29 Apr 2026 20:20:21 +0900 Subject: [PATCH 09/11] =?UTF-8?q?test(mypage/DetailCard):=20detailCard=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: #286 --- .../components/DetailCard/index.test.tsx | 101 ++++++++++++++++++ features/mypage/mapper.test.ts | 24 ++--- features/mypage/mockData.ts | 20 ++-- features/mypage/utils.ts | 1 - 4 files changed, 123 insertions(+), 23 deletions(-) create mode 100644 features/mypage/components/DetailCard/index.test.tsx diff --git a/features/mypage/components/DetailCard/index.test.tsx b/features/mypage/components/DetailCard/index.test.tsx new file mode 100644 index 00000000..8c61ccf4 --- /dev/null +++ b/features/mypage/components/DetailCard/index.test.tsx @@ -0,0 +1,101 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import DetailCard from "."; +import { mockMeMeetingApiRes } from "../../mockData"; + +jest.mock("next/image", () => { + return function MockImage(props: { alt: string }) { + return {props.alt}; + }; +}); + +function renderDetailCard(props = {}) { + const user = userEvent.setup(); + + render(); + return { + user, + }; +} + +describe("모임 카드 컴포넌트", () => { + describe("모임 정보를 확인한다", () => { + test("모임명, 참여 인원, 위치, 날짜, 시간이 렌더링 된다", () => { + renderDetailCard(); + + const name = screen.getByText("코딩 스터디"); + const participantCount = screen.getByText("2/2"); + const region = screen.getByText("경기 수원시 영통구"); + const date = screen.getByText("4월 6일"); + const time = screen.getByText("01:05"); + + expect(name).toBeInTheDocument(); + expect(participantCount).toBeInTheDocument(); + expect(region).toBeInTheDocument(); + expect(date).toBeInTheDocument(); + expect(time).toBeInTheDocument(); + }); + + test("모임명과 이미지에 상세페이지 링크가 연결된다", () => { + renderDetailCard(); + + const titleLink = screen.getByRole("link", { name: "코딩 스터디" }); + const imageLink = screen.getByRole("link", { + name: "코딩 스터디모임 대표 이미지", + }); + + expect(titleLink).toHaveAttribute("href", "/meetup/1000"); + expect(imageLink).toHaveAttribute("href", "/meetup/1000"); + }); + + test("badge가 있으면 렌더링 된다", () => { + renderDetailCard({ + badges: [ + { + label: "개설확정", + variant: "completed", + }, + ], + }); + + const badge = screen.getByText("개설확정"); + expect(badge).toBeInTheDocument(); + }); + }); + describe("모임 액션 버튼을 클릭한다", () => { + test("액션 버튼을 클릭하면 액션 핸들러가 실행된다", async () => { + const handleClick = jest.fn(); + const { user } = renderDetailCard({ + actions: [ + { + label: "리뷰 작성하기", + variant: "primary", + handleCardButtonClick: handleClick, + }, + ], + }); + + const reviewCreateButton = screen.getByRole("button", { name: "리뷰 작성하기" }); + await user.click(reviewCreateButton); + + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + test("찜 버튼을 클릭하면 찜하기가 된다", async () => { + const handleWishClick = jest.fn(); + + const { user } = renderDetailCard({ + wishAction: { + isWished: false, + isPending: false, + handleWishClick, + }, + }); + + const wishButton = screen.getByRole("button", { name: "찜 하기" }); + await user.click(wishButton); + + expect(handleWishClick).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/features/mypage/mapper.test.ts b/features/mypage/mapper.test.ts index feb9a881..364e7ee3 100644 --- a/features/mypage/mapper.test.ts +++ b/features/mypage/mapper.test.ts @@ -27,8 +27,8 @@ describe("mypage mapper", () => { id: 1000, name: "코딩 스터디", region: "경기 수원시 영통구", - dateTime: "2026-03-31T16:05:00.000Z", - registrationEnd: "2026-03-31T16:00:00.000Z", + dateTime: "2026-04-05T16:05:00.000Z", + registrationEnd: "2026-04-05T16:00:00.000Z", capacity: 2, participantCount: 2, image: "https://example.com/host.jpg", @@ -62,13 +62,13 @@ describe("mypage mapper", () => { test("canceledAt과 confirmedAt에 값이 있으면 그대로 반환한다", () => { const input = { ...mockMeMeetingApiRes, - canceledAt: "2026-03-31T09:41:49.482Z", - confirmedAt: "2026-03-31T09:40:02.178Z", + canceledAt: "2026-04-05T09:41:49.482Z", + confirmedAt: "2026-04-05T09:40:02.178Z", }; const result = mapUsersMeMeetings(input); - expect(result.canceledAt).toBe("2026-03-31T09:41:49.482Z"); - expect(result.confirmedAt).toBe("2026-03-31T09:40:02.178Z"); + expect(result.canceledAt).toBe("2026-04-05T09:41:49.482Z"); + expect(result.confirmedAt).toBe("2026-04-05T09:40:02.178Z"); }); test("mapJoinedMeeting와 달리 role를 반환한다 ", () => { const result = mapUsersMeMeetings(mockMeMeetingApiRes); @@ -85,8 +85,8 @@ describe("mypage mapper", () => { id: 1000, name: "코딩 스터디", region: "경기 수원시 영통구", - dateTime: "2026-03-31T16:05:00.000Z", - registrationEnd: "2026-03-31T16:00:00.000Z", + dateTime: "2026-04-05T16:05:00.000Z", + registrationEnd: "2026-04-05T16:00:00.000Z", capacity: 2, participantCount: 2, image: "https://example.com/host.jpg", @@ -119,13 +119,13 @@ describe("mypage mapper", () => { test("canceledAt과 confirmedAt에 값이 있으면 그대로 반환한다", () => { const input = { ...mockMeetingJoinedApiRes, - canceledAt: "2026-03-31T09:41:49.482Z", - confirmedAt: "2026-03-31T09:40:02.178Z", + canceledAt: "2026-04-05T09:41:49.482Z", + confirmedAt: "2026-04-05T09:40:02.178Z", }; const result = mapJoinedMeeting(input); - expect(result.canceledAt).toBe("2026-03-31T09:41:49.482Z"); - expect(result.confirmedAt).toBe("2026-03-31T09:40:02.178Z"); + expect(result.canceledAt).toBe("2026-04-05T09:41:49.482Z"); + expect(result.confirmedAt).toBe("2026-04-05T09:40:02.178Z"); }); }); diff --git a/features/mypage/mockData.ts b/features/mypage/mockData.ts index a8f09f0c..953bdd55 100644 --- a/features/mypage/mockData.ts +++ b/features/mypage/mockData.ts @@ -11,8 +11,8 @@ export const mockMeMeetingApiRes: MeMeetingApiRes = { address: "경기 수원시 영통구 원천동 산 5-1, 상세주소", latitude: 37.28295793156606, longitude: 127.0435528563181, - dateTime: "2026-03-31T16:05:00.000Z", - registrationEnd: "2026-03-31T16:00:00.000Z", + dateTime: "2026-04-05T16:05:00.000Z", + registrationEnd: "2026-04-05T16:00:00.000Z", capacity: 2, participantCount: 2, image: "https://example.com/host.jpg", @@ -20,8 +20,8 @@ export const mockMeMeetingApiRes: MeMeetingApiRes = { canceledAt: null, confirmedAt: null, hostId: 1234, - createdAt: "2026-03-31T09:41:49.482Z", - updatedAt: "2026-03-31T09:42:02.178Z", + createdAt: "2026-04-05T09:41:49.482Z", + updatedAt: "2026-04-05T09:42:02.178Z", host: { id: 1234, name: "홍길동", @@ -29,7 +29,7 @@ export const mockMeMeetingApiRes: MeMeetingApiRes = { }, createdBy: 1234, isFavorited: false, - joinedAt: "2026-03-31T09:41:50.024Z", + joinedAt: "2026-04-05T09:41:50.024Z", isReviewed: false, isCompleted: true, role: "host", @@ -44,8 +44,8 @@ export const mockMeetingJoinedApiRes: MeetingJoinedApiRes = { address: "경기 수원시 영통구 원천동 산 5-1, 상세주소", latitude: 37.28295793156606, longitude: 127.0435528563181, - dateTime: "2026-03-31T16:05:00.000Z", - registrationEnd: "2026-03-31T16:00:00.000Z", + dateTime: "2026-04-05T16:05:00.000Z", + registrationEnd: "2026-04-05T16:00:00.000Z", capacity: 2, participantCount: 2, image: "https://example.com/host.jpg", @@ -53,8 +53,8 @@ export const mockMeetingJoinedApiRes: MeetingJoinedApiRes = { canceledAt: null, confirmedAt: null, hostId: 1234, - createdAt: "2026-03-31T09:41:49.482Z", - updatedAt: "2026-03-31T09:42:02.178Z", + createdAt: "2026-04-05T09:41:49.482Z", + updatedAt: "2026-04-05T09:42:02.178Z", host: { id: 1234, name: "홍길동", @@ -62,7 +62,7 @@ export const mockMeetingJoinedApiRes: MeetingJoinedApiRes = { }, createdBy: 1234, isFavorited: false, - joinedAt: "2026-03-31T09:41:50.024Z", + joinedAt: "2026-04-05T09:41:50.024Z", isReviewed: false, isCompleted: true, }; diff --git a/features/mypage/utils.ts b/features/mypage/utils.ts index 06159c46..434342fa 100644 --- a/features/mypage/utils.ts +++ b/features/mypage/utils.ts @@ -8,7 +8,6 @@ export interface HostMeetupActionHandlers { /** 모임 취소 */ onCancelMeetup: () => void; /** 모임 리뷰 작성 */ - onWriteReview: () => void; } From 64fee63b0a46b101f31db284ce46814a5b2e69f6 Mon Sep 17 00:00:00 2001 From: celine Date: Wed, 29 Apr 2026 21:30:17 +0900 Subject: [PATCH 10/11] =?UTF-8?q?test(mypage/ReviewCard):=20reviewCard=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves:#286 --- .../components/ReviewCard/index.test.tsx | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 features/mypage/components/ReviewCard/index.test.tsx diff --git a/features/mypage/components/ReviewCard/index.test.tsx b/features/mypage/components/ReviewCard/index.test.tsx new file mode 100644 index 00000000..4ce95f41 --- /dev/null +++ b/features/mypage/components/ReviewCard/index.test.tsx @@ -0,0 +1,102 @@ +import userEvent from "@testing-library/user-event"; +import ReviewCard from "."; +import { ReviewCardItem } from "../../types"; +import { mockUserProfile } from "../../mockData"; +import { render, screen } from "@testing-library/react"; + +jest.mock("next/image", () => { + return function MockImage(props: { alt: string }) { + return {props.alt}; + }; +}); + +jest.mock("@smastrom/react-rating", () => ({ + Rating: ({ value }: { value: number }) =>
{value}
, +})); + +const mockItem: ReviewCardItem = { + id: 10, + score: 5, + comment: "함께 공부해서 좋았어요", + meetingId: 1000, + meetingType: "자기계발", + meetingName: "코딩 스터디", + meetingImage: "https://example.com/image.jpg", + meetingDateTime: "2026-04-02T16:05:00.000Z", + createdAt: "2026-04-08T06:22:04.892Z", +}; + +function renderReviewCard(props = {}) { + const user = userEvent.setup(); + const handleEdit = jest.fn(); + const handleDelete = jest.fn(); + + render( + , + ); + return { + user, + handleEdit, + handleDelete, + }; +} + +describe("작성한 리뷰 컴포넌트", () => { + describe("작성한 리뷰 내용을 확인한다", () => { + test("별점, 리뷰 내용, 모임명, 카테고리, 작성자 이름이 렌더링 된다", () => { + renderReviewCard(); + + const score = screen.getByTestId("rating-value"); + const review = screen.getByText("함께 공부해서 좋았어요"); + const meetingName = screen.getByText("코딩 스터디"); + const type = screen.getByText("자기계발"); + const userName = screen.getByText("홍길동"); + + expect(score).toHaveTextContent("5"); + expect(review).toBeInTheDocument(); + expect(meetingName).toBeInTheDocument(); + expect(type).toBeInTheDocument(); + expect(userName).toBeInTheDocument(); + }); + + test("이미지에 상세페이지 링크가 연결된다", () => { + renderReviewCard(); + + const imageLink = screen.getByRole("link", { + name: "코딩 스터디모임 대표 이미지", + }); + + expect(imageLink).toHaveAttribute("href", "/meetup/1000"); + }); + }); + describe("작성한 리뷰를 변경한다", () => { + test("수정하기 클릭 시 수정 액션이 실행된다", async () => { + const { user, handleEdit } = renderReviewCard(); + + const dropdown = screen.getByRole("button", { name: "리뷰 옵션 열기" }); + await user.click(dropdown); + + const editButton = await screen.findByRole("menuitem", { name: "수정하기" }); + await user.click(editButton); + + expect(handleEdit).toHaveBeenCalledTimes(1); + }); + test("삭제하기 클릭 시 삭제 액션이 실행된다", async () => { + const { user, handleDelete } = renderReviewCard(); + + const dropdown = screen.getByRole("button", { name: "리뷰 옵션 열기" }); + await user.click(dropdown); + + const deleteButton = await screen.findByRole("menuitem", { name: "삭제하기" }); + await user.click(deleteButton); + + expect(handleDelete).toHaveBeenCalledTimes(1); + }); + }); +}); From dbeb22fdd3c8e5fa240bd453f9c6fc1080ddf66a Mon Sep 17 00:00:00 2001 From: celine Date: Thu, 30 Apr 2026 17:36:32 +0900 Subject: [PATCH 11/11] =?UTF-8?q?test(mypage/profile):=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=20=EC=88=98=EC=A0=95=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves:#286 --- .../ProfileModal/ProfileImage.test.tsx | 134 +++++++++++ .../components/ProfileModal/ProfileImage.tsx | 1 + .../components/ProfileModal/index.test.tsx | 222 ++++++++++++++++++ features/mypage/mapper.test.ts | 4 +- features/mypage/mockData.ts | 10 +- 5 files changed, 364 insertions(+), 7 deletions(-) create mode 100644 features/mypage/components/ProfileModal/ProfileImage.test.tsx create mode 100644 features/mypage/components/ProfileModal/index.test.tsx diff --git a/features/mypage/components/ProfileModal/ProfileImage.test.tsx b/features/mypage/components/ProfileModal/ProfileImage.test.tsx new file mode 100644 index 00000000..9ed9f897 --- /dev/null +++ b/features/mypage/components/ProfileModal/ProfileImage.test.tsx @@ -0,0 +1,134 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import ProfileImage from "./ProfileImage"; + +const mockFns = { + uploadImage: jest.fn(), + resetFile: jest.fn(), + changeFile: jest.fn(), +}; + +jest.mock("@/components/ui/Avatar", () => { + return function MockAvatar({ src }: { src: string | null }) { + return
{src ?? "no-image"}
; + }; +}); + +jest.mock("@/hooks/useInputImage", () => ({ + __esModule: true, + default: () => ({ + previewUrl: null, + resetFile: mockFns.resetFile, + changeFile: mockFns.changeFile, + }), +})); + +jest.mock("@/features/mypage/mutations", () => ({ + useUploadProfileImage: () => ({ + mutateAsync: mockFns.uploadImage, + isPending: false, + }), +})); + +function renderProfileImage(props = {}) { + const user = userEvent.setup(); + const handleImageChange = jest.fn(); + const handleUploadPendingChange = jest.fn(); + + render( + , + ); + + return { + user, + handleImageChange, + handleUploadPendingChange, + }; +} + +// 테스트용 이미지 파일 +function createImageFile({ name = "profile.png", type = "image/png", size = 1024 } = {}) { + return new File([new Uint8Array(size)], name, { type }); +} + +function getFileInput() { + return document.querySelector('input[type="file"]') as HTMLInputElement; +} + +describe("프로필 이미지 업로드 컴포넌트", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFns.uploadImage.mockResolvedValue("https://example.com/uploaded.jpg"); + }); + + test("현재 프로필 이미지가 렌더링된다", () => { + renderProfileImage(); + + const profileImage = screen.getByTestId("avatar-src"); + + expect(profileImage).toHaveTextContent("https://example.com/profile.jpg"); + }); + + test("이미지 크기가 1MB 초과 시 용량 제한 메세지가 보인다", async () => { + const { user, handleImageChange } = renderProfileImage(); + const oversizedImage = createImageFile({ size: 1024 * 1024 + 1 }); + const fileInput = getFileInput(); + + await user.upload(fileInput, oversizedImage); + + const errorMessage = screen.getByText("파일 크기는 1MB를 초과할 수 없습니다."); + + expect(errorMessage).toBeInTheDocument(); + expect(mockFns.uploadImage).not.toHaveBeenCalled(); + expect(handleImageChange).toHaveBeenCalledWith("https://example.com/profile.jpg"); + }); + + test("지원하지 않는 이미지 형식이면 형식 제한 메세지가 보인다", async () => { + renderProfileImage(); + const textFile = createImageFile({ name: "profile.txt", type: "text/plain" }); + const fileInput = getFileInput(); + + fireEvent.change(fileInput, { + target: { files: [textFile] }, + }); + + const errorMessage = await screen.findByText( + "JPEG, PNG, WebP, GIF 형식의 이미지만 업로드 가능합니다.", + ); + + expect(errorMessage).toBeInTheDocument(); + expect(mockFns.uploadImage).not.toHaveBeenCalled(); + }); + + test("이미지 업로드 성공 시 업로드된 이미지 URL을 전달한다", async () => { + const { user, handleImageChange } = renderProfileImage(); + const image = createImageFile(); + const fileInput = getFileInput(); + + await user.upload(fileInput, image); + + expect(mockFns.changeFile).toHaveBeenCalled(); + expect(mockFns.uploadImage).toHaveBeenCalledWith(image); + expect(handleImageChange).toHaveBeenCalledWith("https://example.com/uploaded.jpg"); + }); + + test("업로드된 이미지 삭제 시 이미지가 삭제된다", async () => { + const { user, handleImageChange } = renderProfileImage({ + initialImageUrl: null, + value: "https://example.com/uploaded.jpg", + }); + const deleteButton = screen.getByRole("button", { name: "업로드 이미지 삭제" }); + + await user.click(deleteButton); + + expect(mockFns.resetFile).toHaveBeenCalled(); + expect(handleImageChange).toHaveBeenCalledWith(null); + }); +}); diff --git a/features/mypage/components/ProfileModal/ProfileImage.tsx b/features/mypage/components/ProfileModal/ProfileImage.tsx index 4971c379..b510e402 100644 --- a/features/mypage/components/ProfileModal/ProfileImage.tsx +++ b/features/mypage/components/ProfileModal/ProfileImage.tsx @@ -154,6 +154,7 @@ export default function ProfileImage({ iconSize="md" onClick={() => handleImageReset()} className={STYLE.profileButton} + aria-label="업로드 이미지 삭제" /> )}
diff --git a/features/mypage/components/ProfileModal/index.test.tsx b/features/mypage/components/ProfileModal/index.test.tsx new file mode 100644 index 00000000..14c0083a --- /dev/null +++ b/features/mypage/components/ProfileModal/index.test.tsx @@ -0,0 +1,222 @@ +import { render, screen } from "@testing-library/react"; +import ProfileModal from "."; +import userEvent from "@testing-library/user-event"; +import { mockUserProfile } from "../../mockData"; + +const patchMutate = jest.fn(); + +jest.mock("@/components/ui/Modals", () => ({ + Modal: ({ + isOpen, + title, + onClose, + children, + footer, + }: { + isOpen: boolean; + title?: string; + onClose?: () => void; + children: React.ReactNode; + footer?: React.ReactNode; + }) => + isOpen ? ( +
+ {title &&

{title}

} + +
{children}
+ {footer &&
{footer}
} +
+ ) : null, +})); + +jest.mock("@/features/mypage/mutations", () => ({ + usePatchUsersMe: jest.fn(() => ({ + mutate: patchMutate, + isPending: false, + })), +})); + +jest.mock("./ProfileImage", () => { + return function MockProfileImage({ + value, + handleImageChange, + handleUploadPendingChange, + }: { + value: string | null; + handleImageChange: (imageUrl: string | null) => void; + handleUploadPendingChange?: (isPending: boolean) => void; + }) { + return ( +
+
{value ?? "no-image"}
+ + +
+ ); + }; +}); + +function renderProfileModal(props = {}) { + const user = userEvent.setup(); + const onClose = jest.fn(); + + render(); + return { + user, + onClose, + }; +} + +describe("프로필 수정 모달 컴포넌트", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("프로필 수정 모달이 열리고 닫히는지 확인한다", () => { + test("모달이 열리면 프로필이 기본값으로 렌더링 된다", () => { + renderProfileModal(); + + const title = screen.getByRole("heading", { name: "프로필 수정하기" }); + const name = screen.getByRole("textbox", { name: /닉네임/ }); + const email = screen.getByRole("textbox", { name: "아이디" }); + const image = screen.getByTestId("profile-image-value"); + + expect(title).toBeInTheDocument(); + expect(name).toHaveValue("홍길동"); + expect(email).toHaveValue("test@example.com"); + expect(image).toHaveTextContent("https://example.com/profile.jpg"); + }); + + test("닫기버튼 클릭 시 onClose가 호출되며 모달이 닫힌다", async () => { + const { user, onClose } = renderProfileModal(); + const closeButton = screen.getByRole("button", { name: "모달 닫기" }); + + await user.click(closeButton); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + }); + + describe("프로필을 수정할 수 있는지 확인한다", () => { + test("변경된 값이 없으면 mutation을 호출하지 않고 모달을 닫는다", async () => { + const { user, onClose } = renderProfileModal(); + + const editButton = screen.getByRole("button", { name: "수정하기" }); + await user.click(editButton); + + expect(patchMutate).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + test("닉네임만 변경하고 제출하면 변경된 필드만 payload로 전달된다.", async () => { + const { user } = renderProfileModal(); + + const name = screen.getByRole("textbox", { name: /닉네임/ }); + const editButton = screen.getByRole("button", { name: "수정하기" }); + + await user.clear(name); + await user.type(name, "김코딩"); + await user.click(editButton); + + expect(patchMutate).toHaveBeenCalledTimes(1); + expect(patchMutate).toHaveBeenCalledWith({ name: "김코딩" }); + }); + + test("이미지와 닉네임 모두 변경 시 변경된 필드가 payload로 전달된다", async () => { + const { user } = renderProfileModal(); + + const name = screen.getByRole("textbox", { name: /닉네임/ }); + const imageButton = screen.getByRole("button", { name: "이미지 변경" }); + const editButton = screen.getByRole("button", { name: "수정하기" }); + + await user.clear(name); + await user.type(name, "김코딩"); + await user.click(imageButton); + await user.click(editButton); + + expect(patchMutate).toHaveBeenCalledWith({ + name: "김코딩", + image: "https://example.com/new.jpg", + }); + }); + }); + + describe("잘못된 값 제출 시 유효성 검사 메세지를 확인한다", () => { + test("닉네임을 비우고 제출하면 필수 입력 메세지가 보인다", async () => { + const { user } = renderProfileModal(); + + const name = screen.getByRole("textbox", { name: /닉네임/ }); + const editButton = screen.getByRole("button", { name: "수정하기" }); + + await user.clear(name); + await user.click(editButton); + + const message = screen.getByText("닉네임은 필수 입력 항목입니다."); + expect(message).toBeInTheDocument(); + expect(patchMutate).not.toHaveBeenCalled(); + }); + test("닉네임이 8자 초과 시 길이 제한 메세지가 보인다", async () => { + const { user } = renderProfileModal(); + + const name = screen.getByRole("textbox", { name: /닉네임/ }); + const editButton = screen.getByRole("button", { name: "수정하기" }); + + await user.clear(name); + await user.type(name, "아홉글자닉네임테스트"); + await user.click(editButton); + + const message = screen.getByText("닉네임은 8자 이하로 입력해주세요."); + expect(message).toBeInTheDocument(); + expect(patchMutate).not.toHaveBeenCalled(); + }); + }); + + describe("수정 취소 시 모달이 닫히는지 확인한다", () => { + test("변경된 내용이 없으면 취소 클릭 시 바로 모달이 닫긴다", async () => { + const { user, onClose } = renderProfileModal(); + const closeButton = screen.getByRole("button", { name: "취소" }); + + await user.click(closeButton); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + test("변경된 내용이 있으면 취소 클릭 시 Alert가 열리고 Alert 확인 시 모달이 닫긴다", async () => { + const { user, onClose } = renderProfileModal(); + + const name = screen.getByRole("textbox", { name: /닉네임/ }); + const closeButton = screen.getByRole("button", { name: "취소" }); + + await user.clear(name); + await user.type(name, "김코딩"); + await user.click(closeButton); + + expect(onClose).not.toHaveBeenCalled(); + + const alertMessage = screen.getByText("변경된 내용이 있습니다.수정을 취소하시겠습니까?"); + expect(alertMessage).toBeInTheDocument(); + + const AlertButton = screen.getByRole("button", { name: "확인" }); + await user.click(AlertButton); + expect(onClose).toHaveBeenCalledTimes(1); + }); + }); + + describe("이미지 업로드 중 상태를 확인한다", () => { + test("이미지 업로드 중이면 수정하기 버튼이 비활성화된다", async () => { + const { user } = renderProfileModal(); + + const uploadButton = screen.getByRole("button", { name: "이미지 업로드 중" }); + const editButton = screen.getByRole("button", { name: "수정하기" }); + + await user.click(uploadButton); + expect(editButton).toBeDisabled(); + }); + }); +}); diff --git a/features/mypage/mapper.test.ts b/features/mypage/mapper.test.ts index 364e7ee3..8a487e1e 100644 --- a/features/mypage/mapper.test.ts +++ b/features/mypage/mapper.test.ts @@ -31,7 +31,7 @@ describe("mypage mapper", () => { registrationEnd: "2026-04-05T16:00:00.000Z", capacity: 2, participantCount: 2, - image: "https://example.com/host.jpg", + image: "https://example.com/profile.jpg", canceledAt: null, confirmedAt: null, hostId: 1234, @@ -89,7 +89,7 @@ describe("mypage mapper", () => { registrationEnd: "2026-04-05T16:00:00.000Z", capacity: 2, participantCount: 2, - image: "https://example.com/host.jpg", + image: "https://example.com/profile.jpg", canceledAt: null, confirmedAt: null, hostId: 1234, diff --git a/features/mypage/mockData.ts b/features/mypage/mockData.ts index 953bdd55..7eb99594 100644 --- a/features/mypage/mockData.ts +++ b/features/mypage/mockData.ts @@ -15,7 +15,7 @@ export const mockMeMeetingApiRes: MeMeetingApiRes = { registrationEnd: "2026-04-05T16:00:00.000Z", capacity: 2, participantCount: 2, - image: "https://example.com/host.jpg", + image: "https://example.com/profile.jpg", description: "함께 공부해요", canceledAt: null, confirmedAt: null, @@ -25,7 +25,7 @@ export const mockMeMeetingApiRes: MeMeetingApiRes = { host: { id: 1234, name: "홍길동", - image: "https://example.com/host.jpg", + image: "https://example.com/profile.jpg", }, createdBy: 1234, isFavorited: false, @@ -48,7 +48,7 @@ export const mockMeetingJoinedApiRes: MeetingJoinedApiRes = { registrationEnd: "2026-04-05T16:00:00.000Z", capacity: 2, participantCount: 2, - image: "https://example.com/host.jpg", + image: "https://example.com/profile.jpg", description: "함께 공부해요", canceledAt: null, confirmedAt: null, @@ -58,7 +58,7 @@ export const mockMeetingJoinedApiRes: MeetingJoinedApiRes = { host: { id: 1234, name: "홍길동", - image: "https://example.com/host.jpg", + image: "https://example.com/profile.jpg", }, createdBy: 1234, isFavorited: false, @@ -86,5 +86,5 @@ export const mockUserProfile: UserProfile = { id: 1234, name: "홍길동", email: "test@example.com", - image: "https://example.com/host.jpg", + image: "https://example.com/profile.jpg", };