테스트 코드를 리액트에 적용해보자

 

 

PAZIZIC TV 프로젝트에서 비디오 카드 컴포넌트를 대상으로 테스트 코드를 작성해보기에 앞서 

테스트 환경을 알아보자.

 

1. 테스트 환경 설정

 "devDependencies": {
    "@testing-library/jest-dom": "^6.5.0",
    "@testing-library/react": "^13.4.0",
    "@testing-library/user-event": "^13.5.0",
    "react-test-renderer": "^18.3.1",
    "@types/jest": "^29.5.12",
  }

 

각 라이브러리의 역할, 사용할 수 잇는 메서드, 그리고 무엇을 테스트하기 위해 필요한지 알아보자.

 

1. @testing-library/jest-dom
설명:

- Jest의 기본 매처에 추가적인 DOM 관련 매처를 제공하고, 테스트에서 DOM 요소의 상태를 검사할 때 사용

 

사용 가능한 메서드:
- toBeInTheDocument(): 요소가 DOM에 존재하는지 확인
- toHaveTextContent(): 요소의 텍스트 내용이 특정 값과 일치하는지 확인
- toBeVisible(): 요소가 화면에 보이는지 확인

 

테스트 목적:

- UI 요소의 존재 여부, 텍스트 내용, 가시성 등을 검증할 수 있음.

 

2. @testing-library/react
설명:

- React 컴포넌트를 테스트하기 위한 라이브러리로, 컴포넌트를 렌더링하고 사용자 상호작용을 모사할 수 있는 API를 제공

 

사용 가능한 메서드:
- render(): 컴포넌트를 렌더링.
- screen.getByText(), screen.getByRole(), screen.getByTestId(): 특정 요소를 찾기 위한 쿼리 메서드.
- cleanup(): 테스트 후에 DOM을 정리.

 

테스트 목적: 

- 컴포넌트의 렌더링 결과, 요소의 존재 여부, 사용자 상호작용 등을 검증할 수 있음.

 

3. @testing-library/user-event
설명: 

- 실제 사용자 이벤트를 시뮬레이션하기 위한 라이브러리입니다. 사용자 행동을 더 자연스럽게 모사할 수 있음.

 

사용 가능한 메서드:
- userEvent.click(): 요소를 클릭.
- userEvent.type(): 입력 필드에 텍스트를 입력.
- userEvent.selectOptions(): 셀렉트 박스에서 옵션을 선택.

 

테스트 목적:

- 사용자 인터랙션을 테스트하고, 해당 인터랙션이 UI에 미치는 영향을 검증할 수 있음.

 

4. react-test-renderer
설명:

- React 컴포넌트를 렌더링하고, 그 결과를 스냅샷으로 저장하거나 테스트할 수 있는 라이브러리입니다. UI의 구조를 검사하는 데 유용.

 

사용 가능한 메서드:
- create(): 컴포넌트를 렌더링하고, 결과를 스냅샷으로 저장할 수 있음.
- toJSON(): 렌더링된 결과를 JSON 형태로 변환.

 

테스트 목적:

- 컴포넌트의 렌더링 결과가 예상과 일치하는지 확인하고, UI의 변화가 의도된 대로 이루어졌는지를 검증

 

5. @types/jest
설명: 

- Jest의 타입 정의를 TypeScript에 제공하여, Jest의 메서드에 대한 타입 체크와 자동 완성을 지원.

 

사용 가능한 메서드: 

- Jest의 모든 기본 매처(test, expect, describe 등)에 대한 타입 정보 제공.

 

테스트 목적: 

- TypeScript를 사용하는 경우, 타입 안정성을 높이고 코드 작성 시의 효율성을 향상.

 


 

# VideoCard.jsx

import React from 'react';
import { formatAgo } from '../util/date';
import { useNavigate } from 'react-router-dom';

