Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions components/common/ErrorPage/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { render, screen } from "@testing-library/react";
import ErrorPage from ".";
import userEvent from "@testing-library/user-event";

describe("ErrorPage", () => {
test("prefix, title, description을 전달하면 해당 문구와 다시시도 버튼이 렌더링된다 ", () => {
render(
<ErrorPage
onRetryAction={() => {}}
prefix="목록을 "
title="불러오지 못했습니다."
description="커스텀 설명입니다."
/>,
);
const title = screen.getByText("목록을 불러오지 못했습니다.");
const description = screen.getByText("커스텀 설명입니다.");
const button = screen.getByRole("button", { name: "다시 시도" });

expect(title).toBeInTheDocument();
expect(description).toBeInTheDocument();
expect(button).toBeInTheDocument();
});

test("다시 시도 버튼 클릭 시 onRetryAction이 호출된다", async () => {
const user = userEvent.setup();
const onRetryAction = jest.fn();
render(<ErrorPage onRetryAction={onRetryAction} />);

const button = screen.getByRole("button", { name: "다시 시도" });
await user.click(button);

expect(onRetryAction).toHaveBeenCalledTimes(1);
});
});
66 changes: 66 additions & 0 deletions components/common/QueryErrorBoundary/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { render, screen } from "@testing-library/react";
import QueryErrorBoundary from ".";
import userEvent from "@testing-library/user-event";

function ThrowError(): never {
throw new Error("테스트 에러");
}
describe("QueryErrorBoundary", () => {
// 테스트 로그 쌓임 방지
const consoleError = jest.spyOn(console, "error").mockImplementation(() => {});

// 테스트 끝날때마다 호출기록 초기화ㅏ
afterEach(() => {
consoleError.mockClear();
});

// 테스트 종료시 원래 콘솔로 돌리기
afterAll(() => {
consoleError.mockRestore();
});

test("자식 컴포넌트에서 에러가 발생하면 fallback UI가 렌더되는지 확인", () => {
render(
<QueryErrorBoundary prefix="목록을 ">
<ThrowError />
</QueryErrorBoundary>,
);

const title = screen.getByText("목록을 불러오지 못했습니다.");
const description = screen.getByText("잠시 후 다시 시도해주세요.");
const button = screen.getByRole("button", { name: "다시 시도" });

expect(title).toBeInTheDocument();
expect(description).toBeInTheDocument();
expect(button).toBeInTheDocument();
});
test("다시 시도 버튼 클릭시 reset 후 자식컴포넌트를 리렌더링하는지 확인 ", async () => {
const user = userEvent.setup();

let isError = true;

function TestComponent() {
if (isError) {
throw new Error("초기 에러");
}
return <div>복구된 콘텐츠</div>;
}

render(
<QueryErrorBoundary prefix="목록을 ">
<TestComponent />
</QueryErrorBoundary>,
);

const title = screen.getByText("목록을 불러오지 못했습니다.");
expect(title).toBeInTheDocument();

isError = false;

const button = screen.getByRole("button", { name: "다시 시도" });
await user.click(button);

const content = await screen.findByText("복구된 콘텐츠");
expect(content).toBeInTheDocument();
});
});
12 changes: 6 additions & 6 deletions components/ui/Modals/AlertModal/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jest.mock("..", () => ({
}));

