[TypeScript/Keupang] #6 Header 컴포넌트 제작하기

HoneyCode Lab·2024년 12월 10일

프로젝트

목록 보기
6/8
post-thumbnail

개요


✅Keupang Github
이제 본격적으로 UI제작을 시작한다.

일단 모든 페이지에서 보일 헤더 컴포넌트 제작을 하려고 한다.

고려해야할 점은 다음과 같다.

  1. 네비게이션바가 포함된다.
  2. 카테고리와 마이페이지는 드랍다운이다.
  3. 반응형이어야 한다.
  4. 테마에 반응하여야 한다.
  5. 각 카테고리 클릭 시 해당 URL로 이동하여야 한다.

 


헤더 컴포넌트 제작하기


헤더 컴포넌트 제작하기로 Github ISSUE를 만들었고, 현재 제작이 완료된 지금 4개의 Pull Request가 물려있다.

PR별로 작성해보려고 한다.

 

헤더 컴포넌트 제작 전, 환경 설정


헤더 컴포넌트를 제작하는데 필요한 환경을 설정하였다.

chore: 반응형 테마 설정 변경

헤더 제작시 버튼이 반드시 들어가게 되는데 여기에 사용될 hover 속성에 대한 스타일 값과 미디어 쿼리에 따른 사이즈 변경등에 대한 테마를 작성했다.

theme.ts파일을 수정했고, 이에 따른 타입을 설정하는 emotion.d.ts파일또한 수정하였다.

 

chore: 헤더 제작을 위한 라이브러리 설정

드랍다운 관련 기능을 만들때 화살표 아이콘이 필요할거 같아 아이콘 라이브러리를 설치하였다.

또한, 기존에 작성된 버튼을 상속하여 새로운 버튼 스타일을 만들것을 고려해 @emotion/babel-plugin을 설치하였다.

 


컴포넌트 제작 전 유틸함수, 훅 제작 및 상수 정의


헤더 컴포넌트에서 사용될 유틸함수와 Hooks를 제작하고, 드랍다운에서 사용될 contants 파일을 정의하였다.

feat: 미디어 쿼리 사용을 위한 유틸 함수 제작

미디어 쿼리를 사용하는데 @media (max-width:1024px){}등으로 계속 사용되는 부분에 이질감을 느꼈다.

이를 하나의 유틸함수로 추출하여 미디어쿼리가 제대로 작성될 수 있도록 테스트를 작성하고, 미리 theme.ts에 대응된 값으로 사용하여 일관성을 유지하려고 했다.

import { Theme } from '@emotion/react';

export const mediaQuery =
	(breakpoint: keyof Theme['breakpoints']) => (props: { theme: Theme }) => `
  @media (max-width: ${props.theme.breakpoints[breakpoint]})
`;

위처럼 사용하였고, 그러면 아래와 같이 사용할 수 있다.

${mediaQuery('md')} {
	flex-direction: column;
	padding: ${({ theme }) => theme.spacing.md};
	gap: ${({ theme }) => theme.spacing.md};
}

 

feat: 테마 상태 관리를 위한 컨텍스트 개발

이제 라이트모드, 다크모드에 대한 상태를 관리하기 위해 컨텍스트를 개발했다.

필연적으로 헤더에 테마 상태를 변경하는 이벤트가 물리게 될 것이고, 이는 전역으로 관리되어야 하므로 컨텍스트로 추출하여 정의하였다.

import { createContext, ReactNode, useContext, useState } from 'react';
import { ThemeProvider as EmotionThemeProvider } from '@emotion/react';
import { lightTheme, darkTheme } from '../styles/theme';