export default function VideoCard({ video, type }) {
   const { title, thumbnails, channelTitle, publishedAt } = video.snippet;
   const navigate = useNavigate();
   const isList = type === 'list';
   return (
      <li
         className={isList ? 'flex gap-1 m-2' : ''}
         onClick={() => {
            navigate(`/videos/watch/${video.id}`, { state: { video } });
         }}
      >
         <img
            className={
               isList ? 'w-60 mr-2 cursor-pointer' : 'w-full cursor-pointer'
            }
            src={thumbnails.medium.url}
            alt={title}
         />
         <div>
            <p className='font-semibold my-2 line-clamp-2 cursor-pointer'>
               {title}
            </p>
            <p className='text-sm opacity-80'>{channelTitle}</p>
            <p className='text-sm opacity-80'>{formatAgo(publishedAt, 'ko')}</p>
         </div>
      </li>
   );
}

 

위의 비디오카드 컴포넌트는 video, type 데이터를 전달받고, 그 데이터를 li태그에서 보여주고 나서 전달받은 prop을 적적하게 보여주는 역할을 한다. 기능이라면 클릭 시 특정한 경로로 데이터를 가지고 이동하도록 하는 기능이 있다. 

 

1. 렌더링 테스트

- 기본 렌더링 : 컴포넌트가 주어진 props에 따라 올바르게 렌더링되는지를 확인한다.

- type prop에 따라 클래스가 올바르게 적용되는지를 검증한다.

 

2. 요소 존재 여부 확인

- 타이틀, 채널명, 게시 날짜 : 컴포넌트가 각각의 요소를 올바르게 표시하는지 확인한다.

- 썸네일 이미지가 올바르게 렌더링되는지 확인한다.

 

3. 사용자 상호작용 테스트

- 클릭 이벤트 : 리스트 항목을 클릭했을 때 navigate 함수가 호출되는지를 검증한다. 이때, 올바른 경로와 상태가 잘 전달되는지 확인한다 

 

4. 비주얼 테스트 (선택)

- 스냅샷 테스트 : 컴포넌트의 렌더링 결과를 스냅샷으로 저장하고, 이후 변화가 의도된 대로 이루어졌는지를 확인한다.

 

위 3가지를 중점적으로 테스트하면 될 것 같다.

 


 

우선

 

1. 렌더링 테스트

import { render } from '@testing-library/react';
import VideoCard from '../VideoCard';

const video = {
  id: 1,
  snippet: {
    title: 'title',
    channelId: '1',
    channelTitle: 'channelTitle',
    publishedAt: new Date(),
    thumbnails: {
      medium: {
        url: 'http://image/',
      },
    },
  },
};

describe('VideoCard', () => {
  it('renders vieo item', () => {
    render(<VideoCard video={video} />);
  });
});

위의 테스트 코드로 테스트를 돌려보면, 

 

useNavigate() may be used only in the context of a <Router> component.

 

와 같은 테스트 실패 메시지가 뜨는데, 리액트 라우터를 사용하는 컴포넌트를 만들 때는 리액트 라우터 환경을 만들어줘야 한다...

 

2. 요소 존재 여부 확인

import { fireEvent, render, screen } from '@testing-library/react';
import VideoCard from '../VideoCard';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { formatAgo } from '../../util/date';

const video = {
  id: 1,
  snippet: {
    title: 'title',
    channelId: '1',
    channelTitle: 'channelTitle',
    publishedAt: new Date(),
    thumbnails: {
      medium: {
        url: 'http://image/',
      },
    },
  },
};
const { title, thumbnails, channelTitle, publishedAt } = video.snippet;

