diff --git a/components/layout/Header/index.test.tsx b/components/layout/Header/index.test.tsx
new file mode 100644
index 00000000..40516938
--- /dev/null
+++ b/components/layout/Header/index.test.tsx
@@ -0,0 +1,140 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import Header from ".";
+
+const push = jest.fn();
+const logout = jest.fn();
+const openLogin = jest.fn();
+
+jest.mock("next/image", () => {
+ return function MockImage(props: { alt: string }) {
+ return
;
+ };
+});
+
+jest.mock("next/navigation", () => ({
+ usePathname: jest.fn(),
+ useRouter: () => ({ push }),
+}));
+
+jest.mock("@/hooks/useUser", () => ({
+ useUser: jest.fn(),
+}));
+
+jest.mock("@/features/header/queries", () => ({
+ useGetFavoritesCount: jest.fn(),
+}));
+
+jest.mock("@/features/header/components/Notification", () => {
+ return function MockNotification() {
+ return ;
+ };
+});
+
+jest.mock("@/components/ui/Dropdowns/ActionDropdown", () => {
+ return function MockActionDropdown({
+ items,
+ }: {
+ items: { label: string; onClick: () => void; disabled?: boolean }[];
+ }) {
+ return (
+
+
+ {items.map((item) => (
+
+ ))}
+
+ );
+ };
+});
+
+jest.mock("@/store/modal.store", () => ({
+ useModalStore: () => ({ openLogin }),
+}));
+
+jest.mock("@/features/auth/mutations", () => ({
+ useLogout: () => ({ mutate: logout, isPending: false }),
+}));
+
+jest.mock("@/features/auth/components/LoginModal", () => ({
+ LoginModal: () => null,
+}));
+
+jest.mock("@/features/auth/components/SignUpModal", () => ({
+ SignUpModal: () => null,
+}));
+
+const { usePathname } = jest.requireMock("next/navigation");
+const { useUser } = jest.requireMock("@/hooks/useUser");
+const { useGetFavoritesCount } = jest.requireMock("@/features/header/queries");
+
+function renderHeader() {
+ const user = userEvent.setup();
+
+ render();
+
+ return {
+ user,
+ };
+}
+
+describe("Header", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ usePathname.mockReturnValue("/meetup/list");
+ useGetFavoritesCount.mockReturnValue({ data: undefined });
+ });
+
+ test("로그아웃 상태에서는 로그인 버튼이 보이고 프로필 메뉴와 알림 버튼은 보이지 않는다", async () => {
+ useUser.mockReturnValue({ user: null, isLoggedIn: false, isPending: false });
+
+ const { user } = renderHeader();
+
+ const loginButton = screen.getByRole("button", { name: "로그인" });
+ const profileMenuButton = screen.queryByRole("button", { name: "프로필 메뉴 열기" });
+ const notificationButton = screen.queryByRole("button", { name: "알림 버튼" });
+
+ expect(loginButton).toBeInTheDocument();
+ expect(profileMenuButton).not.toBeInTheDocument();
+ expect(notificationButton).not.toBeInTheDocument();
+
+ await user.click(loginButton);
+ expect(openLogin).toHaveBeenCalledTimes(1);
+ });
+
+ test("로그인 상태에서는 알림 버튼, 프로필 메뉴, 찜한 모임 개수가 보인다", async () => {
+ useUser.mockReturnValue({
+ user: {
+ id: 1,
+ name: "홍길동",
+ email: "test@example.com",
+ image: "https://example.com/me.jpg",
+ },
+ isLoggedIn: true,
+ isPending: false,
+ });
+ useGetFavoritesCount.mockReturnValue({ data: { count: 3 } });
+
+ const { user } = renderHeader();
+
+ const notificationButton = screen.getByRole("button", { name: "알림 버튼" });
+ const profileMenuButton = screen.getByRole("button", { name: "프로필 메뉴 열기" });
+ const favoritesCount = screen.getByText("3");
+ const loginButton = screen.queryByRole("button", { name: "로그인" });
+
+ expect(notificationButton).toBeInTheDocument();
+ expect(profileMenuButton).toBeInTheDocument();
+ expect(favoritesCount).toBeInTheDocument();
+ expect(loginButton).not.toBeInTheDocument();
+
+ const mypageButton = screen.getByRole("button", { name: "마이페이지" });
+ await user.click(mypageButton);
+ expect(push).toHaveBeenCalledWith("/mypage");
+
+ const logoutButton = screen.getByRole("button", { name: "로그아웃" });
+ await user.click(logoutButton);
+ expect(logout).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/features/mypage/AvailableReviewList/index.test.tsx b/features/mypage/AvailableReviewList/index.test.tsx
new file mode 100644
index 00000000..9957fe37
--- /dev/null
+++ b/features/mypage/AvailableReviewList/index.test.tsx
@@ -0,0 +1,178 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import AvailableReviewListWrapper from ".";
+import { mockMeMeetingApiRes } from "../mockData";
+
+const handleWishToggle = jest.fn();
+const postMeetingReview = jest.fn();
+const reviewFormValues = { score: 5, comment: "좋은 모임이었어요" };
+
+jest.mock("@/components/ui/Empty", () => {
+ return function MockEmpty({ children }: { children: React.ReactNode }) {
+ return {children}
;
+ };
+});
+
+jest.mock("@/components/ui/Loading", () => {
+ return function MockLoading() {
+ return 목록 로딩 중
;
+ };
+});
+
+jest.mock("../components/DetailCard", () => {
+ return function MockDetailCard({
+ item,
+ actions = [],
+ wishAction,
+ }: {
+ item: { id: number; name: string; isFavorited: boolean };
+ actions?: { label: string; handleCardButtonClick: () => void }[];
+ wishAction?: { handleWishClick: () => void };
+ }) {
+ return (
+
+ {item.name}
+ {actions.map((action) => (
+
+ ))}
+
+
+ );
+ };
+});
+
+jest.mock("@/features/shared/components/ReviewModal", () => ({
+ __esModule: true,
+ default: function MockReviewModal({
+ isOpen,
+ handleFormSubmit,
+ }: {
+ isOpen: boolean;
+ handleFormSubmit: (values: typeof reviewFormValues) => void;
+ }) {
+ return isOpen ? (
+
+
+
+ ) : null;
+ },
+}));
+
+jest.mock("@/hooks/useMeetingFavorite", () => ({
+ __esModule: true,
+ default: () => ({ handleWishToggle }),
+}));
+
+jest.mock("../queries", () => ({
+ useMyMeetupInfinite: jest.fn(),
+}));
+
+jest.mock("../mutations", () => ({
+ usePostMeetingsReviews: () => ({ mutate: postMeetingReview, isPending: false }),
+}));
+
+const { useMyMeetupInfinite } = jest.requireMock("../queries");
+
+function setAvailableReviews(data = [mockMeMeetingApiRes], isFetchingNextPage = false) {
+ useMyMeetupInfinite.mockReturnValue({
+ data: { pages: [{ data }] },
+ hasNextPage: false,
+ isFetchingNextPage,
+ });
+}
+
+function renderAvailableReviewList() {
+ const user = userEvent.setup();
+
+ render();
+
+ return {
+ user,
+ };
+}
+
+describe("AvailableReviewList", () => {
+ const consoleError = jest.spyOn(console, "error").mockImplementation(() => {});
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ setAvailableReviews();
+ });
+
+ afterAll(() => {
+ consoleError.mockRestore();
+ });
+
+ test("작성 가능한 리뷰 모임을 렌더링한다", () => {
+ renderAvailableReviewList();
+
+ const name = screen.getByText("코딩 스터디");
+ const reviewButton = screen.getByRole("button", { name: "리뷰 작성하기" });
+
+ expect(name).toBeInTheDocument();
+ expect(reviewButton).toBeInTheDocument();
+ });
+
+ test("데이터가 없으면 Empty UI를 렌더링한다", () => {
+ setAvailableReviews([]);
+
+ renderAvailableReviewList();
+
+ const empty = screen.getByText("작성 가능한 리뷰가 없어요");
+ expect(empty).toBeInTheDocument();
+ });
+
+ test("다음 페이지를 불러오는 중이면 Loading UI를 렌더링한다", () => {
+ setAvailableReviews([mockMeMeetingApiRes], true);
+
+ renderAvailableReviewList();
+
+ const loading = screen.getByText("목록 로딩 중");
+ expect(loading).toBeInTheDocument();
+ });
+
+ test("찜 버튼을 클릭하면 찜 토글 핸들러를 호출한다", async () => {
+ setAvailableReviews([{ ...mockMeMeetingApiRes, isFavorited: false }]);
+
+ const { user } = renderAvailableReviewList();
+ const wishButton = screen.getByRole("button", { name: "찜 토글" });
+ await user.click(wishButton);
+
+ expect(handleWishToggle).toHaveBeenCalledWith(mockMeMeetingApiRes.id, false);
+ });
+
+ test("리뷰 작성 모달에서 제출하면 리뷰 작성 mutation을 호출한다", async () => {
+ const { user } = renderAvailableReviewList();
+
+ const reviewButton = screen.getByRole("button", { name: "리뷰 작성하기" });
+ await user.click(reviewButton);
+
+ const submitButton = screen.getByRole("button", { name: "리뷰 제출" });
+ await user.click(submitButton);
+
+ expect(postMeetingReview).toHaveBeenCalledWith(
+ { meetingId: 1000, reviewFormValues },
+ expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }),
+ );
+ });
+
+ test("데이터 조회 중 에러가 발생하면 ErrorBoundary fallback UI를 렌더링한다", () => {
+ useMyMeetupInfinite.mockImplementation(() => {
+ throw new Error("query error");
+ });
+
+ renderAvailableReviewList();
+
+ const errorMessage = screen.getByText("작성 가능 한 리뷰를 불러오지 못했습니다.");
+ const retryButton = screen.getByRole("button", { name: "다시 시도" });
+
+ expect(errorMessage).toBeInTheDocument();
+ expect(retryButton).toBeInTheDocument();
+ });
+});
diff --git a/features/mypage/CreatedMeetingList/index.test.tsx b/features/mypage/CreatedMeetingList/index.test.tsx
new file mode 100644
index 00000000..420314bf
--- /dev/null
+++ b/features/mypage/CreatedMeetingList/index.test.tsx
@@ -0,0 +1,224 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import CreatedMeetingListWrapper from ".";
+import { mockMeMeetingApiRes } from "../mockData";
+
+const patchMeetingsStatus = jest.fn();
+const postMeetingReview = jest.fn();
+const handleWishToggle = jest.fn();
+const reviewFormValues = { score: 5, comment: "좋은 모임이었어요" };
+
+jest.mock("@/components/ui/Empty", () => {
+ return function MockEmpty({ children }: { children: React.ReactNode }) {
+ return {children}
;
+ };
+});
+
+jest.mock("@/components/ui/Loading", () => {
+ return function MockLoading() {
+ return 목록 로딩 중
;
+ };
+});
+
+jest.mock("../components/DetailCard", () => {
+ return function MockDetailCard({
+ item,
+ actions = [],
+ wishAction,
+ }: {
+ item: { name: string };
+ actions?: { label: string; handleCardButtonClick: () => void }[];
+ wishAction?: { handleWishClick: () => void };
+ }) {
+ return (
+
+ {item.name}
+ {actions.map((action) => (
+
+ ))}
+
+
+ );
+ };
+});
+
+jest.mock("@/components/ui/Modals/AlertModal", () => {
+ return function MockAlert({
+ isOpen,
+ children,
+ handleConfirmButton,
+ }: {
+ isOpen: boolean;
+ children: React.ReactNode;
+ handleConfirmButton: () => void;
+ }) {
+ return isOpen ? (
+
+ {children}
+
+
+ ) : null;
+ };
+});
+
+jest.mock("@/features/shared/components/ReviewModal", () => ({
+ __esModule: true,
+ default: function MockReviewModal({
+ isOpen,
+ handleFormSubmit,
+ }: {
+ isOpen: boolean;
+ handleFormSubmit: (values: typeof reviewFormValues) => void;
+ }) {
+ return isOpen ? (
+
+
+
+ ) : null;
+ },
+}));
+
+jest.mock("@/hooks/useMeetingFavorite", () => ({
+ __esModule: true,
+ default: () => ({ handleWishToggle }),
+}));
+
+jest.mock("../queries", () => ({
+ useMyCreatedInfinite: jest.fn(),
+}));
+
+jest.mock("../mutations", () => ({
+ usePatchMeetingsStatus: () => ({ mutate: patchMeetingsStatus, isPending: false }),
+ useDeleteMeetings: () => ({ mutate: jest.fn(), isPending: false }),
+ usePostMeetingsReviews: () => ({ mutate: postMeetingReview, isPending: false }),
+}));
+
+const { useMyCreatedInfinite } = jest.requireMock("../queries");
+
+function setCreatedMeetings(data = [mockMeMeetingApiRes], isFetchingNextPage = false) {
+ useMyCreatedInfinite.mockReturnValue({
+ data: { pages: [{ data }] },
+ hasNextPage: false,
+ isFetchingNextPage,
+ });
+}
+
+function renderCreatedMeetingList() {
+ const user = userEvent.setup();
+
+ render();
+
+ return {
+ user,
+ };
+}
+
+describe("CreatedMeetingList", () => {
+ const consoleError = jest.spyOn(console, "error").mockImplementation(() => {});
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ setCreatedMeetings([{ ...mockMeMeetingApiRes, isCompleted: false, isReviewed: false }]);
+ });
+
+ afterAll(() => {
+ consoleError.mockRestore();
+ });
+
+ test("내가 만든 모임을 렌더링한다", () => {
+ renderCreatedMeetingList();
+
+ const name = screen.getByText("코딩 스터디");
+ const confirmButton = screen.getByRole("button", { name: "모임 확정하기" });
+
+ expect(name).toBeInTheDocument();
+ expect(confirmButton).toBeInTheDocument();
+ });
+
+ test("데이터가 없으면 Empty UI를 렌더링한다", () => {
+ setCreatedMeetings([]);
+
+ renderCreatedMeetingList();
+
+ const empty = screen.getByText("아직 내가 만든 모임이 없어요");
+ expect(empty).toBeInTheDocument();
+ });
+
+ test("다음 페이지를 불러오는 중이면 Loading UI를 렌더링한다", () => {
+ setCreatedMeetings([{ ...mockMeMeetingApiRes, isCompleted: false, isReviewed: false }], true);
+
+ renderCreatedMeetingList();
+
+ const loading = screen.getByText("목록 로딩 중");
+ expect(loading).toBeInTheDocument();
+ });
+
+ test("모임 확정을 확인하면 상태 변경 mutation을 호출한다", async () => {
+ const { user } = renderCreatedMeetingList();
+
+ const confirmButton = screen.getByRole("button", { name: "모임 확정하기" });
+ await user.click(confirmButton);
+
+ const alertMessage = screen.getByText("모임을 확정하시겠습니까?");
+ const alertConfirmButton = screen.getByRole("button", { name: "확인" });
+
+ expect(alertMessage).toBeInTheDocument();
+
+ await user.click(alertConfirmButton);
+
+ expect(patchMeetingsStatus).toHaveBeenCalledWith(
+ { meetingId: 1000, status: "CONFIRMED" },
+ expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }),
+ );
+ });
+
+ test("완료된 미작성 모임에서 리뷰를 제출하면 리뷰 작성 mutation을 호출한다", async () => {
+ setCreatedMeetings([{ ...mockMeMeetingApiRes, isCompleted: true, isReviewed: false }]);
+
+ const { user } = renderCreatedMeetingList();
+
+ const reviewButton = screen.getByRole("button", { name: "리뷰 작성하기" });
+ await user.click(reviewButton);
+
+ const submitButton = screen.getByRole("button", { name: "리뷰 제출" });
+ await user.click(submitButton);
+
+ expect(postMeetingReview).toHaveBeenCalledWith(
+ { meetingId: 1000, reviewFormValues },
+ expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }),
+ );
+ });
+
+ test("찜 버튼을 클릭하면 찜 토글 핸들러를 호출한다", async () => {
+ setCreatedMeetings([{ ...mockMeMeetingApiRes, isFavorited: false }]);
+
+ const { user } = renderCreatedMeetingList();
+
+ const wishButton = screen.getByRole("button", { name: "찜 토글" });
+ await user.click(wishButton);
+
+ expect(handleWishToggle).toHaveBeenCalledWith(mockMeMeetingApiRes.id, false);
+ });
+
+ test("데이터 조회 중 에러가 발생하면 ErrorBoundary fallback UI를 렌더링한다", () => {
+ useMyCreatedInfinite.mockImplementation(() => {
+ throw new Error("query error");
+ });
+
+ renderCreatedMeetingList();
+
+ const errorMessage = screen.getByText("내가 만든 모임을 불러오지 못했습니다.");
+ const retryButton = screen.getByRole("button", { name: "다시 시도" });
+
+ expect(errorMessage).toBeInTheDocument();
+ expect(retryButton).toBeInTheDocument();
+ });
+});
diff --git a/features/mypage/JoinedMeetingList/index.test.tsx b/features/mypage/JoinedMeetingList/index.test.tsx
new file mode 100644
index 00000000..d81939bf
--- /dev/null
+++ b/features/mypage/JoinedMeetingList/index.test.tsx
@@ -0,0 +1,192 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import JoinedMeetingListWrapper from ".";
+import { mockMeMeetingApiRes } from "../mockData";
+
+const handleWishToggle = jest.fn();
+const deleteMeetingsJoin = jest.fn();
+
+jest.mock("@/components/ui/Empty", () => {
+ return function MockEmpty({ children }: { children: React.ReactNode }) {
+ return {children}
;
+ };
+});
+
+jest.mock("@/components/ui/Loading", () => {
+ return function MockLoading() {
+ return 목록 로딩 중
;
+ };
+});
+
+jest.mock("../components/DetailCard", () => {
+ return function MockDetailCard({
+ item,
+ actions = [],
+ wishAction,
+ }: {
+ item: { name: string };
+ actions?: { label: string; handleCardButtonClick: () => void }[];
+ wishAction?: { handleWishClick: () => void };
+ }) {
+ return (
+
+ {item.name}
+ {actions.map((action) => (
+
+ ))}
+
+
+ );
+ };
+});
+
+jest.mock("@/components/ui/Modals/AlertModal", () => {
+ return function MockAlert({
+ isOpen,
+ children,
+ handleConfirmButton,
+ }: {
+ isOpen: boolean;
+ children: React.ReactNode;
+ handleConfirmButton: () => void;
+ }) {
+ return isOpen ? (
+
+ {children}
+
+
+ ) : null;
+ };
+});
+
+jest.mock("@/features/shared/components/ReviewModal", () => ({
+ __esModule: true,
+ default: function MockReviewModal({ isOpen }: { isOpen: boolean }) {
+ return isOpen ? 리뷰 모달
: null;
+ },
+}));
+
+jest.mock("@/hooks/useMeetingFavorite", () => ({
+ __esModule: true,
+ default: () => ({ handleWishToggle }),
+}));
+
+jest.mock("../queries", () => ({
+ useMyJoinedInfinite: jest.fn(),
+}));
+
+jest.mock("../mutations", () => ({
+ usePatchMeetingsStatus: () => ({ mutate: jest.fn(), isPending: false }),
+ useDeleteMeetings: () => ({ mutate: jest.fn(), isPending: false }),
+ useDeleteMeetingsJoin: () => ({ mutate: deleteMeetingsJoin, isPending: false }),
+ usePostMeetingsReviews: () => ({ mutate: jest.fn(), isPending: false }),
+}));
+
+const { useMyJoinedInfinite } = jest.requireMock("../queries");
+
+function setJoinedMeetings(data = [mockMeMeetingApiRes], isFetchingNextPage = false) {
+ useMyJoinedInfinite.mockReturnValue({
+ data: { pages: [{ data }] },
+ hasNextPage: false,
+ isFetchingNextPage,
+ });
+}
+
+function renderJoinedMeetingList() {
+ const user = userEvent.setup();
+
+ render();
+
+ return {
+ user,
+ };
+}
+
+describe("JoinedMeetingList", () => {
+ const consoleError = jest.spyOn(console, "error").mockImplementation(() => {});
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ setJoinedMeetings([{ ...mockMeMeetingApiRes, role: "participant", isCompleted: false }]);
+ });
+
+ afterAll(() => {
+ consoleError.mockRestore();
+ });
+
+ test("내가 참여한 모임을 렌더링한다", () => {
+ renderJoinedMeetingList();
+
+ const name = screen.getByText("코딩 스터디");
+ const cancelButton = screen.getByRole("button", { name: "참여 취소하기" });
+
+ expect(name).toBeInTheDocument();
+ expect(cancelButton).toBeInTheDocument();
+ });
+
+ test("데이터가 없으면 Empty UI를 렌더링한다", () => {
+ setJoinedMeetings([]);
+
+ renderJoinedMeetingList();
+
+ const empty = screen.getByText("아직 참여한 모임이 없어요");
+ expect(empty).toBeInTheDocument();
+ });
+
+ test("다음 페이지를 불러오는 중이면 Loading UI를 렌더링한다", () => {
+ setJoinedMeetings([{ ...mockMeMeetingApiRes, role: "participant", isCompleted: false }], true);
+
+ renderJoinedMeetingList();
+
+ const loading = screen.getByText("목록 로딩 중");
+ expect(loading).toBeInTheDocument();
+ });
+
+ test("참여 취소를 확인하면 참여 취소 mutation을 호출한다", async () => {
+ const { user } = renderJoinedMeetingList();
+
+ const cancelButton = screen.getByRole("button", { name: "참여 취소하기" });
+ await user.click(cancelButton);
+
+ const alertMessage = screen.getByText("모임 예약을 취소하시겠습니까?");
+ const alertConfirmButton = screen.getByRole("button", { name: "확인" });
+
+ expect(alertMessage).toBeInTheDocument();
+
+ await user.click(alertConfirmButton);
+
+ expect(deleteMeetingsJoin).toHaveBeenCalledWith(
+ { meetingId: 1000 },
+ expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }),
+ );
+ });
+
+ test("찜 버튼을 클릭하면 찜 토글 핸들러를 호출한다", async () => {
+ const { user } = renderJoinedMeetingList();
+ setJoinedMeetings([{ ...mockMeMeetingApiRes, isFavorited: false }]);
+ const wishButton = screen.getByRole("button", { name: "찜 토글" });
+ await user.click(wishButton);
+
+ expect(handleWishToggle).toHaveBeenCalledWith(mockMeMeetingApiRes.id, false);
+ });
+
+ test("데이터 조회 중 에러가 발생하면 ErrorBoundary fallback UI를 렌더링한다", () => {
+ useMyJoinedInfinite.mockImplementation(() => {
+ throw new Error("query error");
+ });
+
+ renderJoinedMeetingList();
+
+ const errorMessage = screen.getByText("나의 모임을 불러오지 못했습니다.");
+ const retryButton = screen.getByRole("button", { name: "다시 시도" });
+
+ expect(errorMessage).toBeInTheDocument();
+ expect(retryButton).toBeInTheDocument();
+ });
+});
diff --git a/features/mypage/MyProfile/index.test.tsx b/features/mypage/MyProfile/index.test.tsx
new file mode 100644
index 00000000..3a27862c
--- /dev/null
+++ b/features/mypage/MyProfile/index.test.tsx
@@ -0,0 +1,85 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import MyProfileContainer from ".";
+import { mockUserProfile } from "../mockData";
+
+jest.mock("react-loading-skeleton", () => {
+ return function MockSkeleton() {
+ return ;
+ };
+});
+
+jest.mock("@/components/ui/Avatar", () => {
+ return function MockAvatar({ src }: { src: string | null }) {
+ return
;
+ };
+});
+
+jest.mock("../components/ProfileModal", () => {
+ return function MockProfileModal({ isOpen, user }: { isOpen: boolean; user: { name: string } }) {
+ return isOpen ? {user.name} 프로필 수정 모달
: null;
+ };
+});
+
+jest.mock("@/hooks/useUser", () => ({
+ useUser: jest.fn(),
+}));
+
+const { useUser } = jest.requireMock("@/hooks/useUser");
+
+function renderMyProfile() {
+ const user = userEvent.setup();
+ const view = render();
+
+ return {
+ user,
+ ...view,
+ };
+}
+
+describe("MyProfile", () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test("유저 정보가 로딩 중이면 프로필 스켈레톤을 렌더링한다", () => {
+ useUser.mockReturnValue({ user: null, isPending: true });
+
+ renderMyProfile();
+
+ const skeletons = screen.getAllByTestId("profile-skeleton");
+ expect(skeletons).toHaveLength(3);
+ });
+
+ test("유저 정보가 있으면 이름과 이메일을 렌더링한다", () => {
+ useUser.mockReturnValue({ user: mockUserProfile, isPending: false });
+
+ renderMyProfile();
+
+ const name = screen.getByText("홍길동");
+ const email = screen.getByText("test@example.com");
+
+ expect(name).toBeInTheDocument();
+ expect(email).toBeInTheDocument();
+ });
+
+ test("프로필 수정 버튼을 클릭하면 프로필 모달을 연다", async () => {
+ useUser.mockReturnValue({ user: mockUserProfile, isPending: false });
+
+ const { user } = renderMyProfile();
+
+ const editButton = screen.getByRole("button", { name: "프로필 수정" });
+ await user.click(editButton);
+
+ const modal = screen.getByRole("dialog");
+ expect(modal).toHaveTextContent("홍길동 프로필 수정 모달");
+ });
+
+ test("유저 정보가 없으면 아무것도 렌더링하지 않는다", () => {
+ useUser.mockReturnValue({ user: null, isPending: false });
+
+ const { container } = renderMyProfile();
+
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/features/mypage/MyTab/TabWrapper/index.test.tsx b/features/mypage/MyTab/TabWrapper/index.test.tsx
new file mode 100644
index 00000000..488d5843
--- /dev/null
+++ b/features/mypage/MyTab/TabWrapper/index.test.tsx
@@ -0,0 +1,99 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import TabWrapper from ".";
+
+const querySet = jest.fn();
+let tabQuery: string | null = null;
+
+jest.mock("@/hooks/useQueryParams", () => ({
+ useQueryParams: () => ({
+ get: () => tabQuery,
+ set: querySet,
+ }),
+}));
+
+jest.mock("../../JoinedMeetingList", () => {
+ return function MockJoinedMeetingList() {
+ return 참여 모임 콘텐츠
;
+ };
+});
+
+jest.mock("../../CreatedMeetingList", () => {
+ return function MockCreatedMeetingList() {
+ return 개설 모임 콘텐츠
;
+ };
+});
+
+jest.mock("../../AvailableReviewList", () => {
+ return function MockAvailableReviewList() {
+ return 리뷰 작성 콘텐츠
;
+ };
+});
+
+jest.mock("../../WrittenReviewList", () => {
+ return function MockWrittenReviewList() {
+ return 리뷰 목록 콘텐츠
;
+ };
+});
+
+function renderTabWrapper() {
+ const user = userEvent.setup();
+
+ render();
+
+ return {
+ user,
+ };
+}
+
+describe("MyTab", () => {
+ beforeEach(() => {
+ tabQuery = null;
+ querySet.mockReset();
+ querySet.mockImplementation(({ tab }: { tab: string | null }) => {
+ tabQuery = tab;
+ });
+ });
+
+ test("기본 탭으로 참여 모임 콘텐츠를 렌더링한다", () => {
+ renderTabWrapper();
+
+ const joinedTab = screen.getByRole("tab", { name: "참여 모임" });
+ const joinedContent = screen.getByText("참여 모임 콘텐츠");
+
+ expect(joinedTab).toBeInTheDocument();
+ expect(joinedContent).toBeInTheDocument();
+ });
+
+ test("탭을 클릭하면 선택한 콘텐츠로 변경하고 query 값을 갱신한다", async () => {
+ const { user } = renderTabWrapper();
+
+ const createdTab = screen.getByRole("tab", { name: "개설 모임" });
+ await user.click(createdTab);
+
+ await waitFor(() => {
+ const createdContent = screen.getByText("개설 모임 콘텐츠");
+ expect(createdContent).toBeInTheDocument();
+ });
+ expect(querySet).toHaveBeenCalledWith({ tab: "CreatedMeetingList" });
+ });
+
+ test("URL query의 tab 값이 있으면 해당 탭 콘텐츠를 기본으로 렌더링한다", () => {
+ tabQuery = "WrittenReviewList";
+
+ renderTabWrapper();
+
+ const writtenReviewContent = screen.getByText("리뷰 목록 콘텐츠");
+ expect(writtenReviewContent).toBeInTheDocument();
+ });
+
+ test("잘못된 tab query 값은 제거한다", async () => {
+ tabQuery = "UnknownTab";
+
+ renderTabWrapper();
+
+ await waitFor(() => {
+ expect(querySet).toHaveBeenCalledWith({ tab: null });
+ });
+ });
+});
diff --git a/features/mypage/WrittenReviewList/index.test.tsx b/features/mypage/WrittenReviewList/index.test.tsx
new file mode 100644
index 00000000..3feafe78
--- /dev/null
+++ b/features/mypage/WrittenReviewList/index.test.tsx
@@ -0,0 +1,237 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import WrittenReviewListWrapper from ".";
+import { mockUserProfile } from "../mockData";
+import { ReviewCardItem } from "../types";
+
+const patchReviews = jest.fn();
+const deleteReviews = jest.fn();
+const reviewFormValues = { score: 4, comment: "수정한 리뷰입니다" };
+
+const mockReviewItem: ReviewCardItem = {
+ id: 123,
+ 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",
+};
+
+jest.mock("@/components/ui/Empty", () => {
+ return function MockEmpty({ children }: { children: React.ReactNode }) {
+ return {children}
;
+ };
+});
+
+jest.mock("@/components/ui/Loading", () => {
+ return function MockLoading() {
+ return 목록 로딩 중
;
+ };
+});
+
+jest.mock("../components/ReviewCard", () => {
+ return function MockReviewCard({
+ item,
+ handleEdit,
+ handleDelete,
+ }: {
+ item: { meetingName: string; comment: string };
+ handleEdit: () => void;
+ handleDelete: () => void;
+ }) {
+ return (
+
+ {item.meetingName}
+ {item.comment}
+
+
+
+ );
+ };
+});
+
+jest.mock("@/components/ui/Modals/AlertModal", () => {
+ return function MockAlert({
+ isOpen,
+ children,
+ handleConfirmButton,
+ }: {
+ isOpen: boolean;
+ children: React.ReactNode;
+ handleConfirmButton: () => void;
+ }) {
+ return isOpen ? (
+
+ {children}
+
+
+ ) : null;
+ };
+});
+
+jest.mock("@/features/shared/components/ReviewModal", () => ({
+ __esModule: true,
+ default: function MockReviewModal({
+ isOpen,
+ initialValue,
+ handleFormSubmit,
+ }: {
+ isOpen: boolean;
+ initialValue?: { score?: number; comment?: string };
+ handleFormSubmit: (values: typeof reviewFormValues) => void;
+ }) {
+ return isOpen ? (
+
+ 초기 리뷰: {initialValue?.comment}
+
+
+ ) : null;
+ },
+}));
+
+jest.mock("@/hooks/useUser", () => ({
+ useUser: jest.fn(),
+}));
+
+jest.mock("../queries", () => ({
+ useMyReviewInfinite: jest.fn(),
+}));
+
+jest.mock("../mutations", () => ({
+ usePatchReviews: () => ({ mutate: patchReviews, isPending: false }),
+ useDeleteReviews: () => ({ mutate: deleteReviews, isPending: false }),
+}));
+
+const { useUser } = jest.requireMock("@/hooks/useUser");
+const { useMyReviewInfinite } = jest.requireMock("../queries");
+
+function setWrittenReviews(data = [mockReviewItem], isFetchingNextPage = false) {
+ useMyReviewInfinite.mockReturnValue({
+ data: { pages: [{ data }] },
+ hasNextPage: false,
+ isFetchingNextPage,
+ });
+}
+
+function renderWrittenReviewList() {
+ const user = userEvent.setup();
+ const view = render();
+
+ return {
+ user,
+ ...view,
+ };
+}
+
+describe("WrittenReviewList", () => {
+ const consoleError = jest.spyOn(console, "error").mockImplementation(() => {});
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ useUser.mockReturnValue({ user: mockUserProfile });
+ setWrittenReviews();
+ });
+
+ afterAll(() => {
+ consoleError.mockRestore();
+ });
+
+ test("작성한 리뷰를 렌더링한다", () => {
+ renderWrittenReviewList();
+
+ const meetingName = screen.getByText("코딩 스터디");
+ const comment = screen.getByText("함께 공부해서 좋았어요");
+
+ expect(meetingName).toBeInTheDocument();
+ expect(comment).toBeInTheDocument();
+ });
+
+ test("유저 정보가 없으면 아무것도 렌더링하지 않는다", () => {
+ useUser.mockReturnValue({ user: null });
+
+ const { container } = renderWrittenReviewList();
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ test("데이터가 없으면 Empty UI를 렌더링한다", () => {
+ setWrittenReviews([]);
+
+ renderWrittenReviewList();
+
+ const empty = screen.getByText("아직 작성한 리뷰가 없어요");
+ expect(empty).toBeInTheDocument();
+ });
+
+ test("다음 페이지를 불러오는 중이면 Loading UI를 렌더링한다", () => {
+ setWrittenReviews([mockReviewItem], true);
+
+ renderWrittenReviewList();
+
+ const loading = screen.getByText("목록 로딩 중");
+ expect(loading).toBeInTheDocument();
+ });
+
+ test("리뷰 수정 모달에서 제출하면 리뷰 수정 mutation을 호출한다", async () => {
+ const { user } = renderWrittenReviewList();
+
+ const editButton = screen.getByRole("button", { name: "수정하기" });
+ await user.click(editButton);
+
+ const initialReview = screen.getByText("초기 리뷰: 함께 공부해서 좋았어요");
+ const submitButton = screen.getByRole("button", { name: "리뷰 수정 제출" });
+
+ expect(initialReview).toBeInTheDocument();
+
+ await user.click(submitButton);
+
+ expect(patchReviews).toHaveBeenCalledWith(
+ { reviewId: 123, reviewFormValues },
+ expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }),
+ );
+ });
+
+ test("리뷰 삭제를 확인하면 리뷰 삭제 mutation을 호출한다", async () => {
+ const { user } = renderWrittenReviewList();
+
+ const deleteButton = screen.getByRole("button", { name: "삭제하기" });
+ await user.click(deleteButton);
+
+ const alertMessage = screen.getByText("리뷰를 삭제하시겠습니까?");
+ const alertConfirmButton = screen.getByRole("button", { name: "확인" });
+
+ expect(alertMessage).toBeInTheDocument();
+
+ await user.click(alertConfirmButton);
+
+ expect(deleteReviews).toHaveBeenCalledWith(
+ { reviewId: 123 },
+ expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }),
+ );
+ });
+
+ test("데이터 조회 중 에러가 발생하면 ErrorBoundary fallback UI를 렌더링한다", () => {
+ useMyReviewInfinite.mockImplementation(() => {
+ throw new Error("query error");
+ });
+
+ renderWrittenReviewList();
+
+ const errorMessage = screen.getByText("작성한 리뷰를 불러오지 못했습니다.");
+ const retryButton = screen.getByRole("button", { name: "다시 시도" });
+
+ expect(errorMessage).toBeInTheDocument();
+ expect(retryButton).toBeInTheDocument();
+ });
+});