From 8b011131542ab461cd869daf318ad6c5e7c68097 Mon Sep 17 00:00:00 2001 From: PollyGotACracker Date: Sat, 2 May 2026 00:46:38 +0900 Subject: [PATCH 01/13] =?UTF-8?q?test(ui):=20groupCard,=20inputFile=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 --- components/ui/GroupCard/index.test.tsx | 240 ++++++++++++++++++ components/ui/Inputs/InputFile/index.test.tsx | 64 +++++ 2 files changed, 304 insertions(+) create mode 100644 components/ui/GroupCard/index.test.tsx create mode 100644 components/ui/Inputs/InputFile/index.test.tsx diff --git a/components/ui/GroupCard/index.test.tsx b/components/ui/GroupCard/index.test.tsx new file mode 100644 index 00000000..f47a9518 --- /dev/null +++ b/components/ui/GroupCard/index.test.tsx @@ -0,0 +1,240 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import GroupCard from "."; + +const joinButtonText = "참여하기"; +const cancelButtonText = "참여 취소"; +const likeButtonLabel = "찜 하기"; +const unlikeButtonLabel = "찜 취소"; +const ribbonText = "참여 모임"; +const regClosedText = "모집 마감"; +const completedText = "모임 완료"; +const confirmedText = "개설 확정"; + +const mockId = 1; +const mockHref = "/meetup/1"; +const mockImageSrc = "https://example.com/img.jpg"; +const mockImageAlt = "모임 이미지"; +const mockName = "오피스 스트레칭"; +const mockRegion = "을지로 3가"; +const mockType = "운동/건강"; +const mockDate = "1월 7일"; +const mockTime = "17:30"; +const mockDeadlineText = "오늘 21시 마감"; +const mockCapacity = 20; +const mockParticipantCount = 12; +const defaultStatus = { + isConfirmed: false, + isRegClosed: false, + isLiked: false, + isJoined: false, + isCompleted: false, +}; + +jest.mock("next/image", () => { + type MockImageProps = React.ImgHTMLAttributes; + return function MockImage(props: MockImageProps) { + return ; + }; +}); + +jest.mock("next/link", () => { + type MockLinkProps = React.AnchorHTMLAttributes & { href: string }; + return function MockLink({ href, children, ...props }: MockLinkProps) { + return ( + + {children} + + ); + }; +}); + +function renderFullCard( + statusOverrides: Partial = {}, + onClick = jest.fn(), + onLike = jest.fn(), +) { + const status = { ...defaultStatus, ...statusOverrides }; + return render( + + + + + + + + + + + , + ); +} + +describe("GroupCard 컴포넌트 테스트", () => { + test("isJoined가 true이면 참여 리본이 표시되어야 함", () => { + renderFullCard({ isJoined: true }); + + expect(screen.getByText(ribbonText)).toBeInTheDocument(); + }); + + test("isJoined가 false이면 참여 리본이 표시되지 않아야 함", () => { + renderFullCard({ isJoined: false }); + + expect(screen.queryByText(ribbonText)).not.toBeInTheDocument(); + }); +}); + +describe("GroupCard.Image 테스트", () => { + test("이미지가 렌더링되어야 함", () => { + renderFullCard(); + + expect(screen.getByAltText(mockImageAlt)).toBeInTheDocument(); + }); + + test("isRegClosed가 true이면 '모집 마감' 오버레이가 표시되어야 함", () => { + renderFullCard({ isRegClosed: true }); + + expect(screen.getByText(regClosedText)).toBeInTheDocument(); + }); + + test("isCompleted가 true이면 '모임 완료' 오버레이가 표시되어야 함", () => { + renderFullCard({ isRegClosed: true, isCompleted: true }); + + expect(screen.getByText(completedText)).toBeInTheDocument(); + }); + + test("isRegClosed가 false이면 오버레이가 표시되지 않아야 함", () => { + renderFullCard({ isRegClosed: false }); + + expect(screen.queryByText(regClosedText)).not.toBeInTheDocument(); + expect(screen.queryByText(completedText)).not.toBeInTheDocument(); + }); +}); + +describe("GroupCard.Title 테스트", () => { + test("모임 이름이 렌더링되어야 함", () => { + renderFullCard(); + + expect(screen.getByText(mockName)).toBeInTheDocument(); + }); + + test("isConfirmed가 true이면 '개설 확정' 레이블이 표시되어야 함", () => { + renderFullCard({ isConfirmed: true }); + + expect(screen.getByText(confirmedText)).toBeInTheDocument(); + }); + + test("isConfirmed가 false이면 '개설 확정' 레이블이 표시되지 않아야 함", () => { + renderFullCard({ isConfirmed: false }); + + expect(screen.queryByText(confirmedText)).not.toBeInTheDocument(); + }); +}); + +describe("GroupCard.SubTitle 테스트", () => { + test("지역과 모임 종류가 렌더링되어야 함", () => { + renderFullCard(); + + expect(screen.getByText(mockRegion)).toBeInTheDocument(); + expect(screen.getByText(mockType)).toBeInTheDocument(); + }); +}); + +describe("GroupCard.BadgeGroup 테스트", () => { + test("날짜, 시간, 마감 텍스트가 렌더링되어야 함", () => { + renderFullCard(); + + expect(screen.getByText(mockDate)).toBeInTheDocument(); + expect(screen.getByText(mockTime)).toBeInTheDocument(); + expect(screen.getByText(mockDeadlineText)).toBeInTheDocument(); + }); + + test("deadlineText가 없으면 마감 태그가 렌더링되지 않아야 함", () => { + render( + + + + + , + ); + + expect(screen.getByText(mockDate)).toBeInTheDocument(); + expect(screen.queryByText(mockDeadlineText)).not.toBeInTheDocument(); + }); +}); + +describe("GroupCard.ParticipantBar 테스트", () => { + test("참여 인원과 정원이 렌더링되어야 함", () => { + renderFullCard(); + + expect(screen.getByText(String(mockParticipantCount))).toBeInTheDocument(); + expect(screen.getByText(String(mockCapacity))).toBeInTheDocument(); + }); +}); + +describe("GroupCard.JoinButton 테스트", () => { + test("미참여 상태에서 '참여하기' 버튼이 렌더링되어야 함", () => { + renderFullCard({ isJoined: false }); + + expect(screen.getByText(joinButtonText)).toBeInTheDocument(); + }); + + test("참여 상태에서 '참여 취소' 버튼이 렌더링되어야 함", () => { + renderFullCard({ isJoined: true }); + + expect(screen.getByText(cancelButtonText)).toBeInTheDocument(); + }); + + test("클릭 시 onClick이 호출되어야 함", async () => { + const onClick = jest.fn(); + const user = userEvent.setup(); + renderFullCard({ isJoined: false }, onClick); + + await user.click(screen.getByText(joinButtonText)); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + test("isRegClosed가 true이고 미참여이면 버튼이 비활성화되어야 함", () => { + renderFullCard({ isRegClosed: true, isJoined: false }); + + expect(screen.getByText(joinButtonText)).toBeDisabled(); + }); + + test("isCompleted가 true이면 버튼이 렌더링되지 않아야 함", () => { + renderFullCard({ isCompleted: true }); + + expect(screen.queryByText(joinButtonText)).not.toBeInTheDocument(); + expect(screen.queryByText(cancelButtonText)).not.toBeInTheDocument(); + }); + + test("isRegClosed가 true이지만 참여 상태이면 '참여 취소' 버튼이 활성화되어야 함", () => { + renderFullCard({ isRegClosed: true, isJoined: true }); + + expect(screen.getByText(cancelButtonText)).toBeEnabled(); + }); +}); + +describe("GroupCard.LikeButton 테스트", () => { + test("찜 안 한 상태에서 '찜 하기' 버튼이 렌더링되어야 함", () => { + renderFullCard({ isLiked: false }); + + expect(screen.getByRole("button", { name: likeButtonLabel })).toBeInTheDocument(); + }); + + test("찜한 상태에서 '찜 취소' 버튼이 렌더링되어야 함", () => { + renderFullCard({ isLiked: true }); + + expect(screen.getByRole("button", { name: unlikeButtonLabel })).toBeInTheDocument(); + }); + + test("클릭 시 onClick이 호출되어야 함", async () => { + const onLike = jest.fn(); + const user = userEvent.setup(); + renderFullCard({}, jest.fn(), onLike); + + await user.click(screen.getByRole("button", { name: likeButtonLabel })); + + expect(onLike).toHaveBeenCalledTimes(1); + }); +}); diff --git a/components/ui/Inputs/InputFile/index.test.tsx b/components/ui/Inputs/InputFile/index.test.tsx new file mode 100644 index 00000000..37142c2d --- /dev/null +++ b/components/ui/Inputs/InputFile/index.test.tsx @@ -0,0 +1,64 @@ +import { render, screen } from "@testing-library/react"; +import InputFile from "."; + +const placeholderText = "파일 첨부"; +const hiddenInputSelector = 'input[type="file"]'; + +const mockPreviewUrl = "https://example.com/thumb.jpg"; +const mockLabel = "테스트 라벨명"; +const mockName = "customName"; +const mockUseInputImage = { + previewUrl: null as string | null, + setPreviewUrl: jest.fn(), + resetFile: jest.fn(), + changeFile: jest.fn(), +}; + +jest.mock("next/image", () => { + type MockImageProps = React.ImgHTMLAttributes; + return function MockImage(props: MockImageProps) { + return ; + }; +}); + +jest.mock("@/hooks/useInputImage", () => ({ + __esModule: true, + default: () => mockUseInputImage, +})); + +describe("InputFile 컴포넌트 테스트", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseInputImage.previewUrl = null; + }); + + test("커스텀 name이 input에 적용되어야 함", () => { + render(); + + const input = document.querySelector(hiddenInputSelector); + expect(input).toHaveAttribute("name", mockName); + }); + + test("커스텀 label이 렌더링되어야 함", () => { + render(); + + expect(screen.getByText(mockLabel)).toBeInTheDocument(); + expect(document.querySelector(hiddenInputSelector)).toBeInTheDocument(); + }); + + test("isPending이 true이면 로딩 오버레이가 표시되어야 함", () => { + const { container } = render(); + const overlay = container.querySelector("svg"); + + expect(overlay).toBeInTheDocument(); + }); + + test("미리보기 이미지가 있으면 삭제 버튼이 보이고 플레이스홀더는 숨겨져야 함", () => { + mockUseInputImage.previewUrl = mockPreviewUrl; + render(); + + expect(screen.queryByText(placeholderText)).not.toBeInTheDocument(); + expect(screen.getByRole("button")).toBeInTheDocument(); + expect(screen.getByAltText("thumbnail")).toBeInTheDocument(); + }); +}); From 9a51bddc95a78628e104958940b63a5f4778f900 Mon Sep 17 00:00:00 2001 From: PollyGotACracker Date: Sat, 2 May 2026 00:47:24 +0900 Subject: [PATCH 02/13] =?UTF-8?q?fix(meetup):=20=EB=AA=A8=EC=9E=84=20?= =?UTF-8?q?=ED=8F=BC=20=ED=95=84=EB=93=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=9D=B4=EC=8A=88=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- features/meetup/components/CapacityField/index.tsx | 2 +- features/meetup/components/NameField/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/features/meetup/components/CapacityField/index.tsx b/features/meetup/components/CapacityField/index.tsx index bdc299ca..82d04e6a 100644 --- a/features/meetup/components/CapacityField/index.tsx +++ b/features/meetup/components/CapacityField/index.tsx @@ -5,7 +5,7 @@ import { MIN_CONFIRMED_COUNT } from "@/features/meetupDetail/components/Personne interface CapacityFieldProps { /** 필드 이름 @default "capacity" */ - name: string; + name?: string; /** 필드 값 */ value?: number; /** 필드 값 변경 함수 */ diff --git a/features/meetup/components/NameField/index.tsx b/features/meetup/components/NameField/index.tsx index be36f5f1..a910ac93 100644 --- a/features/meetup/components/NameField/index.tsx +++ b/features/meetup/components/NameField/index.tsx @@ -29,7 +29,7 @@ export default function NameField({ Date: Sat, 2 May 2026 00:48:08 +0900 Subject: [PATCH 03/13] =?UTF-8?q?test(meetup):=20=EB=AA=A8=EC=9E=84=20?= =?UTF-8?q?=ED=8F=BC=20=ED=95=84=EB=93=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=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 --- .../components/AddressField/index.test.tsx | 207 ++++++++++++++++++ .../components/CapacityField/index.test.tsx | 53 +++++ .../components/DateTimeField/index.test.tsx | 66 ++++++ .../components/DescField/index.test.tsx | 39 ++++ .../components/FileField/index.test.tsx | 105 +++++++++ .../components/NameField/index.test.tsx | 39 ++++ 6 files changed, 509 insertions(+) create mode 100644 features/meetup/components/AddressField/index.test.tsx create mode 100644 features/meetup/components/CapacityField/index.test.tsx create mode 100644 features/meetup/components/DateTimeField/index.test.tsx create mode 100644 features/meetup/components/DescField/index.test.tsx create mode 100644 features/meetup/components/FileField/index.test.tsx create mode 100644 features/meetup/components/NameField/index.test.tsx diff --git a/features/meetup/components/AddressField/index.test.tsx b/features/meetup/components/AddressField/index.test.tsx new file mode 100644 index 00000000..73e587a2 --- /dev/null +++ b/features/meetup/components/AddressField/index.test.tsx @@ -0,0 +1,207 @@ +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import AddressField, { type AddressValues } from "."; +import type { KakaoPlaceItem } from "../../types"; + +const searchInputPlaceholder = "건물, 지번 또는 도로명 검색"; +const detailInputPlaceholder = "상세 주소"; +const listboxRole = "listbox"; + +const mockAddressNamePartial = "역삼"; +const mockAddressName = "서울 강남구 역삼동 123"; +const mockPlace: KakaoPlaceItem = { + id: "1", + address_name: mockAddressName, + road_address_name: "서울 강남구 테헤란로 123", + place_name: "테스트 장소", + x: "127.0276", + y: "37.4979", + category_group_code: "", + category_group_name: "", + category_name: "", + distance: "", + phone: "", + place_url: "", +}; +function createDefaultValue(): AddressValues { + return { + addressName: "", + addressDetail: "", + latitude: 0, + longitude: 0, + region: "", + }; +} + +const mockHandleShowToast = jest.fn(); +jest.mock("@/providers/toast-provider", () => ({ + useToast: () => ({ handleShowToast: mockHandleShowToast }), +})); + +describe("AddressField 컴포넌트 테스트", () => { + let getKakaoPlaceFn: jest.Mock; + let setIsComboOpened: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + getKakaoPlaceFn = jest.fn().mockResolvedValue([mockPlace]); + setIsComboOpened = jest.fn(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test("주소 입력 포커스 시 setIsComboOpened(true)가 호출되어야 함", async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render( + , + ); + + const addressInput = screen.getByPlaceholderText(searchInputPlaceholder); + await user.click(addressInput); + + expect(setIsComboOpened).toHaveBeenCalledWith(true); + }); + + test("검색 결과가 콤보박스에 표시되어야 함", () => { + render( + , + ); + + const listbox = screen.queryByRole(listboxRole); + expect(listbox).not.toBeInTheDocument(); + }); + + test("isComboOpened가 true이고 검색 결과가 있으면 리스트가 표시되어야 함", async () => { + const setValue = jest.fn(); + render( + , + ); + + const addressInput = screen.getByPlaceholderText(searchInputPlaceholder); + fireEvent.change(addressInput, { target: { value: mockAddressNamePartial } }); + jest.advanceTimersByTime(200); + + await waitFor(() => { + expect(getKakaoPlaceFn).toHaveBeenCalledWith(mockAddressNamePartial); + expect(screen.getByRole(listboxRole)).toBeInTheDocument(); + expect(screen.getByText(mockAddressName)).toBeInTheDocument(); + }); + }); + + test("콤보박스 항목 클릭 시 주소가 선택되고 콤보박스가 닫혀야 함", async () => { + const setValue = jest.fn(); + render( + , + ); + + const addressInput = screen.getByPlaceholderText(searchInputPlaceholder); + fireEvent.change(addressInput, { target: { value: mockAddressNamePartial } }); + jest.advanceTimersByTime(200); + + await waitFor(() => { + expect(screen.getByRole(listboxRole)).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText(mockAddressName)); + + expect(setValue).toHaveBeenCalledWith( + expect.objectContaining({ + addressName: mockAddressName, + latitude: 37.4979, + longitude: 127.0276, + }), + ); + expect(setIsComboOpened).toHaveBeenCalledWith(false); + }); + + test("상세 주소 변경 시 setValue가 호출되어야 함", async () => { + const setValue = jest.fn(); + render( + , + ); + + const detailInput = screen.getByPlaceholderText(detailInputPlaceholder); + fireEvent.change(detailInput, { target: { value: "5층" } }); + + expect(setValue).toHaveBeenCalled(); + }); + + test("카카오 API 에러 시 에러 토스트가 표시되어야 함", async () => { + getKakaoPlaceFn.mockRejectedValue(new Error("API 오류")); + const setValue = jest.fn(); + + render( + , + ); + + const addressInput = screen.getByPlaceholderText(searchInputPlaceholder); + fireEvent.change(addressInput, { target: { value: mockAddressNamePartial } }); + jest.advanceTimersByTime(200); + + await waitFor(() => { + expect(mockHandleShowToast).toHaveBeenCalledWith({ + message: "API 오류", + status: "error", + }); + }); + }); + + test("onValuesChange 콜백이 호출되어야 함", async () => { + const onValuesChange = jest.fn(); + + render( + , + ); + + const mockAddressDetail = "테스트 주소 상세"; + const detailInput = screen.getByPlaceholderText(detailInputPlaceholder); + fireEvent.change(detailInput, { target: { value: mockAddressDetail } }); + + expect(onValuesChange).toHaveBeenCalledWith( + expect.objectContaining({ addressDetail: mockAddressDetail }), + ); + }); +}); diff --git a/features/meetup/components/CapacityField/index.test.tsx b/features/meetup/components/CapacityField/index.test.tsx new file mode 100644 index 00000000..60d366a0 --- /dev/null +++ b/features/meetup/components/CapacityField/index.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import CapacityField from "."; + +const placeholderText = "숫자만 입력해주세요"; + +describe("CapacityField 컴포넌트 테스트", () => { + const onChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("커스텀 name이 input에 적용되어야 함", () => { + const name = "customName"; + render(); + + const input = screen.getByPlaceholderText(placeholderText); + expect(input).toHaveAttribute("name", name); + }); + + test("입력된 값이 양수일 때 input에 표시되어야 함", () => { + const value = 5; + render(); + + expect(screen.getByDisplayValue(value.toString())).toBeInTheDocument(); + }); + + test("value가 0 이하이면 빈 값으로 표시되어야 함", () => { + const value = 0; + render(); + + const input = screen.getByPlaceholderText(placeholderText); + expect(input).toHaveValue(null); + }); + + test("입력 시 onChange가 (number, event) 형태로 호출되어야 함", async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText(placeholderText); + await user.type(input, "10"); + + expect(onChange).toHaveBeenCalledWith(expect.any(Number), expect.any(Object)); + }); + + test("isRequired가 false이면 required 속성이 없어야 함", () => { + render(); + + const input = screen.getByPlaceholderText(placeholderText); + expect(input).not.toBeRequired(); + }); +}); diff --git a/features/meetup/components/DateTimeField/index.test.tsx b/features/meetup/components/DateTimeField/index.test.tsx new file mode 100644 index 00000000..d86a7e01 --- /dev/null +++ b/features/meetup/components/DateTimeField/index.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type React from "react"; +import DateTimeField from "."; + +const dateLabelText = "날짜 선택 열기"; +const timeLabelText = "시간 선택 열기"; +const applyButtonText = "적용"; + +describe("DateTimeField 컴포넌트 테스트", () => { + const onDateChange = jest.fn(); + const onTimeChange = jest.fn(); + + function renderDateTimeField(props?: Partial>) { + return render( + , + ); + } + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("날짜와 시간 값이 표시되어야 함", () => { + const date = "2026-05-01"; + const time = "14:00"; + renderDateTimeField({ date, time }); + + expect(screen.getByDisplayValue(date)).toBeInTheDocument(); + expect(screen.getByDisplayValue(time)).toBeInTheDocument(); + }); + + test("날짜 선택 시 onDateChange가 호출되어야 함", async () => { + const user = userEvent.setup(); + const date = "2026-05-01"; + renderDateTimeField({ date }); + + await user.click(screen.getByLabelText(dateLabelText)); + const applyButton = screen.getByRole("button", { name: applyButtonText }); + await user.click(applyButton); + + expect(onDateChange).toHaveBeenCalledWith(date); + }); + + test("시, 분 선택 시 onTimeChange가 호출되어야 함", async () => { + const user = userEvent.setup(); + renderDateTimeField(); + + await user.click(screen.getByLabelText(timeLabelText)); + + const hourOption = screen.getByRole("option", { name: "14시" }); + await user.click(hourOption); + + const minuteOption = screen.getByRole("option", { name: "30분" }); + await user.click(minuteOption); + + expect(onTimeChange).toHaveBeenCalledWith("14:30"); + }); +}); diff --git a/features/meetup/components/DescField/index.test.tsx b/features/meetup/components/DescField/index.test.tsx new file mode 100644 index 00000000..d9e70c5f --- /dev/null +++ b/features/meetup/components/DescField/index.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import DescField from "."; + +const placeholderText = "모임을 설명해주세요"; + +describe("DescField 컴포넌트 테스트", () => { + const onChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("입력 시 onChange가 (value, event) 형태로 호출되어야 함", async () => { + const user = userEvent.setup(); + render(); + + const value = "테스트 설명"; + const textarea = screen.getByPlaceholderText(placeholderText); + await user.type(textarea, value); + + expect(onChange).toHaveBeenCalledWith(value, expect.any(Object)); + }); + + test("isRequired가 false이면 required 속성이 없어야 함", () => { + render(); + + const textarea = screen.getByPlaceholderText(placeholderText); + expect(textarea).not.toBeRequired(); + }); + + test("커스텀 name이 textarea에 적용되어야 함", () => { + const name = "customDesc"; + render(); + + const textarea = screen.getByPlaceholderText(placeholderText); + expect(textarea).toHaveAttribute("name", name); + }); +}); diff --git a/features/meetup/components/FileField/index.test.tsx b/features/meetup/components/FileField/index.test.tsx new file mode 100644 index 00000000..ac18d55a --- /dev/null +++ b/features/meetup/components/FileField/index.test.tsx @@ -0,0 +1,105 @@ +import { render, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import FileField from "."; + +const hiddenInputSelector = 'input[type="file"]'; + +const mockImgUrl = "https://example.com/image.jpg"; +const createFile = () => new File(["dummy"], "image.jpg", { type: "image/jpg" }); + +const mockHandleShowToast = jest.fn(); +jest.mock("@/providers/toast-provider", () => ({ + useToast: () => ({ handleShowToast: mockHandleShowToast }), +})); + +jest.mock("@/hooks/useInputImage", () => ({ + __esModule: true, + default: ({ onChange }: { onChange?: (e: React.ChangeEvent) => void }) => ({ + previewUrl: null, + setPreviewUrl: jest.fn(), + resetFile: jest.fn(), + changeFile: (e: React.ChangeEvent) => onChange?.(e), + }), +})); + +jest.mock("next/image", () => { + return function MockImage(props: { alt: string; src: string }) { + return {props.alt}; + }; +}); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; + }; +} + +describe("FileField 컴포넌트 테스트", () => { + let onChange = jest.fn(); + let uploadImageFn = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + uploadImageFn; + }); + + test("이미지 업로드 성공 시 onChange와 성공 토스트가 호출되어야 함", async () => { + uploadImageFn.mockResolvedValue(mockImgUrl); + + render(, { + wrapper: createWrapper(), + }); + + const input = document.querySelector(hiddenInputSelector) as HTMLInputElement; + + const user = userEvent.setup(); + await user.upload(input, createFile()); + + await waitFor(() => { + expect(uploadImageFn).toHaveBeenCalled(); + }); + expect(uploadImageFn.mock.calls[0][0]).toEqual(createFile()); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith(mockImgUrl, expect.any(Object)); + }); + expect(mockHandleShowToast).toHaveBeenCalledWith({ + message: "이미지가 업로드되었습니다.", + status: "success", + }); + }); + + test("이미지 업로드 실패 시 에러 토스트가 호출되어야 함", async () => { + uploadImageFn.mockRejectedValue(new Error("업로드 실패")); + + render(, { + wrapper: createWrapper(), + }); + + const input = document.querySelector(hiddenInputSelector) as HTMLInputElement; + + const user = userEvent.setup(); + await user.upload(input, createFile()); + + await waitFor(() => { + expect(mockHandleShowToast).toHaveBeenCalledWith({ + message: "업로드 실패", + status: "error", + }); + }); + }); + + test("커스텀 name이 적용되어야 함", () => { + const name = "customThumbnail"; + render(, { + wrapper: createWrapper(), + }); + + const input = document.querySelector(hiddenInputSelector) as HTMLInputElement; + expect(input).toHaveAttribute("name", name); + }); +}); diff --git a/features/meetup/components/NameField/index.test.tsx b/features/meetup/components/NameField/index.test.tsx new file mode 100644 index 00000000..c235d3bf --- /dev/null +++ b/features/meetup/components/NameField/index.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import NameField from "."; + +const placeholderText = "모임 이름을 입력해주세요"; + +describe("NameField 컴포넌트 테스트", () => { + const onChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("입력 시 onChange가 (value, event) 형태로 호출되어야 함", async () => { + const user = userEvent.setup(); + render(); + + const value = "테스트 모임명"; + const input = screen.getByPlaceholderText(placeholderText); + await user.type(input, value); + + expect(onChange).toHaveBeenCalledWith(value, expect.any(Object)); + }); + + test("isRequired가 false이면 required 속성이 없어야 함", () => { + render(); + + const input = screen.getByPlaceholderText(placeholderText); + expect(input).not.toBeRequired(); + }); + + test("커스텀 name이 input에 적용되어야 함", () => { + const name = "customName"; + render(); + + const input = screen.getByPlaceholderText(placeholderText); + expect(input).toHaveAttribute("name", name); + }); +}); From 07cadf7aa683b474d68da79e3b1a64a9852c655a Mon Sep 17 00:00:00 2001 From: PollyGotACracker Date: Sat, 2 May 2026 00:48:44 +0900 Subject: [PATCH 04/13] =?UTF-8?q?test(meetup):=20=EB=AA=A8=EC=9E=84=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A4=EA=B8=B0=20provider=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../providers/FormDataProvider.test.tsx | 52 +++++++++++------ .../providers/FormStepProvider.test.tsx | 57 ++++++++++++++----- 2 files changed, 79 insertions(+), 30 deletions(-) diff --git a/features/meetup/create/providers/FormDataProvider.test.tsx b/features/meetup/create/providers/FormDataProvider.test.tsx index f54f45a5..3d8f1e63 100644 --- a/features/meetup/create/providers/FormDataProvider.test.tsx +++ b/features/meetup/create/providers/FormDataProvider.test.tsx @@ -2,19 +2,27 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import FormDataProvider, { useFormData } from "./FormDataProvider"; +const testIdStep1 = "step1"; +const testIdStep2 = "step2"; +const testIdStep3 = "step3"; +const testIdAll = "all"; +const testIdValid1 = "valid1"; +const testIdValid2 = "valid2"; +const testIdValid3 = "valid3"; +const testIdInvalid1 = "invalid1"; + function Probe() { const { getStepValid, checkAllStepValid, setStepValid } = useFormData(); return (
- {String(getStepValid(1))} - {String(getStepValid(2))} - {String(checkAllStepValid())} - - + {String(getStepValid(1))} + {String(getStepValid(2))} + {String(getStepValid(3))} + {String(checkAllStepValid())} +
); } @@ -37,16 +45,26 @@ describe("모임 생성 폼 단계별 유효성 상태 저장 테스트", () => }); test("특정 단계를 true로 바꾸면 해당 단계는 true이고 전체 통과는 false", async () => { - expect(screen.getByTestId("step1")).toHaveTextContent("false"); - await user.click(screen.getByRole("button", { name: "valid" })); - expect(screen.getByTestId("step1")).toHaveTextContent("true"); - expect(screen.getByTestId("all")).toHaveTextContent("false"); + expect(screen.getByTestId(testIdStep1)).toHaveTextContent("false"); + await user.click(screen.getByTestId(testIdValid1)); + expect(screen.getByTestId(testIdStep1)).toHaveTextContent("true"); + expect(screen.getByTestId(testIdAll)).toHaveTextContent("false"); }); test("특정 단계 true 후 다시 false로 바꾸면 해당 단계는 false이고 전체 통과는 false", async () => { - await user.click(screen.getByRole("button", { name: "valid" })); - await user.click(screen.getByRole("button", { name: "invalid" })); - expect(screen.getByTestId("step1")).toHaveTextContent("false"); - expect(screen.getByTestId("all")).toHaveTextContent("false"); + await user.click(screen.getByTestId(testIdValid1)); + await user.click(screen.getByTestId(testIdInvalid1)); + expect(screen.getByTestId(testIdStep1)).toHaveTextContent("false"); + expect(screen.getByTestId(testIdAll)).toHaveTextContent("false"); + }); + + test("모든 단계가 통과하면 전체 통과는 true", async () => { + await user.click(screen.getByTestId(testIdValid1)); + await user.click(screen.getByTestId(testIdValid2)); + await user.click(screen.getByTestId(testIdValid3)); + expect(screen.getByTestId(testIdStep1)).toHaveTextContent("true"); + expect(screen.getByTestId(testIdStep2)).toHaveTextContent("true"); + expect(screen.getByTestId(testIdStep3)).toHaveTextContent("true"); + expect(screen.getByTestId(testIdAll)).toHaveTextContent("true"); }); }); diff --git a/features/meetup/create/providers/FormStepProvider.test.tsx b/features/meetup/create/providers/FormStepProvider.test.tsx index 056897ba..1da6741a 100644 --- a/features/meetup/create/providers/FormStepProvider.test.tsx +++ b/features/meetup/create/providers/FormStepProvider.test.tsx @@ -22,16 +22,24 @@ function StepProbe() { ); } +const totalSteps = 5; function setupFormStepProbe(options?: { step?: number; totalSteps?: number }) { const user = userEvent.setup(); - render( - - + let key = 0; + const buildUi = () => ( + + - , + ); - return user; + const view = render(buildUi()); + // key를 사용한 리렌더링 트리거 + const rerender = () => { + key++; + view.rerender(buildUi()); + }; + return { ...view, user, rerender }; } describe("모임 생성 폼 단계별 이동 및 주소 변경 테스트", () => { @@ -39,32 +47,55 @@ describe("모임 생성 폼 단계별 이동 및 주소 변경 테스트", () => window.history.replaceState(null, "", "/"); }); - test("next 함수 실행 시 브라우저 쿼리 step은 1 증가", async () => { + test("next 함수 실행 시 step은 1 증가", async () => { window.history.replaceState(null, "", "?step=2"); - const user = setupFormStepProbe(); + const { user, rerender } = setupFormStepProbe(); expect(screen.getByTestId("current")).toHaveTextContent("2"); + await user.click(screen.getByRole("button", { name: "next" })); + rerender(); + expect(screen.getByTestId("current")).toHaveTextContent("3"); expect(new URLSearchParams(window.location.search).get("step")).toBe("3"); }); - test("prev 함수 실행 시 브라우저 쿼리 step은 1 감소", async () => { + test("prev 함수 실행 시 step은 1 감소", async () => { window.history.replaceState(null, "", "?step=2"); - const user = setupFormStepProbe(); + const { user, rerender } = setupFormStepProbe(); + await user.click(screen.getByRole("button", { name: "prev" })); + rerender(); + expect(screen.getByTestId("current")).toHaveTextContent("1"); expect(new URLSearchParams(window.location.search).get("step")).toBe("1"); }); - test("step 쿼리가 없을 때 next 함수는 step prop + 1을 브라우저 쿼리에 적용", async () => { - const user = setupFormStepProbe({ step: 4 }); + test("step 쿼리가 없을 때 next 함수는 step prop + 1을 step에 적용", async () => { + const { user, rerender } = setupFormStepProbe({ step: 4 }); expect(screen.getByTestId("current")).toHaveTextContent("4"); + await user.click(screen.getByRole("button", { name: "next" })); + rerender(); + expect(screen.getByTestId("current")).toHaveTextContent("5"); expect(new URLSearchParams(window.location.search).get("step")).toBe("5"); }); - test("step 쿼리가 없을 때 prev 함수는 step prop - 1을 브라우저 쿼리에 적용", async () => { - const user = setupFormStepProbe({ step: 4 }); + test("step 쿼리가 없을 때 prev 함수는 step prop - 1을 step에 적용", async () => { + const { user, rerender } = setupFormStepProbe({ step: 4 }); expect(screen.getByTestId("current")).toHaveTextContent("4"); + await user.click(screen.getByRole("button", { name: "prev" })); + rerender(); + expect(screen.getByTestId("current")).toHaveTextContent("3"); expect(new URLSearchParams(window.location.search).get("step")).toBe("3"); }); + + test("마지막 단계에서 next 함수 실행 시 step은 totalSteps 값과 동일해야 함", async () => { + window.history.replaceState(null, "", "?step=5"); + const { user, rerender } = setupFormStepProbe(); + + // 아래 동작이 실행되어도 step 쿼리 값은 그대로여야 함 + await user.click(screen.getByRole("button", { name: "next" })); + rerender(); + expect(screen.getByTestId("current")).toHaveTextContent("5"); + expect(new URLSearchParams(window.location.search).get("step")).toBe("5"); + }); }); From 4831703c46c648e72fd3292847edd8997b3a40a5 Mon Sep 17 00:00:00 2001 From: PollyGotACracker Date: Sat, 2 May 2026 00:50:15 +0900 Subject: [PATCH 05/13] =?UTF-8?q?refactor(apis):=20api=20endpoint=20export?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apis/images.ts | 4 ++-- apis/meetingTypes.ts | 2 +- apis/meetings.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apis/images.ts b/apis/images.ts index 7666ddd8..951f4a0e 100644 --- a/apis/images.ts +++ b/apis/images.ts @@ -35,7 +35,7 @@ export async function uploadImage(file: File): Promise { } /** 이미지 업로드 Step1: presigned URL 발급 */ -const ROUTE_IMAGES = "/images/presigned"; +export const ROUTE_IMAGES = "/images/presigned"; async function getPresignedUrl(fileName: string, contentType: string, folder: string = "meetings") { const res = await clientFetch(ROUTE_IMAGES, { method: "POST", @@ -54,7 +54,7 @@ async function getPresignedUrl(fileName: string, contentType: string, folder: st } /** 이미지 업로드 Step2: S3에 이미지 업로드 */ -const ROUTE_IMAGES_UPLOAD = "/images/upload"; +export const ROUTE_IMAGES_UPLOAD = "/images/upload"; async function uploadToS3(presignedUrl: string, file: File) { const res = await clientFetch(ROUTE_IMAGES_UPLOAD, { method: "PUT", diff --git a/apis/meetingTypes.ts b/apis/meetingTypes.ts index 4fe07b45..592d4cc6 100644 --- a/apis/meetingTypes.ts +++ b/apis/meetingTypes.ts @@ -7,7 +7,7 @@ if (!BASE_URL) { } /** 모임 카테고리 목록 조회(빌드에서 실행) */ -const ROUTE_MEETING_TYPES = "/meeting-types"; +export const ROUTE_MEETING_TYPES = "/meeting-types"; export async function getMeetingTypes() { const res = await fetch(`${BASE_URL}${ROUTE_MEETING_TYPES}`, { method: "GET", diff --git a/apis/meetings.ts b/apis/meetings.ts index 4b1409eb..7ecbcd71 100644 --- a/apis/meetings.ts +++ b/apis/meetings.ts @@ -1,7 +1,7 @@ import { clientFetch } from "@/libs/clientFetch"; -const ROUTE_MEETINGS_FAVORITES = (meetingId: number) => `/meetings/${meetingId}/favorites`; -const ROUTE_MEETINGS_JOIN = (meetingId: number) => `/meetings/${meetingId}/join`; +export const ROUTE_MEETINGS_FAVORITES = (meetingId: number) => `/meetings/${meetingId}/favorites`; +export const ROUTE_MEETINGS_JOIN = (meetingId: number) => `/meetings/${meetingId}/join`; /** 모임 찜 추가 */ export async function postMeetingsFavorite({ meetingId }: { meetingId: number }): Promise { From b55978b9fb7c97c6afa7efd1fca71c30e5d9f067 Mon Sep 17 00:00:00 2001 From: PollyGotACracker Date: Sat, 2 May 2026 00:50:54 +0900 Subject: [PATCH 06/13] =?UTF-8?q?test(apis):=20images,=20meetings,=20meeti?= =?UTF-8?q?ngTypes=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 --- apis/images.test.ts | 99 +++++++++++++++++++++++++++++++++++++++ apis/meetingTypes.test.ts | 80 +++++++++++++++++++++++++++++++ apis/meetings.test.ts | 97 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 276 insertions(+) create mode 100644 apis/images.test.ts create mode 100644 apis/meetingTypes.test.ts create mode 100644 apis/meetings.test.ts diff --git a/apis/images.test.ts b/apis/images.test.ts new file mode 100644 index 00000000..f6a61ec0 --- /dev/null +++ b/apis/images.test.ts @@ -0,0 +1,99 @@ +import { http, HttpResponse } from "msw"; +import { server } from "@/mocks/server"; +import { uploadImage, ROUTE_IMAGES, ROUTE_IMAGES_UPLOAD } from "./images"; + +const mockPresignedUrl = "https://s3.example.com/presigned"; +const mockPublicUrl = "https://cdn.example.com/image.png"; +const mockFileName = "test.png"; +const mockFileType = "image/png"; + +const ENDPOINT_PRESIGNED = `/api${ROUTE_IMAGES}`; +const ENDPOINT_UPLOAD = `/api${ROUTE_IMAGES_UPLOAD}`; + +function createMockFile(name: string, type: string) { + return new File(["dummy"], name, { type }); +} + +describe("uploadImage api 테스트", () => { + test("presigned URL 발급 후 S3 업로드에 성공하면 publicUrl을 반환함", async () => { + server.use( + http.post(ENDPOINT_PRESIGNED, () => + HttpResponse.json( + { presignedUrl: mockPresignedUrl, publicUrl: mockPublicUrl }, + { status: 200 }, + ), + ), + http.put(ENDPOINT_UPLOAD, () => HttpResponse.json({}, { status: 200 })), + ); + + const file = createMockFile(mockFileName, mockFileType); + const result = await uploadImage(file); + + expect(result).toBe(mockPublicUrl); + }); + + test("지원하지 않는 파일 형식이면 에러를 throw함", async () => { + const errorFileName = "test.svg"; + const errorFileType = "image/svg"; + const file = createMockFile(errorFileName, errorFileType); + + await expect(uploadImage(file)).rejects.toThrow( + `'${errorFileType}'는 지원하지 않는 파일 형식입니다.`, + ); + }); + + test("presigned URL 발급에 실패하면 에러를 throw함", async () => { + const errorResponse = { code: "PRESIGN_FAILED", message: "presigned URL 발급 실패" }; + server.use( + http.post(ENDPOINT_PRESIGNED, () => HttpResponse.json(errorResponse, { status: 500 })), + ); + + const file = createMockFile(mockFileName, mockFileType); + + await expect(uploadImage(file)).rejects.toThrow(errorResponse.message); + }); + + test("presigned URL 발급 실패 시 응답 바디 파싱이 안 되면 기본 에러 메시지를 throw함", async () => { + server.use(http.post(ENDPOINT_PRESIGNED, () => new HttpResponse(null, { status: 500 }))); + + const file = createMockFile(mockFileName, mockFileType); + + await expect(uploadImage(file)).rejects.toThrow( + "업로드 주소 생성 중 알 수 없는 에러가 발생했습니다.", + ); + }); + + test("S3 업로드에 실패하면 에러를 throw함", async () => { + const errorResponse = { code: "UPLOAD_FAILED", message: "S3 업로드 실패" }; + + server.use( + http.post(ENDPOINT_PRESIGNED, () => + HttpResponse.json( + { presignedUrl: mockPresignedUrl, publicUrl: mockPublicUrl }, + { status: 200 }, + ), + ), + http.put(ENDPOINT_UPLOAD, () => HttpResponse.json(errorResponse, { status: 500 })), + ); + + const file = createMockFile(mockFileName, mockFileType); + + await expect(uploadImage(file)).rejects.toThrow(errorResponse.message); + }); + + test("S3 업로드 실패 시 응답 바디 파싱이 안 되면 기본 에러 메시지를 throw함", async () => { + server.use( + http.post(ENDPOINT_PRESIGNED, () => + HttpResponse.json( + { presignedUrl: mockPresignedUrl, publicUrl: mockPublicUrl }, + { status: 200 }, + ), + ), + http.put(ENDPOINT_UPLOAD, () => new HttpResponse(null, { status: 500 })), + ); + + const file = createMockFile(mockFileName, mockFileType); + + await expect(uploadImage(file)).rejects.toThrow("업로드 중 알 수 없는 에러가 발생했습니다."); + }); +}); diff --git a/apis/meetingTypes.test.ts b/apis/meetingTypes.test.ts new file mode 100644 index 00000000..2b5f3cf4 --- /dev/null +++ b/apis/meetingTypes.test.ts @@ -0,0 +1,80 @@ +import { http, HttpResponse } from "msw"; +import { server } from "@/mocks/server"; +import { BASE_URL } from "@/mocks/constants"; +import { + getMeetingTypes, + initMeetingTypes, + ROUTE_MEETING_TYPES, + type MeetingTypeResponse, +} from "./meetingTypes"; + +const ENDPOINT_MEETING_TYPES = `${BASE_URL}${ROUTE_MEETING_TYPES}`; + +const mockMeetingTypes: MeetingTypeResponse = [ + { + id: 266, + createdAt: "2025-01-01T00:00:00Z", + name: "자기계발", + description: "게임, 코딩 등의 모임입니다.", + }, + { + id: 267, + createdAt: "2025-01-01T00:00:00Z", + name: "운동/스포츠", + description: "런닝, 테니스 등의 모임입니다.", + }, +]; + +describe("meetingTypes api 테스트", () => { + describe("getMeetingTypes", () => { + test("성공 시 카테고리 목록을 반환함", async () => { + server.use( + http.get(ENDPOINT_MEETING_TYPES, () => + HttpResponse.json(mockMeetingTypes, { status: 200 }), + ), + ); + + const result = await getMeetingTypes(); + + expect(result).toEqual(mockMeetingTypes); + }); + + test("실패 시 응답 메시지로 에러를 throw함", async () => { + const errorResponse = { code: "FETCH_FAILED", message: "카테고리 조회 실패" }; + + server.use( + http.get(ENDPOINT_MEETING_TYPES, () => HttpResponse.json(errorResponse, { status: 500 })), + ); + + await expect(getMeetingTypes()).rejects.toThrow(errorResponse.message); + }); + + test("실패 시 응답 바디 파싱이 안 되면 기본 에러 메시지를 throw함", async () => { + server.use(http.get(ENDPOINT_MEETING_TYPES, () => new HttpResponse(null, { status: 500 }))); + + await expect(getMeetingTypes()).rejects.toThrow("모임 카테고리 조회에 실패했습니다."); + }); + }); + + describe("initMeetingTypes", () => { + test("성공 시 카테고리 목록을 반환함", async () => { + server.use( + http.get(ENDPOINT_MEETING_TYPES, () => + HttpResponse.json(mockMeetingTypes, { status: 200 }), + ), + ); + + const result = await initMeetingTypes(); + + expect(result).toEqual(mockMeetingTypes); + }); + + test("getMeetingTypes가 실패하면 null을 반환함", async () => { + server.use(http.get(ENDPOINT_MEETING_TYPES, () => new HttpResponse(null, { status: 500 }))); + + const result = await initMeetingTypes(); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/apis/meetings.test.ts b/apis/meetings.test.ts new file mode 100644 index 00000000..a6847188 --- /dev/null +++ b/apis/meetings.test.ts @@ -0,0 +1,97 @@ +import { http, HttpResponse } from "msw"; +import { server } from "@/mocks/server"; +import { + postMeetingsFavorite, + deleteMeetingsFavorite, + postMeetingsJoin, + deleteMeetingsJoin, + ROUTE_MEETINGS_FAVORITES, + ROUTE_MEETINGS_JOIN, +} from "./meetings"; + +const ENDPOINT_FAVORITES = (id: number) => `/api${ROUTE_MEETINGS_FAVORITES(id)}`; +const ENDPOINT_JOIN = (id: number) => `/api${ROUTE_MEETINGS_JOIN(id)}`; + +describe("postMeetingsFavorite api 테스트", () => { + test("성공 시 응답 데이터를 반환함", async () => { + const result = await postMeetingsFavorite({ meetingId: 3 }); + + expect(result).toMatchObject({ id: 3, isFavorited: true }); + }); + + test("존재하지 않는 모임이면 에러를 throw함", async () => { + await expect(postMeetingsFavorite({ meetingId: 9999 })).rejects.toThrow( + "모임을 찾을 수 없습니다.", + ); + }); + + test("응답 바디가 없으면 기본 에러 메시지를 throw함", async () => { + server.use(http.post(ENDPOINT_FAVORITES(9999), () => new HttpResponse(null, { status: 500 }))); + + await expect(postMeetingsFavorite({ meetingId: 9999 })).rejects.toThrow( + "모임 찜 추가에 실패했습니다.", + ); + }); +}); + +describe("deleteMeetingsFavorite api 테스트", () => { + test("성공 시 응답 데이터를 반환함", async () => { + const result = await deleteMeetingsFavorite({ meetingId: 4 }); + + expect(result).toMatchObject({ message: "찜 해제 성공" }); + }); + + test("존재하지 않는 모임이면 에러를 throw함", async () => { + await expect(deleteMeetingsFavorite({ meetingId: 9999 })).rejects.toThrow( + "모임을 찾을 수 없습니다.", + ); + }); + + test("응답 바디가 없으면 기본 에러 메시지를 throw함", async () => { + server.use( + http.delete(ENDPOINT_FAVORITES(9999), () => new HttpResponse(null, { status: 500 })), + ); + + await expect(deleteMeetingsFavorite({ meetingId: 9999 })).rejects.toThrow( + "모임 찜 해제에 실패했습니다.", + ); + }); +}); + +describe("postMeetingsJoin api 테스트", () => { + test("이미 참여한 모임이면 에러를 throw함", async () => { + await expect(postMeetingsJoin({ meetingId: 1 })).rejects.toThrow("ALREADY_JOINED"); + }); + + test("존재하지 않는 모임이면 에러를 throw함", async () => { + await expect(postMeetingsJoin({ meetingId: 9999 })).rejects.toThrow("NOT_FOUND"); + }); + + test("응답 바디가 없으면 기본 에러 메시지를 throw함", async () => { + server.use(http.post(ENDPOINT_JOIN(9999), () => new HttpResponse(null, { status: 500 }))); + + await expect(postMeetingsJoin({ meetingId: 9999 })).rejects.toThrow( + "모임 참여에 실패했습니다.", + ); + }); +}); + +describe("deleteMeetingsJoin api 테스트", () => { + test("성공 시 응답 데이터를 반환함", async () => { + const result = await deleteMeetingsJoin({ meetingId: 5 }); + + expect(result).toMatchObject({ message: "참여 취소 성공" }); + }); + + test("존재하지 않는 모임이면 에러를 throw함", async () => { + await expect(deleteMeetingsJoin({ meetingId: 9999 })).rejects.toThrow("NOT_FOUND"); + }); + + test("응답 바디가 없으면 기본 에러 메시지를 throw함", async () => { + server.use(http.delete(ENDPOINT_JOIN(9999), () => new HttpResponse(null, { status: 500 }))); + + await expect(deleteMeetingsJoin({ meetingId: 9999 })).rejects.toThrow( + "모임 참여 취소에 실패했습니다.", + ); + }); +}); From 73c96df51700c2522ecdc9dee068518d95042732 Mon Sep 17 00:00:00 2001 From: PollyGotACracker Date: Sat, 2 May 2026 00:59:17 +0900 Subject: [PATCH 07/13] =?UTF-8?q?test(apis):=20=EB=AA=A8=EC=9E=84=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C,=20=EC=83=9D=EC=84=B1=20api=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 --- features/meetup/apis.test.ts | 122 +++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 features/meetup/apis.test.ts diff --git a/features/meetup/apis.test.ts b/features/meetup/apis.test.ts new file mode 100644 index 00000000..e006946b --- /dev/null +++ b/features/meetup/apis.test.ts @@ -0,0 +1,122 @@ +import { http, HttpResponse } from "msw"; +import { server } from "@/mocks/server"; +import { getKakaoPlace, getMeetups, postMeetup } from "./apis"; +import { buildMeetupListResponse } from "@/mocks/data/meetings/helpers"; +import { MEETINGS } from "@/mocks/data/meetings/fixtures"; +import type { KakaoPlaceItem, MeetupCreateRequest } from "./types"; + +const ENDPOINT_KAKAO_PLACE = "/api/kakao/place"; +const ENDPOINT_MEETINGS = "/api/meetings"; + +describe("getKakaoPlace api 테스트", () => { + const mockPlaceItemData: KakaoPlaceItem[] = [ + { + id: "1", + address_name: "서울 강남구 역삼동 123", + road_address_name: "서울 강남구 테헤란로 123", + place_name: "테스트 장소", + x: "127.0276", + y: "37.4979", + category_group_code: "", + category_group_name: "", + category_name: "", + distance: "", + phone: "", + place_url: "", + }, + ]; + + test("성공 시 KakaoPlaceItem 목록 데이터를 반환함", async () => { + server.use( + http.get(ENDPOINT_KAKAO_PLACE, () => + HttpResponse.json({ documents: mockPlaceItemData }, { status: 200 }), + ), + ); + + const result = await getKakaoPlace("역삼"); + + expect(result).toEqual(mockPlaceItemData); + }); + + test("실패 시 응답 메시지로 에러를 throw함", async () => { + const errorResponse = { message: "카카오 API 오류" }; + server.use( + http.get(ENDPOINT_KAKAO_PLACE, () => HttpResponse.json(errorResponse, { status: 500 })), + ); + + await expect(getKakaoPlace("역삼")).rejects.toThrow(errorResponse.message); + }); + + test("응답 body가 없으면 기본 에러 메시지를 throw함", async () => { + server.use(http.get(ENDPOINT_KAKAO_PLACE, () => new HttpResponse(null, { status: 500 }))); + + await expect(getKakaoPlace("역삼")).rejects.toThrow( + "카카오 장소 검색 API 호출에 실패했습니다.", + ); + }); +}); + +describe("getMeetups api 테스트", () => { + test("성공 시 MeetupListResponse 데이터를 반환함", async () => { + const result = await getMeetups({}); + const expected = buildMeetupListResponse(MEETINGS.data, {}); + + expect(result).toEqual(expected); + }); + + test("실패 시 응답 메시지로 에러를 throw함", async () => { + const errorResponse = { message: "알 수 없는 서버 에러가 발생했습니다." }; + server.use( + http.get(ENDPOINT_MEETINGS, () => HttpResponse.json(errorResponse, { status: 500 })), + ); + + await expect(getMeetups({})).rejects.toThrow(errorResponse.message); + }); + + test("응답 body가 없으면 기본 에러 메시지를 throw함", async () => { + server.use(http.get(ENDPOINT_MEETINGS, () => new HttpResponse(null, { status: 500 }))); + + await expect(getMeetups({})).rejects.toThrow("모임 목록을 불러오는데 실패했습니다."); + }); +}); + +describe("postMeetup api 테스트", () => { + const mockCreateData: MeetupCreateRequest = { + name: "테스트 모임", + type: "자기계발", + region: "서울특별시 강남구", + address: "서울특별시 강남구 역삼동 123", + latitude: 37.4979, + longitude: 127.0276, + dateTime: "2026-06-01T10:00:00.000Z", + registrationEnd: "2026-05-31T23:59:59.000Z", + capacity: 10, + image: "https://example.com/image.png", + description: "테스트 모임입니다.", + }; + + test("성공 시 생성된 모임을 반환함", async () => { + const result = await postMeetup(mockCreateData); + + expect(result).toMatchObject({ + name: mockCreateData.name, + type: mockCreateData.type, + }); + expect(result.id).toBeDefined(); + }); + + test("실패 시 응답 메시지로 에러를 throw함", async () => { + const errorResponse = { message: "알 수 없는 클라이언트 문제가 발생했습니다." }; + server.use( + http.post(ENDPOINT_MEETINGS, () => HttpResponse.json(errorResponse, { status: 400 })), + ); + + await expect(postMeetup(mockCreateData)).rejects.toThrow(errorResponse.message); + }); + + test("응답 body가 없으면 기본 에러 메시지를 throw함", async () => { + server.use(http.post(ENDPOINT_MEETINGS, () => new HttpResponse(null, { status: 500 }))); + + await expect(postMeetup(mockCreateData)).rejects.toThrow("모임 생성에 실패했습니다."); + }); +}); From 2178d37dfbd5bd2058305e9e477a9d1a84844636 Mon Sep 17 00:00:00 2001 From: PollyGotACracker Date: Sat, 2 May 2026 00:59:47 +0900 Subject: [PATCH 08/13] =?UTF-8?q?test(hooks):=20useDragScroll=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 --- hooks/useDragScroll.test.ts | 51 +++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 hooks/useDragScroll.test.ts diff --git a/hooks/useDragScroll.test.ts b/hooks/useDragScroll.test.ts new file mode 100644 index 00000000..b48d89f0 --- /dev/null +++ b/hooks/useDragScroll.test.ts @@ -0,0 +1,51 @@ +import { renderHook, act } from "@testing-library/react"; +import type { PointerEvent, MouseEvent } from "react"; +import useDragScroll from "./useDragScroll"; + +type MockPointerEvent = Pick, "button" | "clientX">; +type MockMouseEvent = Pick; + +function setup() { + const { result } = renderHook(() => useDragScroll()); + + // ref.current 직접 주입 + const el = document.createElement("div"); + result.current.ref.current = el; + + // 클릭 이벤트 모킹 + const click: MockMouseEvent = { stopPropagation: jest.fn(), preventDefault: jest.fn() }; + return { result, click }; +} + +describe("useDragScroll 커스텀 훅 테스트", () => { + test("초기 overflow 상태에서 left와 right는 모두 false여야 함", () => { + const { result } = setup(); + + expect(result.current.overflow).toEqual({ left: false, right: false }); + }); + + test("드래그에서 발생되는 기본 클릭 이벤트는 무효여야 함", () => { + const { result, click } = setup(); + + act(() => { + const down: MockPointerEvent = { button: 0, clientX: 100 }; + const move: MockPointerEvent = { button: 0, clientX: 110 }; + result.current.onPointerDown(down as PointerEvent); + result.current.onPointerMove(move as PointerEvent); + }); + + result.current.onClickCapture(click as MouseEvent); + + expect(click.stopPropagation).toHaveBeenCalled(); + expect(click.preventDefault).toHaveBeenCalled(); + }); + + test("드래그 없이 클릭 시 클릭 이벤트가 실행되어야 함", () => { + const { result, click } = setup(); + + result.current.onClickCapture(click as MouseEvent); + + expect(click.stopPropagation).not.toHaveBeenCalled(); + expect(click.preventDefault).not.toHaveBeenCalled(); + }); +}); From c489521a6ad95c91ce012f6316f399b4c2bdecfb Mon Sep 17 00:00:00 2001 From: PollyGotACracker Date: Sat, 2 May 2026 01:00:06 +0900 Subject: [PATCH 09/13] =?UTF-8?q?test(hooks):=20useInputImage=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 --- hooks/useInputImage.test.ts | 47 +++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 hooks/useInputImage.test.ts diff --git a/hooks/useInputImage.test.ts b/hooks/useInputImage.test.ts new file mode 100644 index 00000000..58c04c66 --- /dev/null +++ b/hooks/useInputImage.test.ts @@ -0,0 +1,47 @@ +import { renderHook, act } from "@testing-library/react"; +import useInputImage from "./useInputImage"; + +beforeEach(() => { + jest.spyOn(URL, "revokeObjectURL").mockImplementation(jest.fn()); + jest.spyOn(URL, "createObjectURL").mockReturnValue("blob:mock-url"); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +function setup(props: Partial[0]> = {}) { + const inputRef = { current: document.createElement("input") }; + const { result } = renderHook(() => useInputImage({ inputRef, ...props })); + return { result, inputRef }; +} + +describe("useInputImage 커스텀 훅 테스트", () => { + const defaultUrl = "https://example.com/img.jpg"; + const mockValue = "C:\\fakepath\\img.jpg"; + + test("초기 previewUrl은 defaultUrl과 동일해야 함", () => { + const { result } = setup({ defaultUrl }); + + expect(result.current.previewUrl).toBe(defaultUrl); + }); + + test("resetFile 호출 시 previewUrl이 null이 되고 input value가 초기화됨", () => { + const { result, inputRef } = setup({ defaultUrl }); + inputRef.current.value = mockValue; + + act(() => result.current.resetFile()); + + expect(result.current.previewUrl).toBeNull(); + expect(inputRef.current.value).toBe(""); + }); + + test("resetFile 호출 시 onChange 콜백이 실행됨", () => { + const onChange = jest.fn(); + const { result, inputRef } = setup({ onChange }); + + act(() => result.current.resetFile()); + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ target: inputRef.current })); + }); +}); From be72ad5d944ad39451fae7cbf98049d7048bb37b Mon Sep 17 00:00:00 2001 From: PollyGotACracker Date: Sat, 2 May 2026 01:00:17 +0900 Subject: [PATCH 10/13] =?UTF-8?q?test(hooks):=20useQueryParams=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 --- hooks/useQueryParams.test.ts | 47 ++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 hooks/useQueryParams.test.ts diff --git a/hooks/useQueryParams.test.ts b/hooks/useQueryParams.test.ts new file mode 100644 index 00000000..36f21efd --- /dev/null +++ b/hooks/useQueryParams.test.ts @@ -0,0 +1,47 @@ +import { renderHook, act } from "@testing-library/react"; +import { useQueryParams } from "./useQueryParams"; + +jest.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(window.location.search), +})); + +beforeEach(() => { + window.history.replaceState(null, "", "/"); +}); + +describe("useQueryParams 커스텀 훅 테스트", () => { + test("get 함수 실행 시 해당 키의 쿼리 파라미터 값을 반환", () => { + window.history.replaceState(null, "", "?tab=info&page=2"); + const { result } = renderHook(() => useQueryParams()); + + expect(result.current.get("tab")).toBe("info"); + expect(result.current.get("page")).toBe("2"); + expect(result.current.get("missing")).toBeNull(); + }); + + test("set 함수 실행 시 쿼리 파라미터가 URL에 반영", () => { + window.history.replaceState(null, "", "?tab=info"); + const { result } = renderHook(() => useQueryParams()); + + act(() => { + result.current.set({ tab: "review", page: "3" }); + }); + + const updated = new URLSearchParams(window.location.search); + expect(updated.get("tab")).toBe("review"); + expect(updated.get("page")).toBe("3"); + }); + + test("set 함수에 null 전달 시 해당 쿼리 파라미터가 URL에서 삭제", () => { + window.history.replaceState(null, "", "?tab=info&page=2"); + const { result } = renderHook(() => useQueryParams()); + + act(() => { + result.current.set({ tab: null, page: "5" }); + }); + + const updated = new URLSearchParams(window.location.search); + expect(updated.has("tab")).toBe(false); + expect(updated.get("page")).toBe("5"); + }); +}); From 161b6b876ee9883fd1cde93c7b2e8447585216d0 Mon Sep 17 00:00:00 2001 From: PollyGotACracker Date: Sat, 2 May 2026 01:00:39 +0900 Subject: [PATCH 11/13] =?UTF-8?q?test(hooks):=20useScrollVisibilityDynamic?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hooks/useScrollVisibilityDynamic.test.ts | 68 ++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 hooks/useScrollVisibilityDynamic.test.ts diff --git a/hooks/useScrollVisibilityDynamic.test.ts b/hooks/useScrollVisibilityDynamic.test.ts new file mode 100644 index 00000000..3852a400 --- /dev/null +++ b/hooks/useScrollVisibilityDynamic.test.ts @@ -0,0 +1,68 @@ +import { renderHook, act } from "@testing-library/react"; +import useScrollVisibilityDynamic from "./useScrollVisibilityDynamic"; + +// rAF 내부 스크롤 판정 함수(flush)를 테스트에서 수동 실행하기 위한 변수 +let pendingRaf: FrameRequestCallback | null = null; + +// 대기 중인 rAF 콜백을 즉시 실행 +function flushRaf() { + if (pendingRaf) { + const cb = pendingRaf; + pendingRaf = null; + cb(performance.now()); + } +} + +// 브라우저 스크롤 시뮬레이션 (위치 변경 => 이벤트 발생 => rAF 실행) +function scrollTo(y: number) { + Object.defineProperty(window, "scrollY", { value: y, writable: true, configurable: true }); + window.dispatchEvent(new Event("scroll")); + flushRaf(); +} + +beforeEach(() => { + jest.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { + pendingRaf = cb; + return 1; + }); + jest.spyOn(window, "cancelAnimationFrame").mockImplementation(jest.fn()); + jest.spyOn(performance, "now").mockReturnValue(0); + Object.defineProperty(window, "scrollY", { value: 0, writable: true, configurable: true }); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe("useScrollVisibilityDynamic 커스텀 훅 테스트", () => { + const params = { cooldownMs: 0, collapsePx: 80, revealPx: 40 }; + + test("초기 상태는 isVisible가 true, hasPassedTopOffset가 false여야 함", () => { + const { result } = renderHook(() => useScrollVisibilityDynamic(params)); + + act(() => flushRaf()); + expect(result.current.isVisible).toBe(true); + expect(result.current.hasPassedTopOffset).toBe(false); + }); + + test("topOffset 초과 후 아래로 collapsePx 이상 스크롤 시 isVisible이 false로 전환", () => { + const { result } = renderHook(() => useScrollVisibilityDynamic(params)); + + act(() => flushRaf()); + act(() => scrollTo(170)); + expect(result.current.isVisible).toBe(false); + expect(result.current.hasPassedTopOffset).toBe(true); + }); + + test("접힌 상태에서 위로 revealPx 이상 스크롤 시 isVisible이 true로 복귀", () => { + const { result } = renderHook(() => useScrollVisibilityDynamic(params)); + + act(() => flushRaf()); + act(() => scrollTo(170)); + expect(result.current.isVisible).toBe(false); + + act(() => scrollTo(130)); + expect(result.current.isVisible).toBe(true); + expect(result.current.hasPassedTopOffset).toBe(true); + }); +}); From 962b8305834de2b27fa0ee831e9ec67cc86f45cd Mon Sep 17 00:00:00 2001 From: PollyGotACracker Date: Sat, 2 May 2026 11:35:48 +0900 Subject: [PATCH 12/13] =?UTF-8?q?test(ui):=20inputFile=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=9A=94=EC=86=8C=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EC=BF=BC=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/ui/Inputs/InputFile/index.test.tsx | 16 +++++++++------- components/ui/Inputs/InputFile/index.tsx | 5 ++++- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/components/ui/Inputs/InputFile/index.test.tsx b/components/ui/Inputs/InputFile/index.test.tsx index 37142c2d..d14f80e6 100644 --- a/components/ui/Inputs/InputFile/index.test.tsx +++ b/components/ui/Inputs/InputFile/index.test.tsx @@ -2,7 +2,9 @@ import { render, screen } from "@testing-library/react"; import InputFile from "."; const placeholderText = "파일 첨부"; -const hiddenInputSelector = 'input[type="file"]'; +const hiddenFileInputSelector = 'input[type="file"]'; +const thumbnailAltText = "thumbnail"; +const pendingStatusAriaLabel = "파일 업로드 중"; const mockPreviewUrl = "https://example.com/thumb.jpg"; const mockLabel = "테스트 라벨명"; @@ -35,7 +37,7 @@ describe("InputFile 컴포넌트 테스트", () => { test("커스텀 name이 input에 적용되어야 함", () => { render(); - const input = document.querySelector(hiddenInputSelector); + const input = screen.getByLabelText(placeholderText, { selector: hiddenFileInputSelector }); expect(input).toHaveAttribute("name", mockName); }); @@ -43,14 +45,14 @@ describe("InputFile 컴포넌트 테스트", () => { render(); expect(screen.getByText(mockLabel)).toBeInTheDocument(); - expect(document.querySelector(hiddenInputSelector)).toBeInTheDocument(); + + expect(screen.getByLabelText(mockLabel, { selector: hiddenFileInputSelector })).toBeInTheDocument(); }); test("isPending이 true이면 로딩 오버레이가 표시되어야 함", () => { - const { container } = render(); - const overlay = container.querySelector("svg"); + render(); - expect(overlay).toBeInTheDocument(); + expect(screen.getByRole("status", { name: pendingStatusAriaLabel })).toBeInTheDocument(); }); test("미리보기 이미지가 있으면 삭제 버튼이 보이고 플레이스홀더는 숨겨져야 함", () => { @@ -59,6 +61,6 @@ describe("InputFile 컴포넌트 테스트", () => { expect(screen.queryByText(placeholderText)).not.toBeInTheDocument(); expect(screen.getByRole("button")).toBeInTheDocument(); - expect(screen.getByAltText("thumbnail")).toBeInTheDocument(); + expect(screen.getByAltText(thumbnailAltText)).toBeInTheDocument(); }); }); diff --git a/components/ui/Inputs/InputFile/index.tsx b/components/ui/Inputs/InputFile/index.tsx index 8bc82494..cbb5a583 100644 --- a/components/ui/Inputs/InputFile/index.tsx +++ b/components/ui/Inputs/InputFile/index.tsx @@ -112,7 +112,10 @@ export default function InputFile({ isPending ? "pointer-events-none" : !previewUrl ? "cursor-pointer" : "cursor-initial", )}> {isPending && ( -
+
)} From b11eb6c14152f3f7c6fe2115ba88f484439d1c28 Mon Sep 17 00:00:00 2001 From: PollyGotACracker Date: Sat, 2 May 2026 11:36:36 +0900 Subject: [PATCH 13/13] =?UTF-8?q?test(meetup):=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=9A=94=EC=86=8C=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EC=BF=BC=EB=A6=AC=20=EB=B0=8F=20=EC=84=A4=EB=AA=85?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/AddressField/index.test.tsx | 4 +-- .../components/FileField/index.test.tsx | 35 +++++++++++++------ 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/features/meetup/components/AddressField/index.test.tsx b/features/meetup/components/AddressField/index.test.tsx index 73e587a2..ec43cbee 100644 --- a/features/meetup/components/AddressField/index.test.tsx +++ b/features/meetup/components/AddressField/index.test.tsx @@ -70,7 +70,7 @@ describe("AddressField 컴포넌트 테스트", () => { expect(setIsComboOpened).toHaveBeenCalledWith(true); }); - test("검색 결과가 콤보박스에 표시되어야 함", () => { + test("isComboOpened가 true여도 검색어가 없으면 콤보박스가 표시되지 않아야 함", () => { render( { expect(listbox).not.toBeInTheDocument(); }); - test("isComboOpened가 true이고 검색 결과가 있으면 리스트가 표시되어야 함", async () => { + test("isComboOpened가 true이고 검색 결과가 있으면 콤보박스가 표시되어야 함", async () => { const setValue = jest.fn(); render( new File(["dummy"], "image.jpg", { type: "image/jpg" }); @@ -39,12 +40,13 @@ function createWrapper() { } describe("FileField 컴포넌트 테스트", () => { - let onChange = jest.fn(); - let uploadImageFn = jest.fn(); + let onChange: jest.Mock; + let uploadImageFn: jest.Mock; beforeEach(() => { jest.clearAllMocks(); - uploadImageFn; + onChange = jest.fn(); + uploadImageFn = jest.fn(); }); test("이미지 업로드 성공 시 onChange와 성공 토스트가 호출되어야 함", async () => { @@ -54,15 +56,19 @@ describe("FileField 컴포넌트 테스트", () => { wrapper: createWrapper(), }); - const input = document.querySelector(hiddenInputSelector) as HTMLInputElement; + const input = screen.getByLabelText(fieldLabelText, { + selector: hiddenFileInputSelector, + exact: false, + }); + const file = createFile(); const user = userEvent.setup(); - await user.upload(input, createFile()); + await user.upload(input, file); await waitFor(() => { expect(uploadImageFn).toHaveBeenCalled(); }); - expect(uploadImageFn.mock.calls[0][0]).toEqual(createFile()); + expect(uploadImageFn.mock.calls[0][0]).toEqual(file); await waitFor(() => { expect(onChange).toHaveBeenCalledWith(mockImgUrl, expect.any(Object)); @@ -80,10 +86,14 @@ describe("FileField 컴포넌트 테스트", () => { wrapper: createWrapper(), }); - const input = document.querySelector(hiddenInputSelector) as HTMLInputElement; + const input = screen.getByLabelText(fieldLabelText, { + selector: hiddenFileInputSelector, + exact: false, + }); + const file = createFile(); const user = userEvent.setup(); - await user.upload(input, createFile()); + await user.upload(input, file); await waitFor(() => { expect(mockHandleShowToast).toHaveBeenCalledWith({ @@ -99,7 +109,10 @@ describe("FileField 컴포넌트 테스트", () => { wrapper: createWrapper(), }); - const input = document.querySelector(hiddenInputSelector) as HTMLInputElement; + const input = screen.getByLabelText(fieldLabelText, { + selector: hiddenFileInputSelector, + exact: false, + }); expect(input).toHaveAttribute("name", name); }); });