Skip to content
Open
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
40 changes: 40 additions & 0 deletions src/app/library/_components/GenreBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { render, screen, fireEvent } from '@testing-library/react';
import GenreBadge, { GENRES } from '@/app/library/_components/GenreBadge';

describe('GenreBadge 테스트', () => {
const mockDispatch = jest.fn();
let buttons: HTMLElement[];

const setup = (selectedGenres: string[] = []) => {
render(
<GenreBadge
dispatchSelectedGenres={mockDispatch}
selectedGenres={selectedGenres}
/>
);
buttons = screen.getAllByRole('button');
};

beforeEach(() => {
mockDispatch.mockClear();
});

it('모든 장르 버튼이 렌더링됨', () => {
setup();
const buttonLabels = buttons.map((btn) => btn.textContent);
expect(buttonLabels).toEqual(GENRES);
});

it('"판타지" 배지를 클릭하면 ["판타지"]로 dispatch', () => {
setup();
const fantasyBadge = buttons.find((btn) => btn.textContent === '판타지')!;
fireEvent.click(fantasyBadge);
expect(mockDispatch).toHaveBeenCalledWith(['판타지']);
});

it('선택된 장르는 aria-pressed=true', () => {
setup(['판타지']);
const fantasyBadge = screen.getByRole('button', { name: '판타지' });
expect(fantasyBadge).toHaveAttribute('aria-pressed', 'true');
});
});
4 changes: 3 additions & 1 deletion src/app/library/_components/GenreBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { GenreBadgeProps, GenreTypeWithAll } from './type';

export const GENRE_ALL = '전체';

