프로젝트/BookStore 사이트

1125 기본 컴포넌트 작성 및 테스트 - Title, Button, InputText, 헤더 푸터

thinktank911 2025. 11. 25. 13:28

기본 컴포넌트 작성

Title 컴포넌트

스니펫으로 기본 리액트 컴포넌트 작성

➡️ rfce (ES7+ React/Redux/React-Native snippets 활용)

참고 : https://github.com/r5n-labs/vscode-react-javascript-snippets/blob/HEAD/docs/Snippets.md

 

vscode-react-javascript-snippets/docs/Snippets.md at 185bb91a0b692c54136663464e8225872c434637 · r5n-labs/vscode-react-javascrip

Extension for React/Javascript snippets with search supporting ES7+ and babel features - r5n-labs/vscode-react-javascript-snippets

github.com

 

Props 타입 지정

  • 항상 컴포넌트 만들 때 props 타입 지정해주는 습관 들일 것
  • 여기서 필요한 속성은 children과 size
  • children은 React.ReactNode로 지정해주고, size는 theme.ts에서 만든 HeadingSize를 import해온다.

HeadingSize 만들기

 

theme.ts

// 타이틀 사이즈 타입
export type HeadingSize = "large" | "medium" | "small";

export interface Theme {
    name: ThemeName;
    color: Record<ColorKey, string>;
    heading?: {
        [key in HeadingSize] : {
            fontSize: string;
            fontWeight: number;
        }
    }
}

export const light: Theme = {
    name: 'light',
    color: {
        primary: 'brown',
        background: 'lightgray',
        secondary: 'blue',
        third: 'green',
    },
    heading: {
        large: {
            fontSize: '2rem',
            fontWeight: 700,
        },
        medium: {
            fontSize: '1.5rem',
            fontWeight: 700,
        },
        small: {
            fontSize: '1rem',
            fontWeight: 700,
        },
    }
};

export const dark: Theme = {
    ...light,       // 중복 작성 막기 위해 얕은 복사 후 오버라이딩
    name: 'dark',
    color: {
        primary: 'coral',
        background: 'midnightblue',
        secondary: 'darkblue',
        third: 'darkgreen',
    },
};
  • HeadingSize에 대한 타입을 지정해주고 export
  • 각 타입에 대한 fontSize랑 fontWeight 지정
  • light 테마에 적은 내용 dark 테마에 ...연산자로 얕은 복사

 

TitleStyle 설정 - 테마에서 가져오기

  • *Omit<> 타입으로 Props에서 children 프로퍼티 제거
  • font-size : theme.heading[size]로 가져오기
  • color : 타입을 옵셔널로 지정해주고, props로 color 받는다. color 없을 경우 primary 적용
interface Props {
    children: React.ReactNode;
    size: HeadingSize,
    color?: ColorKey;
}

function Title({children, size, color}: Props){
    return <TitleStyle size={size} color={color}>{children}</TitleStyle>
}

// 여기서 {theme}은 themeProvider에서 props로 넘겨주고 있는 값
const TitleStyle = styled.h1<Omit<Props, 'children'>>`
    font-size: ${({theme, size}) => theme.heading[size].fontSize};
    color: ${({theme, color}) => color ? theme.color[color] : theme.color.primary};
`;

 

※ Omit<> : 유틸리티(제너릭) 타입의 일종. 특정 속성만 제거한 타입을 정의
Omit<{타입 객체}, {제거할 프로퍼티}>
참고 : https://kyounghwan01.github.io/blog/TS/fundamentals/utility-types/#partial

 

 

Title 적용하기

 

Home.tsx

function Home(){
    return (
        <>
        <Title size="medium" color="background">
            제목 테스트
        </Title>
            <div>home body</div>
        </>
    )
}

 

Title 컴포넌트 테스트 작성하기

 

Title.spec.tsx

import { render, screen } from "@testing-library/react";
import Title from "./Title";
import { BookStoreThemeProvider } from "../../context/ThemeContext";