describe('VideoCard', () => {
  it('renders vieo item', () => {
    render(
      <MemoryRouter>
        <VideoCard video={video} />
      </MemoryRouter>
    );

    // 요소 존재 여부 확인
    const image = screen.getByRole('img');
    // expect(image.src).toBe(thumbnails.medium.url);
    // expect(image.alt).toBe(title);
    expect(image).toHaveAttribute('src', thumbnails.medium.url);
    expect(image).toHaveAttribute('alt', title);
    expect(screen.getByText(title)).toBeInTheDocument();
    expect(screen.getByText(channelTitle)).toBeInTheDocument();
    expect(screen.getByText(formatAgo(publishedAt, 'ko'))).toBeInTheDocument();
  });
});

 

3. 사용자 상호작용 테스트

// onClick event test
  it('navigates to detailed video page with vido state when clicked', () => {
    function LocationStateDisplay() {
      return <pre>{JSON.stringify(useLocation().state)}</pre>;
    }

    render(
      <MemoryRouter initialEntries={['/']}>
        <Routes>
          <Route path='/' element={<VideoCard video={video} />} />
          <Route
            path={`/videos/watch/${video.id}`}
            element={<LocationStateDisplay />}
          />
        </Routes>
      </MemoryRouter>
    );

    const card = screen.getByRole('listitem');
    fireEvent.click(card);

    expect(screen.getByText(JSON.stringify({ video }))).toBeInTheDocument();
  });

 

 


 

# Import Statements

import { fireEvent, render, screen } from '@testing-library/react';
import VideoCard from '../VideoCard';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { formatAgo } from '../../util/date';

 

- fireEvent : 사용자 이벤트(클릭, 입력 등)를 시뮬레이션하기 위한 메서드

- render : 컴포넌트를 렌더링하여 테스트 환경을 추가

- screen :  렌더링된 컴포넌트에서 요소를 찾기 위한 유틸리티 메서드

- MemoryRouter : 라우팅을 테스트하기 위한 메모리 기반의 라우터이며, react-router-dom을 사용한 컴포넌틀 렌더링하기 위해 필요

- Route : 특정 경로에 해당하는 컴포넌트를 렌더링하기 위한 컴포넌트

- Routes : 여러 Route를 감싸고, 현재 URL에 맞는 Route를 선택

- useLocation : 현재 URL과 관련된 정보를 가져오는 React Router 훅

 

# Render the Component with Routes

render(
  <MemoryRouter initialEntries={['/']}>
    <Routes>
      <Route path='/' element={<VideoCard video={video} />} />
      <Route
        path={`/videos/watch/${video.id}`}
        element={<LocationStateDisplay />}
      />
    </Routes>
  </MemoryRouter>
);

- initialEntries : 메모리 라우터의 초기 경로를 설정

- Routes : 두 개의 경로를 정의한다. 첫 번째는 비디오 카드를 렌더링하고, 두 번째는 비디오 상세 페이지를 렌더링한다.

 

# Click Event Simulation

const card = screen.getByRole('listitem');
fireEvent.click(card);

- getByRole : 역할이 'listitem'인 요소 (비디오 카드)를 찾음

- fireEvent.click : 클릭 이벤트를 시뮬레이션하여 비디오 카드를 클릭

 

# State Check

expect(screen.getByText(JSON.stringify({ video }))).toBeInTheDocument();

- 클릭 후 이동된 페이지에서 상태가 올바르게 전달되었는지를 확인한다. 여기서는 비디오 데이터가 JSON 문자열로 변환된 형태가 문서에 존재하는지를 확인한다.

 

 

4. navigate 메서드 테스트

 

첫 번째 방법

it('navigates to detailed video page with video state when clicked', () => {
  function LocationStateDisplay() {
    return <pre>{JSON.stringify(useLocation().state)}</pre>;
  }

  render(
    withRouter(
      <>
        <Route path='/' element={<VideoCard video={video} />} />
        <Route
          path={`/videos/watch/${video.id}`}
          element={<LocationStateDisplay />}
        />
      </>
    )
  );

  const card = screen.getByRole('listitem');
  fireEvent.click(card);

  expect(screen.getByText(JSON.stringify({ video }))).toBeInTheDocument();
});

 