const GENRES = [
export const GENRES = [
GENRE_ALL,
...Object.keys(GENRE_LOCATION_MAP),
] as GenreTypeWithAll[];
Expand All @@ -30,6 +30,8 @@ const GenreBadge = ({
<button
type="button"
key={genre}
name={genre}
aria-pressed={selectedGenres.includes(genre)}
onClick={() => updateSelectedGenre(genre)}
className={`flex-center rounded-md px-2 py-1 text-xs transition-all sm:text-sm ${
isActive ? 'bg-black text-white' : 'bg-gray-200 text-black'
Expand Down
111 changes: 111 additions & 0 deletions src/app/library/_components/LibraryListContainer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { render, screen } from '@testing-library/react';
import LibraryListContainer from '@/app/library/_components/LibraryListContainer';
import { LibraryListContainerProps } from '@/app/library/_components/type';

interface Story {
id: number;
title: string;
}

jest.mock('@/hooks/api/library/useInfiniteStories', () => ({
useInfiniteStories: jest.fn(),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기서는 __esModule 옵션이 없는게 export default로 설정돼서 그런건가요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 정확히 몰라서 gpt한테 물어보니, const로 export 하는 경우에는 __esModules가 필요없다고 하네요 ㅎㅎ

}));
jest.mock('@/hooks/useCurrentViewPort', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('@/app/library/_components/LibraryListGrid', () => ({
__esModule: true,
default: ({ stories }: { stories: Story[] }) => (
<div data-testid="library-list-grid">
{stories.map((s) => (
<div key={s.id}>{s.title}</div>
))}
</div>
),
}));
jest.mock('@/app/library/_components/LibraryListSkeleton', () => ({
__esModule: true,
default: () => <div data-testid="skeleton">로딩중...</div>,
}));
jest.mock('@/components/common/Observer/Observer', () => ({
__esModule: true,
default: ({ enabled }: { enabled: boolean }) => (
<div data-testid="observer">{enabled ? 'enabled' : 'disabled'}</div>
),
}));

import { useInfiniteStories } from '@/hooks/api/library/useInfiniteStories';
import useCurrentViewPort from '@/hooks/useCurrentViewPort';
Comment on lines +38 to +39
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

파일 중간에 import 문을 선언해주신 이유가 있을까요?
일반적으로 ES Module 문법에서 import 를 작성하는 것과 차이가 있다면 어떤 부분이었을 지 궁금합니다

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jest.mock()이 적용된 모듈의 mock 버전을 가져오기 위해 import를 중간에 배치한다고 합니다. 일반 ESM은 파일 최상단 import만 허용하지만, Jest는 mock 적용을 위해 변환 시 순서를 재조정하기 떄문에 위 방법이 가능하다고 합니다!

import { VIEWPORT_BREAK_POINT } from '@/constants/viewportBreakPoint';

describe('LibraryListContainer', () => {
const mockUseInfiniteStories = useInfiniteStories as jest.Mock;
const mockUseCurrentViewPort = useCurrentViewPort as jest.Mock;

const defaultProps: LibraryListContainerProps = {
keyword: '',
searchType: '제목',
genres: [],
};

beforeEach(() => {
jest.clearAllMocks();
mockUseCurrentViewPort.mockReturnValue({
viewportWidth: (VIEWPORT_BREAK_POINT.LG + VIEWPORT_BREAK_POINT.XL) / 2,
});
});

const setup = (
storyData?: Story[],
options?: Partial<ReturnType<typeof useInfiniteStories>>
) => {
mockUseInfiniteStories.mockReturnValue({
data: storyData ? { pages: [storyData] } : { pages: [[]] },
fetchNextPage: jest.fn(),
hasNextPage: false,
isFetchingNextPage: false,
isLoading: !storyData,
...options,
});

return render(<LibraryListContainer {...defaultProps} />);
};

it('로딩 상태일 때 Skeleton을 렌더링한다', () => {
setup(undefined, { isLoading: true });
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
});

it('스토리가 없고 keyword가 빈 문자열이면 "아직 스토리가 없어요" 메시지를 표시한다', () => {
setup([]);
expect(screen.getByText('아직 스토리가 없어요')).toBeInTheDocument();
});

it('스토리가 없고 keyword가 있으면 "검색된 스토리가 없어요" 메시지를 표시한다', () => {
render(
<LibraryListContainer keyword="테스트" searchType="제목" genres={[]} />
);
mockUseInfiniteStories.mockReturnValue({
data: { pages: [[]] },
fetchNextPage: jest.fn(),
hasNextPage: false,
isFetchingNextPage: false,
isLoading: false,
});

expect(screen.getByText('검색된 스토리가 없어요')).toBeInTheDocument();
});

it('스토리가 있으면 LibraryListGrid와 Observer를 렌더링한다', () => {
setup([{ id: 1, title: 'Story 1' }], { hasNextPage: true });
expect(screen.getByTestId('library-list-grid')).toBeInTheDocument();
expect(screen.getByTestId('observer')).toHaveTextContent('enabled');
expect(screen.getByText('Story 1')).toBeInTheDocument();
});

it('hasNextPage가 false이면 Observer가 disabled 상태로 렌더링된다', () => {
setup([{ id: 1, title: 'Story 1' }], { hasNextPage: false });
expect(screen.getByTestId('observer')).toHaveTextContent('disabled');
});
});
78 changes: 78 additions & 0 deletions src/app/library/_components/SearchInput.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { render, screen, fireEvent } from '@testing-library/react';
import SearchInput from '@/app/library/_components/SearchInput';

describe('SearchInput 테스트', () => {
const mockSetKeyword = jest.fn();
const mockOnSearch = jest.fn();

const renderSearchInput = (keyword = '') =>
render(
<SearchInput
keyword={keyword}
setKeyword={mockSetKeyword}
onSearch={mockOnSearch}
/>
);

beforeEach(() => {
jest.clearAllMocks();
});

it('초기 렌더링 시 입력창에 keyword 값이 반영된다', () => {
renderSearchInput('초기값');
const input = screen.getByLabelText(
'스토리 제목으로 검색'
) as HTMLInputElement;
expect(input.value).toBe('초기값');
});

it('검색어를 입력하면 setKeyword가 호출된다', () => {
renderSearchInput();
fireEvent.change(screen.getByLabelText('스토리 제목으로 검색'), {
target: { value: '천마재림' },
});
expect(mockSetKeyword).toHaveBeenCalledWith('천마재림');
});

it('검색어 입력 시 초기화 버튼이 나타난다', () => {
renderSearchInput();
fireEvent.change(screen.getByLabelText('스토리 제목으로 검색'), {
target: { value: '천마재림' },
});
expect(screen.getByLabelText('검색어 초기화')).toBeInTheDocument();
});

it('검색어를 입력하고 엔터키를 눌렀을 때 onSearch가 호출된다', () => {
renderSearchInput();
const input = screen.getByLabelText('스토리 제목으로 검색');
fireEvent.change(input, { target: { value: '천마재림' } });
fireEvent.submit(input.closest('form')!);
expect(mockOnSearch).toHaveBeenCalled();
});

it('검색어를 입력하고 검색 버튼을 클릭했을 때 onSearch가 호출된다', () => {
renderSearchInput();
fireEvent.change(screen.getByLabelText('스토리 제목으로 검색'), {
target: { value: '천마재림' },
});
fireEvent.click(screen.getByLabelText('검색'));
expect(mockOnSearch).toHaveBeenCalled();
});

it('초기화 버튼 클릭 시 setKeyword가 빈 문자열로 호출되고 입력값이 비워진다', () => {
renderSearchInput('천마재림');
fireEvent.click(screen.getByLabelText('검색어 초기화'));
expect(mockSetKeyword).toHaveBeenCalledWith('');
expect(
(screen.getByLabelText('스토리 제목으로 검색') as HTMLInputElement).value
).toBe('');
});

it('검색어가 비워질 때 onSearch가 자동 호출된다', () => {
renderSearchInput('천마재림');
fireEvent.change(screen.getByLabelText('스토리 제목으로 검색'), {
target: { value: '' },
});
expect(mockOnSearch).toHaveBeenCalled();
});
});