interface ThemeContextProps {
	isDarkMode: boolean;
	toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextProps | undefined>(undefined);

export const useTheme = () => {
	const context = useContext(ThemeContext);
	if (!context) {
		throw new Error('useTheme은 ThemeProvider와 함께 사용되어야 한다.');
	}
	return context;
};

export const CustomThemeProvider = ({ children }: { children: ReactNode }) => {
	const [isDarkMode, setIsDarkMode] = useState(() => {
		const savedTheme = localStorage.getItem('theme');
		return savedTheme ? JSON.parse(savedTheme) : false;
	});

	const toggleTheme = () => {
		setIsDarkMode((prevMode: boolean) => {
			const newMode = !prevMode;
			localStorage.setItem('theme', JSON.stringify(newMode));
			return newMode;
		});
	};

	return (
		<ThemeContext.Provider value={{ isDarkMode, toggleTheme }}>
			<EmotionThemeProvider theme={isDarkMode ? darkTheme : lightTheme}>
				{children}
			</EmotionThemeProvider>
		</ThemeContext.Provider>
	);
};

라이트모드와 다크모드 테마를 불러오고, react의 컨텍스트 기능과 emotion의 ThemeProvider를 불러와서 작성하였다.

CustomThemeProvider를 만들고, localstorage로 테마 상태를 관리하여 새로고침 및 사이트를 나갔다 들어와도 테마가 유지되도록 하였다.

toggleTheme 함수를 만들어 테마에 대한 변경을 할 수 있도록 하였다.

최종적으로 만든 테마를 Provider에 물려 프로젝트 전역으로 적용될 수 있도록 하였다.

CustomThemeProvider는 App.tsx에서 사용될 것이고, useTheme은 테마 상태와 변경이 필요한 경우 사용될 것이다.

 

feat: 드랍다운 기능 및 포지션 일괄성을 위한 훅 제작

드랍다운은 헤더에서 두 군데에 사용된다.

마이페이지와 카테고리 영역이다.

카테고리는 마우스 hover시 해당 카테고리 리스트가 드랍다운 형식으로 아래에 나오게 될 것이고, 마이페이지는 해당 관련 리스트가 드랍다운으로 표시되게 된다.

이렇게 두 군데에 사용될 기능을 하나의 Hooks로 통합하여 관리하고, 테스트를 작성하여 안정적으로 개발을 하려고 했다.

import { useState, useEffect } from 'react';

export const useDropdown = () => {
	const [isOpen, setIsOpen] = useState(false);
	const [position, setPosition] = useState({ top: 0, left: 0 });

	const openDropdown = (ref: React.RefObject<HTMLDivElement>) => {
		if (ref.current) {
			const rect = ref.current.getBoundingClientRect();
			setPosition({
				top: rect.bottom + window.scrollY,
				left: rect.left + window.scrollX,
			});
		}
		setIsOpen(true);
	};

	const closeDropdown = () => setIsOpen(false);

	useEffect(() => {
		const handleScroll = () => closeDropdown();
		window.addEventListener('scroll', handleScroll);
		return () => window.removeEventListener('scroll', handleScroll);
	}, []);

	return { isOpen, position, openDropdown, closeDropdown };
};

드랍다운의 hover상태를 추적하여 열리고 닫히는 기능을 state로 관리하였고, 드랍다운 리스트가 나올 포지션 또한 state로 관리하였다.

openDropdown 호출 시, 전달된 ref로부터 요소의 boundingClientRect 값을 가져와 position을 설정한다.

이는 스크롤이나 창 이동에도 위치가 정확히 표시되도록 보장한다.

useEffect를 사용해 스크롤 이벤트를 등록하여 사용자가 페이지를 스크롤할 때 드랍다운이 자동으로 닫히도록 했다.

컴포넌트가 언마운트될 때 이벤트 리스너를 제거하여 메모리 누수를 방지하였다.

 

feat: 헤더 드랍다운 요소를 정의하는 상수 정의

이제 드랍다운 시 나오는 데이터를 상수로 정의하여 관리하였다.

export const NAV_LINKS = [
	{ path: '/products', label: '상품 목록' },
	{ path: '/cart', label: '장바구니' },
	{ path: '/orders', label: '주문 내역' },
];

export const CATEGORY_LINKS = [
	{ path: '/category/fashion', label: '패션' },
	{ path: '/category/electronics', label: '전자제품' },
	{ path: '/category/sports', label: '스포츠/레저' },
	{ path: '/category/books', label: '도서' },
	{ path: '/category/food', label: '식품' },
	{ path: '/category/beauty', label: '뷰티' },
	{ path: '/category/home', label: '홈/리빙' },
	{ path: '/category/hobby', label: '완구/취미' },
	{ path: '/category/auto', label: '자동차' },
];

path 속성을 통해 이동 할 URL도 정의하였다.

 


헤더에 사용될 컴포넌트 제작 및 헤더 컴포넌트 제작


이제 본격적으로 헤더에 사용될 자식 컴포넌트를 제작하고, 헤더 컴포넌트를 제작한다.

feat: 3가지의 버튼이 포함될 컴포넌트 제작

로그인, 회원가입, 테마변경 버튼이 포함될 컴포넌트를 제작한다.

import { Button } from './Button';
import { useTheme } from '../contexts/ThemeContext';
import { mediaQuery } from '../utils/utils';
import styled from '@emotion/styled';

const ActionButtonsContainer = styled.div`
	display: flex;
	gap: ${({ theme }) => theme.spacing.md};

	${mediaQuery('md')} {
		justify-content: center;
	}
`;

interface ActionButtonsProps {
	isMobile: boolean;
}

export const ActionButtons: React.FC<ActionButtonsProps> = ({ isMobile }) => {
	const { isDarkMode, toggleTheme } = useTheme();

	return (
		<ActionButtonsContainer>
			<Button variant='secondary' size='small' withBorder>
				로그인
			</Button>
			<Button variant='primary' size='medium'>
				회원가입
			</Button>
			<Button variant='secondary' size='small' onClick={toggleTheme}>
				{!isMobile && <span>{isDarkMode ? '라이트 모드' : '다크 모드'}</span>}
				{isDarkMode ? '🌞' : '🌛'}
			</Button>
		</ActionButtonsContainer>
	);
};

간단하게 ActionButtonsContainer를 만들어 컨테이너 스타일 및 미디어쿼리를 지정하였고 tsx로 UI를 제작하였다.

useTheme을 통해 테마를 관리할 수 있다.

 

feat: 드랍다운 컴포넌트 제작

마이페이지, 카테고리 요소에 적용될 드랍다운 컴포넌트를 제작했다.

import React, { ReactNode, RefObject } from 'react';
import styled from '@emotion/styled';
import ReactDOM from 'react-dom';

const DropdownToggleContainer = styled.div`
	display: flex;
	align-items: center;
	cursor: pointer;

	span {
		margin-left: ${({ theme }) => theme.spacing.sm};
		transition: transform 0.3s;
	}

	.dropdown-arrow {
		transition: transform 0.3s;
	}

	&:hover {
		color: ${({ theme }) => theme.colors.primary};
	}

	&:hover .dropdown-arrow {
		transform: rotate(180deg);
	}
`;

const DropdownContentContainer = styled.div`
	position: absolute;
	background-color: ${({ theme }) => theme.colors.background};
	box-shadow: 0px 8px 16px rgba(0, 0, 0, 0.2);
	padding: ${({ theme }) => theme.spacing.sm};
	z-index: 9999;
	min-width: max-content;

	a {
		color: ${({ theme }) => theme.colors.text};
		text-decoration: none;
		display: block;
		padding: ${({ theme }) => theme.spacing.sm};

		&:hover {
			background-color: ${({ theme }) => theme.colors.secondary};
		}
	}
`;

interface DropdownProps {
	label: string;
	children: ReactNode;
	position: { top: number; left: number };
	isOpen: boolean;
	toggleRef: RefObject<HTMLDivElement>;
	openDropdown: () => void;
	closeDropdown: () => void;
}

const Dropdown: React.FC<DropdownProps> = ({
	label,
	children,
	position,
	isOpen,
	toggleRef,
	openDropdown,
	closeDropdown,
}) => {
	return (
		<div
			ref={toggleRef}
			onMouseEnter={openDropdown}
			onMouseLeave={closeDropdown}>
			<DropdownToggleContainer>
				<span>{label}</span>
				<span className='dropdown-arrow'></span>
			</DropdownToggleContainer>
			{isOpen &&
				ReactDOM.createPortal(
					<DropdownContentContainer
						style={{
							top: position.top,
							left: position.left,
						}}>
						{children}
					</DropdownContentContainer>,
					document.body
				)}
		</div>
	);
};

export default Dropdown;

재사용성
label, children, position, isOpen, toggleRef 등의 Props를 받아 다양한 드랍다운 요구사항에 대응 가능하다.

포지셔닝
position 값을 통해 드랍다운의 위치를 동적으로 설정한다.

마우스 hover 시 드랍다운 컨텐츠가 지정된 위치에 정확히 렌더링된다.

이벤트 처리
onMouseEnter, onMouseLeave 이벤트를 활용하여 드랍다운 열기/닫기를 처리한다.

ReactDOM.createPortal을 사용하여 드랍다운 컨텐츠를 document.body에 렌더링, 레이아웃 간섭을 방지한다.

컴포넌트 Props는 아래와 같이 사용된다.

label: 드랍다운 버튼에 표시될 텍스트.
children: 드랍다운에 표시될 컨텐츠 (링크, 텍스트 등).
position: 드랍다운 컨텐츠의 위치 (top, left).
isOpen: 드랍다운 열림/닫힘 여부를 관리하는 상태.
toggleRef: 드랍다운을 열고 닫는 버튼의 참조값.
openDropdown, closeDropdown: 드랍다운을 열고 닫는 핸들러.

이제, 이 컴포넌트는 아래와 같이 사용될 수 있다.

<Dropdown
	label='카테고리'
	position={categoryPosition}
	isOpen={isCategoryOpen}
	toggleRef={categoryRef}
	openDropdown={() => openCategory(categoryRef)}
	closeDropdown={closeCategory}>
	{CATEGORY_LINKS.map((category) => (
		<Link key={category.path} to={category.path}>
			{category.label}
		</Link>
	))}
</Dropdown>

 

feat: 드랍다운 컴포넌트를 활용하여 네비게이션 컴포넌트 제작

이제 위에서 작성한 드랍다운 컴포넌트를 활용하여 네비게이션을 만든다.

import styled from '@emotion/styled';
import { Link } from 'react-router-dom';
import { NAV_LINKS, CATEGORY_LINKS } from '../constants/navLinks';
import Dropdown from './Dropdown';
import { mediaQuery } from '../utils/utils';
import { useDropdown } from '../hooks/useDropdown';
import { useRef } from 'react';

const StyledNav = styled.nav`
	display: flex;
	gap: ${({ theme }) => theme.spacing.lg};
	align-items: center;

	a {
		text-decoration: none;
		color: ${({ theme }) => theme.colors.text};
		font-size: ${({ theme }) => theme.fontSizes.md};

		&:hover {
			color: ${({ theme }) => theme.colors.primary};
		}
	}

	${mediaQuery('md')} {
		justify-content: flex-start;
		flex-wrap: nowrap;
		white-space: nowrap;
		scrollbar-width: auto;
		overflow-x: auto;
		width: 100%;

		&::-webkit-scrollbar {
			height: 2px;
			background: ${({ theme }) => theme.colors.secondary};
		}

		&::-webkit-scrollbar-thumb {
			background: ${({ theme }) => theme.colors.primary};
			border-radius: 4px;
		}
	}
`;

export const NavLinks = () => {
	const {
		isOpen: isCategoryOpen,
		position: categoryPosition,
		openDropdown: openCategory,
		closeDropdown: closeCategory,
	} = useDropdown();

	const categoryRef = useRef<HTMLDivElement>(null);
	const myPageRef = useRef<HTMLDivElement>(null);
	const {
		isOpen: isMyPageOpen,
		position: myPagePosition,
		openDropdown: openMyPage,
		closeDropdown: closeMyPage,
	} = useDropdown();
	return (
		<StyledNav>
			{NAV_LINKS.map((link) => (
				<Link key={link.path} to={link.path}>
					{link.label}
				</Link>
			))}

			{/* 카테고리 드롭다운 */}
			<Dropdown
				label='카테고리'
				position={categoryPosition}
				isOpen={isCategoryOpen}
				toggleRef={categoryRef}
				openDropdown={() => openCategory(categoryRef)}
				closeDropdown={closeCategory}>
				{CATEGORY_LINKS.map((category) => (
					<Link key={category.path} to={category.path}>
						{category.label}
					</Link>
				))}
			</Dropdown>

			{/* 마이페이지 드롭다운 */}
			<Dropdown
				label='마이페이지'
				position={myPagePosition}
				isOpen={isMyPageOpen}
				toggleRef={myPageRef}
				openDropdown={() => openMyPage(myPageRef)}
				closeDropdown={closeMyPage}>
				<Link to='/mypage/orders'>주문 내역</Link>
				<Link to='/mypage/profile'>내 정보</Link>
				<Link to='/mypage/products'>판매 정보</Link>
			</Dropdown>
		</StyledNav>
	);
};

useDropdown Hooks를 통해 드랍다운에 필요한 함수를 불러왔다.

두 가지에서 사용되므로 두개로 가져왔다.

그 외엔 constants에서 작성한 navLinks를 사용하여 map함수를 통해 보여주었다.

Dropdown은 위에서 사용한 예시처럼 사용하였다.

 

feat: 헤더 컴포넌트 제작

위에서 작성한 컴포넌트를 모두 활용하여 헤더 컴포넌트를 제작했다.

import styled from '@emotion/styled';
import { Link } from 'react-router-dom';
import logo from '../assets/images/logo.svg';
import { mediaQuery } from '../utils/utils';
import { ActionButtons } from './ActionButtons';
import { NavLinks } from './NavLinks';

const HeaderContainer = styled.header`
	width: 100%;
	display: flex;
	justify-content: space-between;
	align-items: center;
	padding: ${({ theme }) => theme.spacing.md} ${({ theme }) => theme.spacing.lg};
	background-color: ${({ theme }) => theme.colors.background};
	color: ${({ theme }) => theme.colors.text};
	box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1);
	border-bottom: 1px solid ${({ theme }) => theme.colors.secondary};

	${mediaQuery('md')} {
		flex-direction: column;
		padding: ${({ theme }) => theme.spacing.md};
		gap: ${({ theme }) => theme.spacing.md};
	}
`;

const Logo = styled(Link)`
	display: flex;
	align-items: center;
	text-decoration: none;

	img {
		width: auto;
		height: 30px;
	}
`;

const Header = () => {
	return (
		<HeaderContainer>
			<Logo <to='/'>
				<img src={logo} alt='KEUPANG 로고' />
			</Logo>
			<NavLinks />
			<ActionButtons isMobile={window.innerWidth <= 768} />
		</HeaderContainer>
	);
};

export default Header;

사용된 컴포넌트를 정리하면 아래와 같다.

HeaderContainer

헤더의 전체 레이아웃을 담당하며, flexbox를 사용해 컴포넌트 배치를 설정

반응형 디자인(mediaQuery)을 적용해 화면 크기에 따라 레이아웃 변화를 제공

Logo

로고 이미지를 표시하며, react-router-dom의 Link를 사용해 홈으로 이동할 수 있도록 설정

NavLinks

네비게이션 링크와 드랍다운(Dropdown)을 포함한 네비게이션 바

카테고리 및 마이페이지 드랍다운을 NavLinks에서 관리하여 기능 분리

ActionButtons

로그인, 회원가입 버튼과 테마 전환 버튼을 포함한 사용자 액션 영역

isMobile 프로퍼티로 화면 크기별 버튼 스타일링을 제어

이제 완성된 헤더를 확인해보자.

이쁘게 잘 뽑혔다.

 


헤더 컴포넌트 관련 코드 테스트 작성


이제 컴포넌트 제작에 사용된 함수 및 Hooks에 대한 테스트를 작성하자.

테스트 개수가 늘어나며, 테스트의 현황 및 커버리지를 파악하기 위한 라이브러리를 설치했다.

@vitest/coverage-istanbul를 설치하여,

yarn test --coverage

위 처럼 사용하면 coverage 관련 표가 나오도록 했다.

설정은 아래와 같았다.

coverage: {
	provider: 'istanbul',
	reporter: ['text', 'json', 'html'],
	reportsDirectory: './coverage',
	include: ['src/**/*.{js,ts,jsx,tsx}'],
	exclude: ['node_modules', 'test/**', 'src/mocks/browser.ts'],
},

vite.config.ts 파일에 위 처럼 작성하였다.

그럼 아래와 같이 커버리지 현황표를 확인하여 보강해야할 테스트를 확인할 수 있다.

