Skip to content

Commit

Permalink
Refactor(#123): 유저페이지 테스트 코드 작성 및 에러 처리 개선 (#181)
Browse files Browse the repository at this point in the history
  • Loading branch information
Stilllee authored Feb 6, 2025
2 parents fe3568f + efc3ee2 commit da4bd1a
Show file tree
Hide file tree
Showing 7 changed files with 370 additions and 15 deletions.
12 changes: 7 additions & 5 deletions src/app/user/[id]/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,18 @@
import { useRouter } from "next/navigation";
import SolidButton from "@/components/common/buttons/SolidButton";

export default function Error({
error,
}: {
export type ErrorProps = {
error: Error & { digest?: string };
}) {
};

export default function Error({ error }: ErrorProps) {
const router = useRouter();

return (
<div className='flex h-full flex-1 flex-col items-center justify-center gap-4'>
<p className='text-gray-100'>{error?.message}</p>
<p className='text-gray-100'>
{error?.message || "오류가 발생했습니다."}
</p>

<SolidButton
className='w-fit'
Expand Down
5 changes: 0 additions & 5 deletions src/app/user/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
export const dynamic = "force-dynamic";

import { cookies } from "next/headers";
import { notFound } from "next/navigation";
import UserProfile from "@/components/user-page/UserProfile";
import UserTabs from "@/components/user-page/UserTabs";
import { getUserProfile } from "@/lib/user/getUserProfile";
Expand Down Expand Up @@ -36,10 +35,6 @@ export default async function UserPage({ params }: Props) {
cache: "no-store",
});

if (!userInfo) {
notFound();
}

return (
<div className='flex h-full flex-1 flex-col p-4 tablet:px-20 tablet:py-[52px] desktop:py-[56px]'>
<div className='relative z-10 mx-auto flex w-full flex-col desktop:max-w-[960px]'>
Expand Down
11 changes: 6 additions & 5 deletions src/lib/user/getUserProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,18 @@ export async function getUserProfile(userId: string, options?: RequestInit) {
...options,
});

const { data, message } = await res.json();

if (!res.ok) {
const errorData = await res.text();
console.error("Profile API Error:", {
console.error("사용자 프로필 조회 실패:", {
url: `/user/profile/${userId}`,
status: res.status,
statusText: res.statusText,
errorMessage: errorData,
errorMessage: message,
});

throw new Error("사용자 프로필을 가져오지 못했습니다.");
throw new Error(message || "유저 프로필을 불러오는데 실패했습니다.");
}

const { data } = await res.json();
return data as UserProfile;
}
76 changes: 76 additions & 0 deletions src/tests/user-page/UserProfile.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { cleanup, render, screen } from "@testing-library/react";
import UserProfile from "@/components/user-page/UserProfile";
import "@testing-library/jest-dom";

describe("UserProfile Component", () => {
const mockUserInfo = {
userId: 1,
email: "[email protected]",
nickname: "테스트 유저",
profileImg: "https://test.com/test-profile.jpg",
qualificationStatus: "QUALIFIED" as const,
bio: "테스트 자기소개",
userTagList: [
{ id: 1, tag: "태그1" },
{ id: 2, tag: "태그2" },
],
ownId: true,
};

beforeEach(() => {
render(<UserProfile userInfo={mockUserInfo} />);
});

describe("기본 정보 렌더링", () => {
it("닉네임을 렌더링 한다.", () => {
expect(screen.getByText("테스트 유저")).toBeInTheDocument();
});

it("프로필 이미지를 올바르게 렌더링한다.", () => {
const image = screen.getByRole("img");
expect(image).toHaveAttribute(
"src",
expect.stringContaining("test-profile.jpg"),
);
expect(image).toHaveAttribute("alt", "테스트 유저님의 프로필 이미지");
});
});

describe("자기소개 렌더링", () => {
it("자기소개를 렌더링 한다.", () => {
expect(screen.getByText("테스트 자기소개")).toBeInTheDocument();
});
});

describe("사용자 태그 목록 렌더링", () => {
it("사용자 태그 목록을 모두 표시한다.", () => {
expect(screen.getByText("태그1")).toBeInTheDocument();
expect(screen.getByText("태그2")).toBeInTheDocument();

const tagList = screen.getByLabelText("자기소개 키워드");
expect(tagList.children).toHaveLength(mockUserInfo.userTagList.length);
});
});

describe("과외선생님 뱃지", () => {
describe("자격이 있을 때", () => {
it("뱃지를 표시한다.", () => {
expect(screen.getByText("과외선생님")).toBeInTheDocument();
});
});

describe("자격이 없을 때", () => {
beforeEach(() => {
cleanup();
const unqualifiedUserInfo = {
...mockUserInfo,
qualificationStatus: "UNQUALIFIED" as const,
};
render(<UserProfile userInfo={unqualifiedUserInfo} />);
});
it("뱃지를 표시하지 않는다.", () => {
expect(screen.queryByText("과외선생님")).not.toBeInTheDocument();
});
});
});
});
47 changes: 47 additions & 0 deletions src/tests/user-page/error.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Error, { type ErrorProps } from "@/app/user/[id]/error";
import "@testing-library/jest-dom";

const mockReplace = jest.fn();

jest.mock("next/navigation", () => ({
useRouter: () => ({
replace: mockReplace,
}),
}));

describe("유저페이지 Error 컴포넌트", () => {
const errorMessage = "해당 유저는 존재하지 않습니다.";
const mockError: ErrorProps["error"] = {
name: "Error",
message: "해당 유저는 존재하지 않습니다.",
digest: "test-digest",
} as const;

beforeEach(() => {
jest.clearAllMocks();
render(<Error error={mockError} />);
});

it("에러 메세지를 표시한다", () => {
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});

it("전달받은 에러 메세지가 없으면 기본 메세지를 표시한다", () => {
const mockErrorWithoutMessage: ErrorProps["error"] = {
name: "Error",
message: "",
} as const;

render(<Error error={mockErrorWithoutMessage} />);
expect(screen.getByText("오류가 발생했습니다.")).toBeInTheDocument();
});

it("홈으로 돌아가기 버튼 클릭시 홈으로 이동한다", async () => {
const button = screen.getByText("홈으로 돌아가기");
await userEvent.click(button);

expect(mockReplace).toHaveBeenCalledWith("/");
});
});
138 changes: 138 additions & 0 deletions src/tests/user/fetcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { fetcher } from "@/lib/user/fetcher";

describe("fetcher", () => {
const mockData = { data: "test" };
const mockResponse = {
ok: true,
json: () => Promise.resolve(mockData),
} as Response;

beforeEach(() => {
jest.resetAllMocks();
global.fetch = jest.fn(() => Promise.resolve(mockResponse));
});

describe("기본 동작", () => {
it("기본 GET 요청을 성공적으로 처리한다", async () => {
const res = await fetcher("/test", "");
const data = await res.json();

expect(res.ok).toBe(true);
expect(data).toEqual(mockData);
});

it("HTTP 메서드를 설정할 수 있다", async () => {
const originalData = { title: "수정 전" };
const updateData = { title: "수정 후" };

(global.fetch as jest.Mock).mockImplementationOnce(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(updateData),
}),
);

const res = await fetcher("/update", "", {
method: "PATCH",
body: JSON.stringify(updateData),
});

expect(global.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
method: "PATCH",
body: JSON.stringify(updateData),
}),
);

const data = await res.json();
expect(data).toEqual(updateData);
expect(data).not.toEqual(originalData);
});
});

describe("헤더 처리", () => {
it("인증 토큰이 필요한 요청을 성공적으로 처리한다", async () => {
const token = "test-token";
const res = await fetcher("/auth-test", token, { auth: true });

expect(global.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: `Bearer ${token}`,
}),
}),
);

const data = await res.json();
expect(data).toEqual(mockData);
});

it("커스텀 헤더를 설정할 수 있다", async () => {
const customHeaders = {
"Accept-Language": "ko-KR",
};
const token = "test-token";

await fetcher("/test", token, {
auth: true,
headers: customHeaders,
});

expect(global.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...customHeaders,
}),
}),
);
});

it("FormData 요청시 Content-Type: application/json이 설정되지 않는다", async () => {
const formData = new FormData();
formData.append("key", "value");

await fetcher("/upload", "", {
method: "POST",
body: formData,
});

expect(global.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
body: formData,
headers: expect.not.objectContaining({
"Content-Type": "application/json",
}),
}),
);

const fetchCall = (global.fetch as jest.Mock).mock.calls[0][1];
expect(fetchCall.body).toBeInstanceOf(FormData);
});
});

describe("Next.js 옵션 처리", () => {
it("Next.js fetch 옵션을 처리할 수 있다", async () => {
const nextOptions = {
cache: "no-store" as const,
next: { revalidate: 60 },
headers: {
"Content-Type": "application/json",
"Accept-Language": "ko-KR",
},
};

await fetcher("/test", "", nextOptions);

expect(global.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining(nextOptions),
);
});
});
});
Loading

0 comments on commit da4bd1a

Please sign in to comment.