describe("AlertModal", () => {
describe("isOpen 상태에 따라 모달이 열리고 닫히는지 확인", () => {
describe("모달이 열리고 닫히는지 확인한다", () => {
test("isOpen이 true면 모달이 열리고 메세지가 보인다", () => {
render(
<Alert isOpen={true} onClose={() => {}} handleConfirmButton={() => {}}>
Expand All @@ -32,7 +32,7 @@ describe("AlertModal", () => {

expect(screen.getByText("Alert 메세지")).toBeInTheDocument();
});
test("isOpen이 false면 모달이 렌더링되지 않는다.", () => {
test("isOpen이 false면 메세지가 보이지 않는다.", () => {
render(
<Alert isOpen={false} onClose={() => {}} handleConfirmButton={() => {}}>
Alert 메세지
Expand All @@ -43,8 +43,8 @@ describe("AlertModal", () => {
});
});

describe("onClose 확인", () => {
test("취소 버튼 클릭 시 onClose가 호출되는지 확인", async () => {
describe("취소 버튼으로 모달을 닫을 수 있는지 확인한다", () => {
test("취소 버튼 클릭 시 onClose가 호출되며 모달이 닫긴다.", async () => {
const handleClose = jest.fn();

render(
Expand All @@ -62,7 +62,7 @@ describe("AlertModal", () => {
});
});

describe("handleConfirmButton 확인", () => {
describe("확인 버튼으로 동작을 실행할 수 있는지 확인한다", () => {
test("확인 버튼 클릭 시 handleConfirm이 호출되는지 확인", async () => {
const handleConfirm = jest.fn();
render(
Expand All @@ -79,7 +79,7 @@ describe("AlertModal", () => {
});
});

describe("confirmLabel 확인", () => {
describe("버튼 문구가 올바르게 보이는지 확인한다", () => {
test("confirmLabel='삭제'면 버튼에 삭제가 보이는지 확인", async () => {
render(
<Alert isOpen={true} confirmLabel="삭제" onClose={() => {}} handleConfirmButton={() => {}}>
Expand Down
48 changes: 48 additions & 0 deletions features/header/components/Notification/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { render, screen } from "@testing-library/react";

import Notification from ".";

jest.mock("@/components/ui/icons", () => ({
IcBellOutline: () => <span>읽은 알림 아이콘</span>,
IcBellUnreadOutline: () => <span>읽지 않은 알림 아이콘</span>,
}));

jest.mock("@/features/header/queries", () => ({
useGetNotificationsCount: jest.fn(),
}));

jest.mock("@/features/header/components/NotificationPanel", () => {
return function MockNotificationPanel() {
return <div>알림 패널</div>;
};
});

const { useGetNotificationsCount } = jest.requireMock("@/features/header/queries");

describe("상단 알림 아이콘 컴포넌트 ", () => {
afterEach(() => {
jest.clearAllMocks();
});

describe("알림 상태를 확인한다", () => {
test("읽지 않은 알림이 없으면 기본 알림 아이콘이 보인다", () => {
useGetNotificationsCount.mockReturnValue({
data: { count: 0 },
});
render(<Notification />);

const icon = screen.getByText("읽은 알림 아이콘");
expect(icon).toBeInTheDocument();
});

test("읽지 않은 알림이 있으면 읽지 않은 알림 아이콘이 보인다", () => {
useGetNotificationsCount.mockReturnValue({
data: { count: 3 },
});
render(<Notification />);

const icon = screen.getByText("읽지 않은 알림 아이콘");
expect(icon).toBeInTheDocument();
});
});
});
91 changes: 91 additions & 0 deletions features/header/components/NotificationCard/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { render, screen } from "@testing-library/react";
import NotificationCard from ".";
import userEvent from "@testing-library/user-event";

const mockItem = {
id: 1,
type: "COMMENT",
message: "새 댓글이 달렸습니다",
image: "",
isRead: false,
createdAt: "2026-05-01T00:00:00.000Z",
postId: 10,
};

jest.mock("@/components/ui/Thumbnail", () => {
return function MockThumbnail() {
return <div data-testid="thumbnail" />;
};
});

jest.mock("@/components/ui/RelativeTime", () => {
return function MockRelativeTime({ date }: { date: string }) {
return <span>{date}</span>;
};
});

function renderNotificationCard(props = {}) {
const handleDeleteAction = jest.fn();
const handleReadAction = jest.fn();
const user = userEvent.setup();

render(
<NotificationCard
item={mockItem}
handleDeleteAction={handleDeleteAction}
handleReadAction={handleReadAction}
{...props}
/>,
);
return {
user,
handleDeleteAction,
handleReadAction,
};
}

describe("상단 알림 카드 컴포넌트", () => {
describe("알림 내용을 확인한다", () => {
test("알림 타입과 메세지가 렌더링된다", () => {
renderNotificationCard();

const notificationType = screen.getByText("새로운 댓글");
const comment = screen.getByText("새 댓글이 달렸습니다");

expect(notificationType).toBeInTheDocument();
expect(comment).toBeInTheDocument();
});

test("읽은 알림이면 읽지 않음 표시가 렌더링되지 않는다", () => {
renderNotificationCard({
item: {
...mockItem,
isRead: true,
},
});

const card = screen.queryByTestId("unread-indicator");
expect(card).not.toBeInTheDocument();
});
});

describe("알림을 읽고 삭제 할 수 있는지 확인한다", () => {
test("카드를 클릭하면 읽음 액션이 호출된다", async () => {
const { handleReadAction, user } = renderNotificationCard();

const card = screen.getByRole("button", { name: "읽지 않은 알림" });
await user.click(card);

expect(handleReadAction).toHaveBeenCalledTimes(1);
});

test("삭제 버튼을 클릭하면 삭제 액션이 호출된다", async () => {
const { handleDeleteAction, user } = renderNotificationCard();

const deleteButton = screen.getByRole("button", { name: "알림 삭제" });
await user.click(deleteButton);

expect(handleDeleteAction).toHaveBeenCalledTimes(1);
});
});
});
83 changes: 37 additions & 46 deletions features/header/components/NotificationCard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import Thumbnail from "@/components/ui/Thumbnail";
import RelativeTime from "@/components/ui/RelativeTime";

const NOTIFICATION_STYLE = {
card: "px-5 py-3 w-full block text-left hover:bg-purple-50 cursor-pointer",
card: "block w-full cursor-pointer px-5 py-3 text-left hover:bg-purple-50",
cardContent: "flex items-start gap-4",
cardDot: "size-1 shrink-0 rounded-full bg-linear-to-r from-purple-400 to-purple-700",
cardType: "flex items-center text-xs font-semibold text-gray-800",
cardDeleteBtn: "flex size-4 cursor-pointer items-center justify-center rounded-full bg-gray-700",
cardDeleteBtn:
"absolute top-3 right-5 z-1 flex size-4 cursor-pointer items-center justify-center rounded-full bg-gray-700",
cardMessage: "pt-1 text-sm text-gray-600 break-keep",
cardDate: "flex items-center justify-end gap-1 text-xs text-gray-400",
};
Expand Down Expand Up @@ -50,58 +51,48 @@ export default function NotificationCard({
const typeUi =
NOTIFICATION_TYPE_UI[item.type as keyof typeof NOTIFICATION_TYPE_UI] ??
DEFAULT_NOTIFICATION_TYPE_UI;
// 알림 클릭 시
function handleCardClick() {
handleReadAction();
}
// 알림 키보드 활성화
function handleKeyDown(event: React.KeyboardEvent<HTMLElement>) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleReadAction();
}
}
function handleDeleteClick(event: React.MouseEvent<HTMLButtonElement>) {
event.stopPropagation();
handleDeleteAction();
}

return (
<article
role="button"
className={cn(NOTIFICATION_STYLE.card, !item.isRead && "bg-purple-50/30")}
onClick={handleCardClick}
onKeyDown={handleKeyDown}>
<div className={NOTIFICATION_STYLE.cardContent}>
<Thumbnail
src={item.image}
width={40}
height={40}
className={cn(
!!item.image ? "border border-gray-200" : "",
"size-10 shrink-0 rounded-lg",
)}
/>
<div className="grow">
<div className="flex items-center justify-between">
<article className="relative">
<button
type="button"
aria-label={item.isRead ? "읽은 알림" : "읽지 않은 알림"}
className={cn(NOTIFICATION_STYLE.card, !item.isRead && "bg-purple-50/30")}
onClick={handleReadAction}>
<div className={NOTIFICATION_STYLE.cardContent}>
<Thumbnail
src={item.image}
width={40}
height={40}
className={cn(!!item.image && "border border-gray-200", "size-10 shrink-0 rounded-lg")}
/>
<div className="grow">
<span className={cn(NOTIFICATION_STYLE.cardType)}>
{typeUi.label}
{typeUi.icon}
</span>
<button
type="button"
className={NOTIFICATION_STYLE.cardDeleteBtn}
onClick={handleDeleteClick}>
<IcDelete color="white" size="10px" />
</button>
</div>
<p className={NOTIFICATION_STYLE.cardMessage}>{item.message}</p>
<div className={NOTIFICATION_STYLE.cardDate}>
{!item.isRead && <span className={NOTIFICATION_STYLE.cardDot} aria-hidden="true" />}
<RelativeTime date={item.createdAt} />
<p className={NOTIFICATION_STYLE.cardMessage}>{item.message}</p>
<div className={NOTIFICATION_STYLE.cardDate}>
{!item.isRead && (
<span
className={NOTIFICATION_STYLE.cardDot}
aria-hidden="true"
data-testid="unread-indicator"
/>
)}
<RelativeTime date={item.createdAt} />
</div>
</div>
</div>
</div>
</button>

<button
type="button"
className={NOTIFICATION_STYLE.cardDeleteBtn}
onClick={handleDeleteAction}
aria-label="알림 삭제">
<IcDelete color="white" size="10px" />
</button>
</article>
);
}
Loading
Loading