 % Coverage report from istanbul
--------------------------|---------|----------|---------|---------|-------------------------------------
File                      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
--------------------------|---------|----------|---------|---------|-------------------------------------
All files                 |   64.62 |    40.11 |   54.37 |   64.16 | 
 src                      |   23.07 |        0 |   28.57 |   23.07 | 
  App.tsx                 |       0 |        0 |       0 |       0 | 20-31
  example.test.ts         |     100 |      100 |     100 |     100 | 
  main.tsx                |       0 |        0 |       0 |       0 | 7-15
 src/components           |   54.54 |    52.21 |   53.22 |   54.63 | 
  ActionButtons.tsx       |       0 |        0 |       0 |       0 | 6-22
  Button.tsx              |     100 |    73.07 |     100 |     100 | 12-14,18-22,30,40,53,57-68,75-83,90
  Card.tsx                |     100 |       68 |     100 |     100 | 10,20-42
  Dropdown.tsx            |       0 |        0 |       0 |       0 | 5-67
  Header.tsx              |       0 |        0 |       0 |       0 | 8-38
  NavLinks.tsx            |       0 |        0 |       0 |       0 | 9-89
  ProductList.tsx         |   94.73 |      100 |     100 |   94.44 | 21
 src/components/__tests__ |     100 |      100 |     100 |     100 | 
  Button.test.tsx         |     100 |      100 |     100 |     100 |                                     
  Card.test.tsx           |     100 |      100 |     100 |     100 | 
  ProductList.test.tsx    |     100 |      100 |     100 |     100 | 
 src/constants            |       0 |      100 |     100 |       0 | 
  navLinks.ts             |       0 |      100 |     100 |       0 | 1-7
 src/contexts             |     100 |    83.33 |     100 |     100 | 
  ThemeContext.tsx        |     100 |    83.33 |     100 |     100 | 23
 src/contexts/__tests__   |   97.22 |      100 |     100 |   97.14 | 
  ThemeContext.test.tsx   |   97.22 |      100 |     100 |   97.14 | 86
 src/hooks                |     100 |       50 |     100 |     100 | 
  useDropdown.ts          |     100 |       50 |     100 |     100 | 8
 src/hooks/__tests__      |     100 |      100 |     100 |     100 | 
  useDropdown.test.tsx    |     100 |      100 |     100 |     100 | 
 src/mocks                |     100 |      100 |     100 |     100 | 
  handler.ts              |     100 |      100 |     100 |     100 | 
  server.ts               |     100 |      100 |     100 |     100 | 
 src/pages                |       0 |        0 |       0 |       0 | 
  CartPage.tsx            |       0 |        0 |       0 |       0 | 7-22
  LoginPage.tsx           |       0 |        0 |       0 |       0 | 7-22
  MainPage.tsx            |       0 |        0 |       0 |       0 | 7-22
  MyPage.tsx              |       0 |        0 |       0 |       0 | 7-22
  NotFoundPage.tsx        |       0 |        0 |       0 |       0 | 7-22
  OrderHistoryPage.tsx    |       0 |        0 |       0 |       0 | 7-22
  ProductDetailPage.tsx   |       0 |        0 |       0 |       0 | 7-22
  ProductListPage.tsx     |       0 |        0 |       0 |       0 | 7-22
  SignupPage.tsx          |       0 |        0 |       0 |       0 | 7-22
 src/styles               |      50 |        0 |       0 |      50 | 
  GlobalStyles.tsx        |       0 |        0 |       0 |       0 | 4-7
  theme.ts                |     100 |      100 |     100 |     100 | 
 src/utils                |       0 |      100 |       0 |       0 | 
  utils.ts                |       0 |      100 |       0 |       0 | 4
--------------------------|---------|----------|---------|---------|-------------------------------------

이를 확인하여 테스트를 작성하도록 하자.

커버리지 목표를 설정하고 배포를 생각하면 좋다.

나는 배포 전, 80% 이상의 테스트 커버리지를 목표로 하려고 한다.

읽는 법은 아래와 같다. GPT의 도움

열 설명

File
디렉토리 및 파일 이름.
% Stmts (Statements)
테스트된 문장(Statements의 비율.
코드의 실행 가능한 문장이 얼마나 테스트되었는지 보여줍니다.
% Branch (Branches)
조건문(if, switch 등)과 같이 분기점이 되는 코드의 테스트 비율.
분기문(조건)의 각 분기가 얼마나 테스트되었는지 나타냅니다.
% Funcs (Functions)
함수의 테스트 비율.
선언된 함수가 얼마나 테스트되었는지 보여줍니다.
% Lines
실행된 라인의 비율.
코드의 각 라인이 얼마나 테스트되었는지 나타냅니다.
Uncovered Line #s
테스트되지 않은 코드 라인의 번호.
어떤 라인이 테스트되지 않았는지 파악하는 데 사용됩니다.

요약 분석

전체 프로젝트 (All files)
Statements: 64.62%
프로젝트 전체의 실행 가능한 문장 중 64.62%가 테스트되었습니다.
Branches: 40.11%
조건이나 분기 코드 중 40.11%만 테스트되었습니다.
Functions: 54.37%
선언된 함수 중 54.37%가 테스트되었습니다.
Lines: 64.16%
전체 라인 중 64.16%가 테스트되었습니다.

폴더별 분석

src/components
Statements: 54.54%
컴포넌트의 실행 가능한 코드 중 절반 이상이 테스트되었습니다.
Branches: 52.21%
조건문 테스트 비율도 비교적 높습니다.
Uncovered Line #s:
ActionButtons.tsx, Dropdown.tsx, Header.tsx, NavLinks.tsx의 많은 라인이 테스트되지 않았습니다.
src/pages
Statements, Branches, Functions, Lines: 0%
페이지 컴포넌트가 전혀 테스트되지 않았습니다.
(예: CartPage.tsx, LoginPage.tsx 등)
src/hooks
Statements: 100%
커스텀 훅의 코드가 완전히 테스트되었습니다.
Branches: 50%
분기 조건의 절반은 테스트되지 않았습니다.
(예: useDropdown.ts)
src/constants
Statements: 0%
상수 파일(예: navLinks.ts)은 테스트되지 않았습니다.

해석 및 개선 방안

커버리지 우선순위 지정
테스트되지 않은 주요 파일
ActionButtons.tsx, Dropdown.tsx, Header.tsx, NavLinks.tsx (주요 UI 컴포넌트)
src/pages 내 모든 페이지
이 파일들에 대해 기능 테스트를 작성하여 커버리지를 개선하세요.
Branches 개선
src/hooks/useDropdown.ts는 Statements 100%를 달성했지만 Branches가 50%로 낮습니다.
조건문(if/else)와 같은 분기 조건에 대한 테스트를 추가하세요.
미사용 코드 제거
테스트되지 않는 코드 중 실제로 사용되지 않는 코드가 있다면 제거하세요.
페이지 컴포넌트 테스트 작성
src/pages 내 파일들은 대부분 테스트되지 않았습니다.
각 페이지에 대한 렌더링 테스트 및 주요 동작(클릭, 입력 등)에 대한 테스트를 추가하세요.
테스트 커버리지 목표 설정
Statements, Branches, Functions, Lines 모두 80% 이상을 목표로 합니다.
중요도가 낮은 파일(src/constants, src/utils)은 우선순위를 낮게 설정할 수 있습니다.

모든 걸 내가 하려고 하지 않고, AI의 도움을 적절히 받아 개발 생산성을 향상 시키는 것은 중요한 역량이라고 생각한다.

위의 개선 방안을 참고하여 내 프로젝트를 보완해 나갈 생각이다.

 

test: ThemeContext 테스트 작성

우선, 내가 이번 ISSUE에서 생성한 파일을에 대한 테스트를 작성해볼 예정이다.

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CustomThemeProvider } from '../ThemeContext';
import { useTheme } from '../ThemeContext';
import { describe, expect, it, vi } from 'vitest';

// 테스트를 위한 목업 컴포넌트
const TestComponent = () => {
	const { isDarkMode, toggleTheme } = useTheme();

	return (
		<div>
			<p>현재 테마: {isDarkMode ? '다크 모드' : '라이트 모드'}</p>
			<button onClick={toggleTheme}>테마 전환</button>
		</div>
	);
};

describe('CustomThemeProvider 테스트 > ', () => {
	it('localStorage에 저장된 값에 따라 초기 테마를 설정해야 한다.', () => {
		localStorage.setItem('theme', JSON.stringify(true)); // 다크 모드 설정

		render(
			<CustomThemeProvider>
				<TestComponent />
			</CustomThemeProvider>
		);

		// 다크 모드가 초기 상태인지 확인
		const textElement = screen.getByText(/현재 테마: 다크 모드/i);
		expect(textElement).toBeInTheDocument();
	});

	it('테마 전환 버튼을 클릭하여 테마를 전환해야 한다.', async () => {
		localStorage.setItem('theme', JSON.stringify(false)); // 라이트 모드로 시작
		const user = userEvent.setup();

		render(
			<CustomThemeProvider>
				<TestComponent />
			</CustomThemeProvider>
		);

		const toggleButton = screen.getByRole('button', { name: /테마 전환/i });

		// 초기 상태 확인
		expect(screen.getByText(/현재 테마: 라이트 모드/i)).toBeInTheDocument();

		// 다크 모드로 전환
		await user.click(toggleButton);
		expect(screen.getByText(/현재 테마: 다크 모드/i)).toBeInTheDocument();

		// 다시 라이트 모드로 전환
		await user.click(toggleButton);
		expect(screen.getByText(/현재 테마: 라이트 모드/i)).toBeInTheDocument();
	});

	it('테마 전환 시 localStorage에 값이 저장되어야 한다.', async () => {
		localStorage.setItem('theme', JSON.stringify(false)); // 초기값: 라이트 모드
		const user = userEvent.setup();

		render(
			<CustomThemeProvider>
				<TestComponent />
			</CustomThemeProvider>
		);

		const toggleButton = screen.getByRole('button', { name: /테마 전환/i });

		// 다크 모드로 전환
		await user.click(toggleButton);
		expect(localStorage.getItem('theme')).toBe('true');

		// 라이트 모드로 전환
		await user.click(toggleButton);
		expect(localStorage.getItem('theme')).toBe('false');
	});

	it('CustomThemeProvider 없이 useTheme 사용 시 오류를 발생시켜야 한다.', () => {
		const consoleErrorSpy = vi
			.spyOn(console, 'error')
			.mockImplementation(() => {});

		const ErrorComponent = () => {
			useTheme(); // ThemeProvider 없이 호출
			return null;
		};

		expect(() => render(<ErrorComponent />)).toThrowError(
			'useTheme은 ThemeProvider와 함께 사용되어야 한다.'
		);

		consoleErrorSpy.mockRestore();
	});
});

테스트를 작성하고, 테스트에 대한 주석은 매우 중요하다고 생각하므로 최대한 간결하지만 자세히 주석을 작성하여 테스트를 했다.

 

test: useDropdown 테스트 작성

useDropdown도 중요한 기능이므로 테스트는 필수이다.

import { renderHook, act } from '@testing-library/react-hooks';
import { useDropdown } from '../useDropdown';
import { describe, expect, it, vi } from 'vitest';

describe('useDropdown Hook 테스트 > ', () => {
	it('초기 상태가 올바르게 설정되어야 한다.', () => {
		const { result } = renderHook(() => useDropdown());

		expect(result.current.isOpen).toBe(false);
		expect(result.current.position).toEqual({ top: 0, left: 0 });
	});

	it('openDropdown 호출 시 드롭다운이 열리고 위치가 설정되어야 한다.', () => {
		const mockRef = {
			current: {
				getBoundingClientRect: () => ({
					bottom: 100,
					left: 200,
				}),
			},
		};

		const { result } = renderHook(() => useDropdown());

		act(() => {
			result.current.openDropdown(mockRef as React.RefObject<HTMLDivElement>);
		});

		expect(result.current.isOpen).toBe(true);
		expect(result.current.position).toEqual({ top: 100, left: 200 });
	});

	it('closeDropdown 호출 시 드롭다운이 닫혀야 한다.', () => {
		const { result } = renderHook(() => useDropdown());

		act(() => {
			result.current.closeDropdown();
		});

		expect(result.current.isOpen).toBe(false);
	});

	it('스크롤 시 드롭다운이 닫혀야 한다.', () => {
		const { result } = renderHook(() => useDropdown());

		act(() => {
			result.current.openDropdown({
				current: {
					getBoundingClientRect: () => ({
						bottom: 100,
						left: 200,
					}),
				},
			} as React.RefObject<HTMLDivElement>);
		});

		expect(result.current.isOpen).toBe(true);

		act(() => {
			window.dispatchEvent(new Event('scroll'));
		});

		expect(result.current.isOpen).toBe(false);
	});

	it('컴포넌트 언마운트 시 이벤트 리스너가 제거되어야 한다.', () => {
		const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
		const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');

		const { unmount } = renderHook(() => useDropdown());

		expect(addEventListenerSpy).toHaveBeenCalledWith(
			'scroll',
			expect.any(Function)
		);
		unmount();
		expect(removeEventListenerSpy).toHaveBeenCalledWith(
			'scroll',
			expect.any(Function)
		);
	});
});

이제 테스트에 대한 결과가 초록색으로 잘 나오면 성공이다.

성공했다.

 


Summary


  • 환경 설정
  • 함수 및 Hooks 정의
  • 자식 컴포넌트 정의
  • 헤더 컴포넌트 작성
  • 테스트 작성
  • 트러블 슈팅
profile
“왜?”라는 질문을 멈추지 않고 본질에 집중하는 개발자입니다.

1개의 댓글

comment-user-thumbnail
2024년 12월 10일

아 굿~

답글 달기