- 작성 의도 및 설명

 

# 목적 : 이 테스트는 VideoCard 컴포넌트를 클릭했을 때, 올바른 비디오 상태와 함께 상세 비디오 페이지로 이동하는지를 확인한다.

 

# LocationStateDisplay 함수 : 이 함수는 현재 위치의 상태를 보여주는 컴포넌트이다. 클릭 후에 상태가 올바르게 전달되었는지 확인하기 위해 사용됨

 

# render 함수 : VideoCard와 상세 페이지를 렌더링하여 서로 연결된 경로를 설정한다.

 

# fireEvent.click(card) : 실제로 카드 요소를 클릭하는 동작을 시뮬레이션한다.

 

# expect 문 : 클릭 후 상세 페이지에 비디오 상태가 올바르게 표시되는지 확인한다.

 

두 번째 방법

jest.mock('react-router-dom', () => ({
  ...jest.requireActual('react-router-dom'),
  useNavigate: jest.fn(),
}));

describe('비디오 카드 테스트할거다.', () => {
  it('li 요소의 클릭 기능 테스트', () => {
    const mockNavigate = jest.fn(); // navigate 함수를 mock으로 선언
    useNavigate.mockReturnValue(mockNavigate); // mockNavigate를 반환하도록 설정

    render(
      <MemoryRouter>
        <VideoCard video={video} />
      </MemoryRouter>
    );

    const card = screen.getByRole('listitem');
    fireEvent.click(card);

    // navigate 함수가 올바르게 호출되었는지, 그리고 전달된 state 확인
    expect(mockNavigate).toHaveBeenCalledWith(`/videos/watch/${video.id}`, {
      state: { video },
    });
  });
});

 

- 작성 의도 및 설명

 

# 목적 : 이 테스트는 클릭했을 때 useNavigate가 올바르게 호출되는지를 확인한다. 즉, 비디오 상세 페이지로 이동하는 기능이 잘 작동하는지를 테스트한다.

 

# jest.mock : react-router-dom의 useNavigate를 모킹하여 실제 함수를 사용하지 않고도 테스트를 진행할 수 있도록 한다.

 

# mockNavigate :  모킹된 navigate 함수로, 이 함수가 호출되는지 여부를 확인할 수 있다.

 

# MemoryRouter : 라우팅을 위한 메모리 기반 라우터를 사용하여 테스트 환경을 설정한다.

 

# expect 문 : 클릭 후 mockNavigate가 올바른 인자와 함께 호출되었는지를 검증한다.

 

 

두 방법의 차이점

1. 테스트 초점 

- 첫 번째 방법 : 비디오 카드 클릭 후 실제로 상태가 상세 페이지에 잘 전달되는지를 확인한다. 즉, 결과를 확인하는 테스트이다.

 

- 두 번째 방법 : 클릭했을 때 useNavigate가 올바르게 호출되는지를 확인한다. 즉, 함수 호출을 확인하는 테스트이다.

 

2. 구현 방식

- 첫 번째 방법 :  실제 라우팅을 시뮬레이션하여 결과를 확인

 

- 두 번째 방법 : useNavigate를 모킹하여 함수 호출을 추적한다. 실제 라우팅을 사용하지 않으므로 더 빠르고 간단한 테스트이다.

 

첫 번째 방법은 결과를 확인하는 테스트에 중점을 두고, 두 번째 방법은 함수 호출을 확인하는 테스트에 중점을 두고 있다. 

 


 

# 좀 더 자세하게 분석하기

jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
}));

 

1. jest.mock(...)

- jest.mock은 Jest 테스트 프레임워크에서 특정 모듈(여기서는 react-router-dom)을 모킹하는 함수이다.

 

2. 'react-router-dom'

- 모킹할 모듈의 이름이다. 여기서는 리액트 애플리케이션에서 라우팅 기능을 제공하는 라이브러리이다.

 