describe("Title 컴포넌트 테스트", () => {
    it('렌더를 확인', () => {
        // 1. 렌더 : 가상화면 렌더됨
        // 적적한 props 넣어줘야 함
        render(
            <BookStoreThemeProvider>
                <Title size="large">제목</Title>
            </BookStoreThemeProvider>
        );

        // 2. 확인 : '제목'이란 텍스트 화면상에 있는지 확인
        expect(screen.getByText("제목")).toBeInTheDocument();
    });

    it('size props 적용', () => {
        const {container} = render(
            <BookStoreThemeProvider>
                <Title size="large">제목</Title>
            </BookStoreThemeProvider>
        );

        expect(container?.firstChild).toHaveStyle({
            fontSize: "2rem"
        });
    });

    it('color props 적용', () => {
        const {container} = render(
            <BookStoreThemeProvider>
                <Title size="large" color="primary">제목</Title>
            </BookStoreThemeProvider>
        );

        expect(container?.firstChild).toHaveStyle({
            color: "brown"
        });
    })
})
  • @testing-library/react 모듈에서 render와 screen 가져오기
  • describe 안에 테스트할 목록 작성
  • it('테스트할 상세 목록', 콜백함수)
  • 콜백함수 안에 1. 렌더 2. 확인 작성
    • 렌더 : 가상화면. 실제와 같은 형식 갖춰서 넣어줘야 함
    • 확인 : expect(screen.getByText("제목")).toBeInTheDocument();
      ➡️ '제목'이란 텍스트 화면상에 있는지 확인
  • 렌더 후 그 안에 props를 테스트 하고 싶으면 render 내용을 container에 담는다.
  • expect 시 toHaveStyle 사용
  • 테스트 실행 : npm run test Title

Button 컴포넌트

Button.tsx 만들기

  • 스니펫 작성
    • 자주 컴포넌트를 만들 것 같아서 스니펫을 적용했다.
    • styled-components 적용 버전
    • ctrl + shift + p > snippet 검색 > configure snippets 선택 > typescriptreact.json 선택
    • typescriptreact.json에 넣어줘야 함. (.tsx에서 스니펫 적용 시)
    • typescript.json은 ts일 경우
{
  "Styled Components Component": {
    "prefix": "stycomp",
    "body": [
      "import styled from \"styled-components\";",
      "",
      "interface Props {",
      "",
      "}",
      "",
      "function ${TM_FILENAME_BASE}(props: Props) {",
      "  return (",
      "    <${TM_FILENAME_BASE}Style></${TM_FILENAME_BASE}Style>",
      "  );",
      "}",
      "",
      "const ${TM_FILENAME_BASE}Style = styled.div``;",
      "",
      "export default ${TM_FILENAME_BASE};"
    ],
    "description": "Styled-components 기본 컴포넌트 자동 생성 (파일명 기반)"
  }
}

 

Button 테마 작성

 

theme.ts

export type ButtonSize = "large" | "medium" | "small";
export type ButtonScheme = "primary" | "normal";

button: {
        [key in ButtonSize] : {
            fontSize: string;
            padding: string;
        }
    };
buttonScheme: {
    [key in ButtonScheme]: {
        color: string;
        backgroundColor: string;
    };
};
  • 버튼 사이즈, 스키마 타입 지정
button: {
        large: {
            fontSize: '1.5rem',
            padding: '1rem 2rem',
        },
        medium: {
            fontSize: '1rem',
            padding: '0.5rem 1rem',
        },
        small: {
            fontSize: '0.75rem',
            padding: '0.25rem 0.5rem',
        },
    },
buttonScheme: {
    primary: {
        color: 'white',
        backgroundColor: 'midnightblue',
    },
    normal: {
        color: 'black',
        backgroundColor: 'lightgray',
    }
}
  • light 테마에 button, buttonScheme 지정

 

Button.tsx에서 Props 설정

interface Props {
  children: React.ReactNode;
  size: ButtonSize;
  scheme: ButtonScheme; // 기본적인 디자인 프리셋
  disabled?: boolean;
  isLoading?: boolean; // 버튼 여러번 클릭 안되도록
}

function Button({ children, size, scheme, 
  disabled, isLoading }: Props) {
  return (
    <ButtonStyle></ButtonStyle>
  )
}

 

태그에 Props 넘겨주기

function Button({ children, size, scheme, 
  disabled, isLoading }: Props) {
  return (
    <ButtonStyle size={size} scheme={scheme} 
      disabled={disabled} isLoading={isLoading}
    >
      {children}
    </ButtonStyle>
  );
}

 

ButtonStyle 작성

const ButtonStyle = styled.button<Omit<Props, "children">>`
    font-size: ${({theme, size}) => theme.button[size].fontSize};
    color: ${({theme, scheme}) => theme.buttonScheme[scheme].color};
    background-color: ${({theme, scheme}) => theme.buttonScheme[scheme].backgroundColor};
    border: 0;
    border-radius: ${({theme}) => theme.borderRadius.default};
    padding: ${({theme, size}) => theme.button[size].padding};
    opacity: ${({disabled}) => disabled ? 0.5 : 1};
    pointer-events: ${({disabled}) => disabled ? "none" : "auto"};
    cursor: ${({disabled}) => disabled ? "default" : "pointer"};
`;
  • styled.div => styled.button으로 변경

 

