기본 컴포넌트 작성
세 컴포넌트 모두 Home 컴포넌트에서 렌더링 된다.
function Home() {
return (
<>
<Title size='large' color='background'>
제목 테스트
</Title>
<Button size="large" scheme="normal">버튼 테스트</Button>
<InputText placeholder="여기에 입력하세요" />
<div>Home body</div>
</>
)
}
Title
컴포넌트
props로 children, size, color을 받는다. children은 h1 태그에 들어가고, size와 color는 스타일로 설정한다.
import { styled } from "styled-components";
import { ColorKey, HeadingSize } from "../../style/theme";
interface Props {
children: React.ReactNode;
size: HeadingSize;
color?: ColorKey;
}
function Title({ children, size, color }: Props) {
return (
<TitleStyle size={size} color={color}>
{children}
</TitleStyle>
)
}
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}
`;
export default Title;
테스트
최상위폴더/test/Title.test.tsx
import { render, screen } from "@testing-library/react";
import Title from "../src/components/common/Title";
import { BookStoreThemeProvider } from '../src/context/themeContext';
import '@testing-library/jest-dom';
describe("Title 컴포넌트 테스트", () => {
it("렌더를 확인", () => {
// 1. 렌더
render(
<BookStoreThemeProvider>
<Title size='large'>제목</Title>
</BookStoreThemeProvider>
);
// 2. 확인
expect(screen.getByText("제목")).toBeInTheDocument();
})
})
Button
컴포넌트
props로 받게 되는 scheme는 글자색과 배경색을 설정하는 데에 사용된다.
import { styled } from "styled-components";
import { ButtonScheme, ButtonSize } from "../../style/theme";
interface Props {
children: React.ReactNode;
size: ButtonSize;
scheme: ButtonScheme;
disabled?: boolean;
isLoading?: boolean;
}
function Button({ children, size, scheme, disabled, isLoading }: Props) {
return (
<ButtonStyle
size={size}
scheme={scheme}
disabled={disabled}
isLoading={isLoading}
>
{children}
</ButtonStyle>
);
}
const ButtonStyle = styled.button<Omit<Props, 'children'>>`
font-size: ${({ theme, size }) => theme.button[size].fontSize};
padding: ${({ 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};
opacity: ${({ disabled }) => (disabled ? 0.5 : 1)};
pointer-events: ${({ disabled }) => (disabled ? 'none' : 'auto')};
cursor: ${({ disabled }) => (disabled ? 'none' : 'pointer')}
`;
export default Button;
테스트
최상위폴더/test/Button.test.tsx
import { render, screen } from "@testing-library/react";
import Button from "../src/components/common/Button";
import { BookStoreThemeProvider } from '../src/context/themeContext';
import '@testing-library/jest-dom';
describe("Title 컴포넌트 테스트", () => {
it("렌더를 확인", () => {
// 1. 렌더
render(
<BookStoreThemeProvider>
<Button size='large' scheme="primary">버튼</Button>
</BookStoreThemeProvider>
);
// 2. 확인
expect(screen.getByText("버튼")).toBeInTheDocument();
})
it("렌더를 확인", () => {
// 1. 렌더
render(
<BookStoreThemeProvider>
<Button size='large' scheme="primary">버튼</Button>
</BookStoreThemeProvider>
);
// 2. 확인
expect(screen.getByRole('button')).toHaveStyle({
fontSize: '1.5rem'
});
})
})
InputText
컴포넌트
React.forwardRef()를 사용하면 npm run dev로 실행했을 때는 이상이 없는데 테스트에서 에러가 발생한다. 따라서 아래 코드처럼 react에서 구조분해로 꺼내와야 한다.
import { ForwardedRef, forwardRef } from 'react';
import styled from 'styled-components';
interface Props extends React.InputHTMLAttributes<HTMLInputElement> {
placeholder?: string;
inputType?: 'text' | 'email' | 'password' | 'number';
}
const InputText = forwardRef((
{ placeholder, inputType, onChange, ...props }: Props,
ref: ForwardedRef<HTMLInputElement>
) => {
return (
<InputTextStyled
type={inputType}
placeholder={placeholder}
ref={ref}
onChange={onChange}
{...props}
/>
);
})
const InputTextStyled = styled.input`
padding: 0.25rem 0.75rem;
border: 1px solid ${({ theme }) => theme.color.border};
border-radius: ${({ theme }) => theme.borderRadius.default};
font-size: 1rem;
line-height: 1.5;
color: ${({ theme }) => theme.color.text};
`;
export default InputText;
테스트
최상위폴더/test/InputText.test.tsx
import { render, screen } from "@testing-library/react";
import InputText from "../src/components/common/InputText";
import { BookStoreThemeProvider } from '../src/context/themeContext';
import '@testing-library/jest-dom';
import { createRef } from "react";
describe("InputText 컴포넌트 테스트", () => {
it("렌더를 확인", () => {
// 1. 렌더
render(
<BookStoreThemeProvider>
<InputText placeholder="여기에 입력" />
</BookStoreThemeProvider>
);
// 2. 확인
expect(screen.getByPlaceholderText("여기에 입력")).toBeInTheDocument();
});
it('forwardRef 테스트', () => {
const ref = createRef<HTMLInputElement>();
render(
<BookStoreThemeProvider>
<InputText placeholder="여기에 입력" ref={ref} />
</BookStoreThemeProvider>
);
expect(ref.current).toBeInstanceOf(HTMLInputElement);
})
})
라우터 작성
터미널에서 아래 명령을 실행하여 라우팅에 필요한 패키지를 설치한다.
npm install react-router-dom @types/react-router-dom --save
App.tsx를 다음과 같이 수정한다.
createBrowserRouter의 배열 안에 Object로 경로와 렌더링 할 컴포넌트를 작성한다.
import Layout from "./components/layout/Layout"
import Home from "./pages/Home"
import { BookStoreThemeProvider } from "./context/themeContext"
import { createBrowserRouter, RouterProvider } from "react-router-dom"
import Error from "./components/common/Error"
import Signup from "./pages/Signup"
const router = createBrowserRouter([
{
path: '/',
element: <Layout><Home /></Layout>,
errorElement: <Layout><Error /></Layout>
},
{
path: '/signup',
element: <Layout><Signup /></Layout>
},
{
path: '/books',
element: <Layout><div>도서 목록</div></Layout>
}
])
function App() {
return (
<>
<BookStoreThemeProvider>
<RouterProvider router={router} />
</BookStoreThemeProvider>
</>
)
}
export default App
이제 a 태그 대신에 Link 태그의 to에 경로를 넣어주면 화면이 전환될 때 깜빡임이 발생하지 않고 부드럽게 넘어간다.
모델 정의
다음 코드는 책 관련 API의 응답 모델이다. 카테고리, 회원, 장바구니 등에 대해 아래와 같이 모델을 정의한다.
파일 이름은 Book.model.ts와 같이 설정한다.
export interface Book {
id: number;
title: string;
img: number;
category_id: number;
form: string;
isbn: string;
summary: string;
detail: string;
author: string;
pages: number;
contents: string;
price: number;
likes: number;
pubDate: string;
}
export interface BookDetail extends Book {
categoryName: string;
liked: boolean;
}
API 통신
기본 설정
http 통신을 위한 axios 패키지를 설치한다.
npm i axios --save
http.ts 파일에 다음과 같은 코드를 작성한다.
BASE_URL은 가동되고 있는 서버의 주소를 적으면 된다.
import axios, { AxiosRequestConfig } from "axios";
const BASE_URL = 'http://localhost:3000';
const DEFAULT_TIMEOUT = 30000;
export const createClient = (config?: AxiosRequestConfig) => {
const axiosInstance = axios.create({
baseURL: BASE_URL,
timeout: DEFAULT_TIMEOUT,
headers: {
'Content-Type': 'application/json'
},
withCredentials: true,
...config
});
axiosInstance.interceptors.response.use((response) => {
return response;
}, (error) => {
return Promise.reject(error);
});
return axiosInstance;
}
export const httpClient = createClient();
카테고리
api/category.api.ts
카테고리 조회 API로 요청을 보내는 함수를 정의한 파일이다.
limit와 current_page가 입력되어야 해서 임시로 넣어두었다.
import { Category } from "../models/category.model";
import { httpClient } from "./http";
export const fetchCategory = async () => {
const response = await httpClient.get<Category[]>('/category?limit=4¤t_page=1');
return response.data;
}
hooks/useCategory.ts
위에서 정의한 함수를 불러서 훅으로 실제로 응답을 받아오는 코드이다.
import { useEffect, useState } from "react";
import { Category } from "../models/category.model";
import { fetchCategory } from "../api/category.api";
export const useCategory = () => {
const [category, setCategory] = useState<Category[]>([]);
useEffect(() => {
fetchCategory().then((category) => {
if (!category) return;
const categoryWithAll = [
{
category_id: null,
category_name: '전체'
},
...category
]
setCategory(categoryWithAll);
})
}, []);
return { category };
}
components/common/Header.tsx
훅을 불러와서 카테고리 배열을 받고, 화면에 렌더링 한다.
import { styled } from "styled-components";
import logo from '../../assets/images/logo.png';
import { FaSignInAlt, FaRegUser } from "react-icons/fa";
import { Link } from "react-router-dom";
import { useCategory } from "../../hooks/useCategory";
function Header() {
const { category } = useCategory();
return (
<HeaderStyle>
...
<nav className="category">
<ul>
{
category.map((item) => (
<li key={item.category_id}>
<Link to={item.category_id === null ? '/books' : `/books?category_id=${item.category_id}`}>
{item.category_name}
</Link>
</li>
))
}
</ul>
</nav>
...
</HeaderStyle>
);
}
const HeaderStyle = styled.header`
...
`;
export default Header;
회원가입
api/auth.api.ts
import { httpClient } from "./http";
import { SignupProps } from "../pages/Signup";
export const signup = async (userData: SignupProps) => {
const response = await httpClient.post('/users/join', userData);
return response.data;
}
hooks/useAlert.ts
메시지를 alert로 표시해 주는 역할을 하는 훅이다.
import { useCallback } from "react"
export const useAlert = () => {
const showAlert = useCallback((message: string) => {
window.alert(message);
}, []);
return showAlert;
}
pages/Signup.tsx
react hook form을 먼저 설치한다.
해당 패키지를 사용하면 폼을 효과적으로 관리할 수 있고, 유효성 검사도 할 수 있다.
npm i react-hook-form
import styled from "styled-components";
import Title from "../components/common/Title";
import InputText from "../components/common/InputText";
import Button from "../components/common/Button";
import { Link, useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form";
import { signup } from "../api/auth.api";
import { useAlert } from "../hooks/useAlert";
export interface SignupProps {
email: string;
password: string;
}
function Signup() {
const navigate = useNavigate();
const showAlert = useAlert();
const { register, handleSubmit, formState: { errors } } = useForm<SignupProps>();
const onSubmit = (data: SignupProps) => {
signup(data).then((res) => {
showAlert('회원가입 완료');
navigate('/login');
})
}
return (
<>
<Title size="large">회원가입</Title>
<SignupStyle>
<form onSubmit={handleSubmit(onSubmit)}>
<fieldset>
<InputText
placeholder="이메일"
inputType="email"
{...register('email', { required: true })}
/>
{
errors.email &&
<p className="error-text">이메일을 입력해주세요.</p>
}
</fieldset>
<fieldset>
<InputText
placeholder="비밀번호"
inputType="password"
{...register('password', { required: true })}
/>
{
errors.password &&
<p className="error-text">비밀번호를 입력해주세요.</p>
}
</fieldset>
<fieldset>
<Button type='submit' size="medium" scheme="primary">
회원가입
</Button>
</fieldset>
<div className="info">
<Link to='/reset'>비밀번호 초기화</Link>
</div>
</form>
</SignupStyle>
</>
)
}
const SignupStyle = styled.div`
...
`;
export default Signup;
발생했던 에러 정리
Vite에서 테스트 환경 구축하기
일단 위 링크에서 시키는 대로 한다.
터미널에 아래 명령어를 입력해서 테스트를 진행한다. Title 대신에 해당하는 테스트 파일의 이름을 입력하면 된다.
npm run test Title
Error: Test environment jest-environment-jsdom cannot be found. Make sure the testEnvironment configuration option points to an existing node module.
위와 같은 에러가 발생하면 다음 명령어를 실행한다.
npm install -D jest-environment-jsdom
'React'는 UMD 전역을 참조하지만 현재 파일은 모듈입니다.
위 에러 발생 시 해당 테스트 파일에 아래 코드를 추가해 준다.
import React from 'react';
위 코드 때문에 테스트에서 에러가 발생할 경우
tsconfig.json 파일의 include 부분을 다음과 같이 수정한다.
"include": [
"src",
"**/*.tsx"
]
'JestMatchers<HTMLElement>' 형식에 'toBeInTheDocument' 속성이 없습니다.
위 에러 발생 시 해당 테스트 파일에 아래 코드를 추가해 준다.
import '@testing-library/jest-dom';
CORS 오류
Access to XMLHttpRequest at '주소 A' from origin '주소 B' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
위 에러가 발생한 이유는 서버에서 cors 설정을 안 해줬기 때문이다.
npm i cors
라우팅 전에 아래 코드를 추가해 주면 해결된다.
const cors = require('cors');
app.use(cors({
origin: true,
credentials: true
}))
프론트 쪽 코드에서는 withCredentials가 true로 설정되어 있어야 한다.
배운 점
- vite는 @testing-library/react가 설정되어있지 않기 때문에 직접 설정해야 한다.
- CORS는 도메인이 다른 서버끼리 리소스를 주고받을 때 보안을 위해 설정된 정책이다.
- 리액트에서 라우팅을 하는 방법과 API 통신을 하는 방법에 대해 학습했다.
'데브코스' 카테고리의 다른 글
[14주차 - DAY5] 도서 정보 사이트 - 로그인과 도서 목록 페이지 (0) | 2024.05.31 |
---|---|
[14주차 주간 발표] CORS (0) | 2024.05.30 |
[14주차 - DAY3] 도서 정보 사이트 - 테마 스위치 (0) | 2024.05.29 |
[14주차 - DAY1] 오픈 소스(5) (0) | 2024.05.27 |
[13주차 - DAY5] 오픈 소스(4) (0) | 2024.05.24 |