3. { ...jest.requireActual ( 'react-router-dom' ) }

- jest.requireActual은 실제 react-router-dom 모듈의 모든 기능을 가져오는 함수이다. 이 기능들을 그대로 사용하고, ,그 위에 추가적인 변경을 할 수 있다.

 

4. useNavigate : jest.fn()

- useNavigate는 react-router-dom에서 제공하는 라우팅 기능 중 하나이다. 여기서는 이 함수를 모킹하여 jest.fn()으로 바꿔준다. jest.fn()은 Jest에서 제공하는, 호출된 횟수나 인자를 추적할 수 있는 가짜 함수이다.

 

- 이 코드는 react-router-dom이라는 라이브러리를 모킹하여, 실제 라우팅 기능을 사용하지 않고도 useNavigate 함수의 호출을 추적할 수 있게 해준다. 이렇게 하면 테스트를 더 쉽게 하고, 실제로 페이지를 전환하지 않고도 원하는 기능이 잘 작동하는지를 확인할 수 있다.

 

# VideoCard.test.js

import { fireEvent, render, screen } from '@testing-library/react';
import VideoCard from '../VideoCard';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { formatAgo } from '../../util/date';
import { fakevideo as video } from '../../testS/videos';
import { withRouter } from '../../tests/utils';
import renderer from 'react-test-renderer';

const { title, thumbnails, channelTitle, publishedAt } = video.snippet;

describe('VideoCard', () => {
  it('renders vieo item', () => {
    render(
      withRouter(<Route path='/' element={<VideoCard video={video} />} />)
    );

    // 요소 존재 여부 확인
    const image = screen.getByRole('img');
    // expect(image.src).toBe(thumbnails.medium.url);
    // expect(image.alt).toBe(title);
    expect(image).toHaveAttribute('src', thumbnails.medium.url);
    expect(image).toHaveAttribute('alt', title);
    expect(screen.getByText(title)).toBeInTheDocument();
    expect(screen.getByText(channelTitle)).toBeInTheDocument();
    expect(screen.getByText(formatAgo(publishedAt, 'ko'))).toBeInTheDocument();
  });

  // onClick event test
  it('navigates to detailed video page with vido state when clicked', () => {
    function LocationStateDisplay() {
      return <pre>{JSON.stringify(useLocation().state)}</pre>;
    }

    render(
      withRouter(
        <>
          <Route path='/' element={<VideoCard video={video} />} />
          <Route
            path={`/videos/watch/${video.id}`}
            element={<LocationStateDisplay />}
          />
        </>
      )
    );

    const card = screen.getByRole('listitem');
    fireEvent.click(card);

    expect(screen.getByText(JSON.stringify({ video }))).toBeInTheDocument();
  });

  // type tests
  it('applies correct class when type is "list"', () => {
    render(
      withRouter(
        <Route path='/' element={<VideoCard video={video} type='list' />} />
      )
    );

    const listItem = screen.getByRole('listitem');
    expect(listItem).toHaveClass('flex gap-1 m-2'); // 'list' 타입일 때 클래스 확인
  });

  it('applies correct class when type is not "list"', () => {
    render(
      withRouter(
        <Route path='/' element={<VideoCard video={video} type='grid' />} />
      )
    );

    const listItem = screen.getByRole('listitem');
    expect(listItem).not.toHaveClass('flex gap-1 m-2'); // 'list'가 아닐 때 클래스 확인
  });

  // snapshot test
  it('renders grid type correctly', () => {
    const component = renderer.create(
      withRouter(<Route path='/' element={<VideoCard video={video} />} />)
    );
    expect(component.toJSON()).toMatchSnapshot();
  });

  it('renders list type correctly', () => {
    const component = renderer.create(
      withRouter(
        <Route path='/' element={<VideoCard video={video} type='list' />} />
      )
    );
    expect(component.toJSON()).toMatchSnapshot();
  });
});