Home.tsx에 Button 추가

<>
<Title size="medium" color="background">
    제목 테스트
</Title>
<Button size="large" scheme="normal">버튼 테스트</Button>
    <div>home body</div>
</>

 

 

Button 컴포넌트 테스트 작성하기

 

Button.spec.tsx

import { render, screen } from "@testing-library/react";
import Button from "./Button";
import { BookStoreThemeProvider } from "../../context/ThemeContext";

describe("Button 컴포넌트 테스트", () => {
    it('렌더를 확인', () => {
        // 1. 렌더 : 가상화면 렌더됨
        // 적적한 props 넣어줘야 함
        render(
            <BookStoreThemeProvider>
                <Button size="large" scheme="primary">버튼</Button>
            </BookStoreThemeProvider>
        );

        // 2. 확인 : '버튼'이란 텍스트 화면상에 있는지 확인
        expect(screen.getByText("버튼")).toBeInTheDocument();
    });

    it('size props 적용', () => {
        const {container} = render(
            <BookStoreThemeProvider>
                <Button size="large" scheme="primary">버튼</Button>
            </BookStoreThemeProvider>
        );

        expect(screen.getByRole("button")).toHaveStyle({
            fontSize: "1.5rem"
        });
    });
})
  • size props 적용 검사할 때 screen.getByrole("button") 사용
    • getByrole : HTML의 role 속성에 따라 요소를 찾아내는 데 사용

input 컴포넌트 생성

forwardRef 방식으로 생성

interface Props {
    placeholder?: string;
}

// 포워드 ref 방식
const InputText = React.forwardRef(
    ({placeholder}: Props, ref:ForwardedRef<HTMLInputElement>) => {
        return (
            <InputTextStyle placeholder={placeholder} ref={ref} />
        )
})

 

2025.11.25 - [프레임워크\라이브러리/React] - [React] forwardRef란?

 

[React] forwardRef란?

forwardRef() 란?React 공식문서는, 일부 컴포넌트가 수신한 ref를 받아 조금 더 아래로 전달하는 Opt-in 기능이라고 소개한다.React 컴포넌트를 forwardRef() 메서드로 감싸면, 컴포넌트 함수는 추가적으로

thinktank911.tistory.com

 

 

Home컴포넌트에 추가

<InputText placeholder="여기에 입력하세요"/>    
        <div>home body</div>

 

 

InputText 컴포넌트 테스트 작성하기

 

InputText.spec.tsx

import { render, screen } from "@testing-library/react";
import InputText from "./InputText";
import { BookStoreThemeProvider } from "../../context/ThemeContext";

describe("InputText 컴포넌트 테스트", () => {
    it('렌더를 확인', () => {
        // 1. 렌더 : 가상화면 렌더됨
        // 적적한 props 넣어줘야 함
        render(
            <BookStoreThemeProvider>
                <InputText placeholder="여기에 입력" />
            </BookStoreThemeProvider>
        );

        // 2. 확인 : '여기에 입력'이란 플레이스홀더 텍스트 화면상에 있는지 확인
        expect(screen.getByPlaceholderText("여기에 입력")).toBeInTheDocument();
    });
})
  • getByPlaceholderText 활용

 

it('forwardRef 테스트', () => {
    const ref = React.createRef<HTMLInputElement>();
    render(
        <BookStoreThemeProvider>
            <InputText placeholder="여기에 입력" ref={ref} />
        </BookStoreThemeProvider>
    );

    // ref.current : ref는 항상 current로 액세스함
    expect(ref.current).toBeInstanceOf(HTMLInputElement);    
});
  • forwardRef 테스트 작성
    • createRef 로 ref 생성
    • ref.current : ref는 항상 current로 액세스함

헤더와 푸터

헤더와 푸터의 구성

헤더 만들기

Header.tsx

  • 로고 이미지 배치
  • 카테고리 배치 : CATEGORY 상수를 map으로 돌려 배치
  • 개인화 영역 배치
  • HeaderStyle 작성
import { styled } from "styled-components";
import logo from "../../assets/images/logo192.png";   

const CATEGORY = [
    {
        id: null,
        name: "전체"
    },
    {
        id: 0,
        name: "동화"
    },
    {
        id: 1,
        name: "소설"
    },
    {
        id: 2,
        name: "사회"
    },
]

