diff --git a/.storybook/main.ts b/.storybook/main.ts
index 65d2f92..2a5e597 100644
--- a/.storybook/main.ts
+++ b/.storybook/main.ts
@@ -5,6 +5,7 @@ import { fileURLToPath } from "url";
import { mergeConfig } from "vite";
const __dirname = dirname(fileURLToPath(import.meta.url));
+const ALIAS_PATH = path.resolve(__dirname, "../src");
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
@@ -23,7 +24,7 @@ const config: StorybookConfig = {
return mergeConfig(baseConfig, {
resolve: {
alias: {
- "@": path.resolve(__dirname, "../src"),
+ "@": ALIAS_PATH,
},
},
});
diff --git a/.storybook/preview.ts b/.storybook/preview.ts
index 5e80da2..2f08867 100644
--- a/.storybook/preview.ts
+++ b/.storybook/preview.ts
@@ -11,12 +11,12 @@ const preview: Preview = {
},
a11y: {
- // 'todo' - show a11y violations in the test UI only
- // 'error' - fail CI on a11y violations
- // 'off' - skip a11y checks entirely
+ // todo: show a11y violations in the test UI only
+ // error: fail CI on a11y violations
+ // off: skip a11y checks entirely
test: "todo",
},
},
};
-export default preview;
\ No newline at end of file
+export default preview;
diff --git a/tests/components/todos/todoFormFile.test.tsx b/tests/components/todos/todoFormFile.test.tsx
new file mode 100644
index 0000000..517269d
--- /dev/null
+++ b/tests/components/todos/todoFormFile.test.tsx
@@ -0,0 +1,67 @@
+import { render, screen, fireEvent } from "@testing-library/react";
+import AttachmentSection from "@/app/(protected)/_components/todo-modal/_components/sections/AttachmentSection";
+import { PaperClipIcon } from "@heroicons/react/24/outline";
+
+describe("AttachmentSection - 파일 유효성 검사", () => {
+ const renderComponent = (onChange = jest.fn()) => {
+ render(
+ }
+ onChange={onChange}
+ />,
+ );
+ };
+
+ beforeEach(() => {
+ jest.spyOn(window, "alert").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it("3MB 초과 파일은 alert를 띄우고 onChange를 호출하지 않는다", () => {
+ const onChange = jest.fn();
+ renderComponent(onChange);
+
+ const input = screen.getByLabelText("파일을 업로드해주세요");
+
+ const bigFile = new File(
+ ["a".repeat(4 * 1024 * 1024)], // 4MB
+ "big.png",
+ { type: "image/png" },
+ );
+
+ fireEvent.change(input, {
+ target: { files: [bigFile] },
+ });
+
+ expect(window.alert).toHaveBeenCalledWith(
+ "3MB 이하의 파일만 업로드 할 수 있습니다.",
+ );
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it("3MB 이하 파일은 정상적으로 onChange가 호출된다", () => {
+ const onChange = jest.fn();
+ renderComponent(onChange);
+
+ const input = screen.getByLabelText("파일을 업로드해주세요");
+
+ const smallFile = new File(
+ ["a".repeat(1024 * 1024)], // 1MB
+ "small.png",
+ { type: "image/png" },
+ );
+
+ fireEvent.change(input, {
+ target: { files: [smallFile] },
+ });
+
+ expect(window.alert).not.toHaveBeenCalled();
+ expect(onChange).toHaveBeenCalledWith(smallFile);
+ });
+});
diff --git a/tests/components/todos/todoFormModal.test.tsx b/tests/components/todos/todoFormModal.test.tsx
new file mode 100644
index 0000000..96593f9
--- /dev/null
+++ b/tests/components/todos/todoFormModal.test.tsx
@@ -0,0 +1,99 @@
+import { screen, fireEvent } from "@testing-library/react";
+import { renderWithQueryClient } from "tests/test-utils";
+import TodoFormContent from "@/app/(protected)/_components/todo-modal/_components/TodoFormContent";
+import { useGoalList } from "@/hooks/queries/goals/useGoalList";
+import { useDeviceSize } from "@/hooks/useDeviceSize";
+
+jest.mock("@/hooks/queries/goals/useGoalList", () => ({
+ useGoalList: jest.fn(),
+}));
+
+jest.mock("@/hooks/useDeviceSize", () => ({
+ useDeviceSize: jest.fn(),
+}));
+
+jest.mock("@/hooks/queries/todos/useCreateMutation", () => ({
+ useCreateMutation: () => ({ mutate: jest.fn() }),
+}));
+
+jest.mock("@/hooks/queries/todos/useEditMutation", () => ({
+ useEditMutation: () => ({ mutate: jest.fn() }),
+}));
+
+jest.mock("@/hooks/queries/files/useFileUploadMutation", () => ({
+ useFileUploadMutation: () => ({ mutateAsync: jest.fn() }),
+}));
+
+afterEach(() => {
+ jest.clearAllMocks();
+});
+
+describe("TodoFormContent", () => {
+ it("create 모드이면 생성 화면을 보여준다", () => {
+ (useDeviceSize as jest.Mock).mockReturnValue({ isMobile: false });
+ (useGoalList as jest.Mock).mockReturnValue({
+ data: { goals: [{ id: 1, title: "React 공부" }] },
+ });
+
+ renderWithQueryClient(
+ ,
+ );
+
+ expect(screen.getByText("할 일 생성")).toBeInTheDocument();
+ expect(screen.getByText("목표를 선택해주세요")).toBeInTheDocument();
+ });
+
+ it("edit 모드이면 기존 값이 input에 채워진다", () => {
+ (useDeviceSize as jest.Mock).mockReturnValue({ isMobile: false });
+ (useGoalList as jest.Mock).mockReturnValue({
+ data: { goals: [{ id: 1, title: "Java 공부" }] },
+ });
+
+ renderWithQueryClient(
+ ,
+ );
+
+ expect(screen.getByDisplayValue("할일1")).toBeInTheDocument();
+ });
+
+ it("파일을 선택하면 input change 이벤트가 정상 동작한다", () => {
+ (useDeviceSize as jest.Mock).mockReturnValue({ isMobile: false });
+ (useGoalList as jest.Mock).mockReturnValue({
+ data: { goals: [{ id: 1, title: "React 공부" }] },
+ });
+
+ renderWithQueryClient(
+ ,
+ );
+
+ const input = screen.getByLabelText("파일을 업로드해주세요");
+ const file = new File(["test"], "test.png", { type: "image/png" });
+
+ fireEvent.change(input, {
+ target: { files: [file] },
+ });
+
+ // ❗ file input은 value 확인 불가 → 에러 없이 동작하면 OK
+ expect(input).toBeInTheDocument();
+ });
+});
diff --git a/tests/components/todos/todos.test.tsx b/tests/components/todos/todos.test.tsx
new file mode 100644
index 0000000..6a9d47f
--- /dev/null
+++ b/tests/components/todos/todos.test.tsx
@@ -0,0 +1,106 @@
+import { screen } from "@testing-library/react";
+import type { ComponentType } from "react";
+import TodosPage from "@/app/(protected)/todos/page";
+import { renderWithQueryClient } from "tests/test-utils";
+import type { ListTodoType } from "@/components/common/list/list-item/types";
+
+import { useTodosQuery } from "@/hooks/queries/todos";
+
+// react-error-boundary는 ES Module 형식이라 Jest가 변환 못하여,
+// 패키지 전체를 모킹 후 children만 렌더링
+jest.mock("react-error-boundary", () => ({
+ ErrorBoundary: ({ children }: { children: React.ReactNode }) => (
+ <>{children}>
+ ),
+ useErrorHandler: () => () => {},
+ withErrorBoundary: (component: ComponentType) => component,
+}));
+
+// mocks
+jest.mock("@/hooks/queries/todos");
+
+jest.mock("@/app/(protected)/todos/_components/TodoHeader", () => {
+ return function MockTodoHeader() {
+ return
TodoHeader
;
+ };
+});
+
+jest.mock("@/app/(protected)/todos/_components/TodosContent", () => {
+ return function MockTodosContent() {
+ const { data } = useTodosQuery();
+ const todos: ListTodoType[] = data?.todos ?? [];
+
+ if (todos.length === 0) {
+ return 등록된 할 일이 없어요
;
+ }
+
+ return (
+
+ {todos.map((todo) => (
+
{todo.label}
+ ))}
+
+ );
+ };
+});
+
+jest.mock("@/app/(protected)/todos/_components/TodosLayout", () => ({
+ __esModule: true,
+ default: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+afterEach(() => {
+ jest.clearAllMocks();
+});
+
+// tests
+describe("모든 할 일 페이지", () => {
+ it("할 일이 있으면 할 일 리스트를 보여준다", () => {
+ (useTodosQuery as jest.Mock).mockReturnValue({
+ data: {
+ todos: [
+ { id: 1, label: "할일1", checked: false },
+ { id: 2, label: "할일2", checked: true },
+ ],
+ totalCount: 2,
+ },
+ });
+
+ renderWithQueryClient();
+
+ expect(screen.getByText("할일1")).toBeInTheDocument();
+ expect(screen.getByText("할일2")).toBeInTheDocument();
+ });
+
+ it("할 일이 없으면 빈 상태 문구를 보여준다", () => {
+ (useTodosQuery as jest.Mock).mockReturnValue({
+ data: {
+ todos: [],
+ totalCount: 0,
+ },
+ });
+
+ renderWithQueryClient();
+
+ expect(screen.getByText("등록된 할 일이 없어요")).toBeInTheDocument();
+ });
+
+ it("모든 할 일 개수가 Header에 보여진다", () => {
+ (useTodosQuery as jest.Mock).mockReturnValue({
+ data: {
+ todos: [
+ { id: 1, label: "할일1", checked: false },
+ { id: 2, label: "할일2", checked: true },
+ { id: 3, label: "할일3", checked: false },
+ ],
+ totalCount: 3,
+ },
+ });
+
+ renderWithQueryClient();
+
+ expect(screen.getByText("TodoHeader")).toBeInTheDocument();
+ });
+});