function Header(){
    return (
        <HeaderStyle>
            <h1 className="logo">
                <img src={logo} alt="book store"/>
            </h1>
            {/* 카테고리 영역 */}
            <nav className="category">
                <ul>
                    {CATEGORY.map((item) => (
                        <li key={item.id}>
                            <a href={item.id === null ? `/books` : 
                                `/books?category_id=${item.id}`}>
                                {item.name}
                            </a>
                        </li>
                    ))}
                </ul>
            </nav>
            {/* 개인화 영역 */}
            <nav className="auth">
                <ul>
                    <li>
                        <a href="/login">로그인</a>
                    </li>
                    <li>
                        <a href="/signup">회원가입</a>
                    </li>
                </ul>
            </nav>
        </HeaderStyle>
    )
}

const HeaderStyle = styled.header`
    width: 100%;
    margin: 0 auto;
    max-width: ${({theme}) => theme.layout.width.large};
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 20px 0;
    border-bottom: 1px solid ${({theme}) => theme.color.background};

    .logo {
        img {
            width: 100px;
        }
    }

    .category {        
        ul {
            display: flex;
            gap: 32px;
            li {
                a {
                    font-size: 1.5rem;
                    font-weight: 600;
                    text-decoration: none;
                    color: ${({theme}) => theme.color.text};
                    &:hover {
                        color: ${({theme}) => theme.color.primary};
                    }
                }
            }
        }
    }

    .auth {
        ul {
            display: flex;
            gap: 16px;
            li {
                a {
                    font-size: 1rem;
                    font-weight: 600;
                    text-decoration: none;
                }
            }
        }
    }
`;

export default Header;

 

로그인 회원가입 아이콘 배치

  • react icons 모듈 설치 후 사용 : npm install react-icons --save
    [이슈]
    ERROR in src/components/common/Header.tsx:48:29 TS2786: 
    'FaSignInAlt' cannot be used as a JSX component. 
    Its return type 'ReactNode' is not a valid JSX element. 
    Type 'undefined' is not assignable to type 'Element | null'. 46 | <li> 47 | <a href="/login"> >
    48 | <FaSignInAlt/> | ^^^^^^^^^^^ 49 | 로그인 50 | </a> 
    51 | </li> ERROR in src/components/common/Header.tsx:54:30 
    TS2786: 'FaRegUser' cannot be used as a JSX component. 
    Its return type 'ReactNode' is not a valid JSX element. 
    52 | <li> 53 | <a href="/signup"> > 54 | <FaRegUser/> | 
    ^^^^^^^^^ 55 | 회원가입 56 | </a> 57 | </li>
  • 문제발생 : <FaSignInAlt> 와 <FaRegUser> 를 react-icons에서 호출하려고 하는데 해당 에러가 발생한다.
  • 원인파악 : react-icons와 React 타입 버전이 맞지 않을 때 발생
  • 해결방안
    1) react-icons 업데이트
    2) TypeScript와 React 타입 업데이트
npm install react-icons@latest

 

npm install @types/react@latest @types/react-dom@latest typescript@latest

 

 

푸터 만들기

  • 헤더와 거의 동일 
  • import styled from "styled-components"; import logo from "../../assets/images/logo192.png"; function Footer(){ return( <FooterStyle> <h1 className="logo"> <img src={logo} alt="book store"/> </h1> <div className="copyright"> <p> copyright(c), 2024, book store. </p> </div> </FooterStyle> ) } const FooterStyle = styled.footer` width: 100%; margin: 0 auto; max-width: ${({theme}) => theme.layout.width.large}; padding: 20px 0; border-top: 1px solid ${({theme}) => theme.color.background}; display: flex; justify-content: space-between; .logo { img { width: 140px; } } .copyright { p { font-size: 0.75rem; color: ${({theme}) => theme.color.text}; } } `; export default Footer;

 


바디 만들기

레이아웃에서 설정

interface LayoutProps {
    children: React.ReactNode;  // 리액트로 만든 모든 컴포넌트들 선언할 수 있다.
}

function Layout({children} : LayoutProps ){
    return (
        <>
        <Header />
        <LayoutStyle>
            {children}
        </LayoutStyle>
        <Footer />
        </>
    )
}

const LayoutStyle = styled.main`
    width: 100%;
    margin: 0 auto;
    max-width: ${({theme}) => theme.layout.width.large};
    padding: 20px 0;
`;

export default Layout;