Vite & React & Vitest & MSW &testing-library로 테스트 코드 작성하기

Megan·2023년 11월 24일
1
post-thumbnail

vite를 사용하면서 jest를 사용하려고 했으나
msw에서 에러가 발생해서 vitest를 사용하기로 결정했습니다.


패키지 설치

우선 필요한 패키지들을 모두 설치해주세요
저 같은 경우는 아래처럼 설치했는데 혹시 제가 빠트린게 있을 수도 있어서
빠진 부분은 확인해서 추가해주세요

yarn add --dev vitest @testing-library/jest-dom @testing-library/react @testing-library/user-event msw

MSW 설정

MSW를 사용하시려는 분들은 우선 공식 문서를 확인해주세요
저는 서비스워커로 데이터 모킹하는 것을 사용하지 않고
테스트를 위해서 msw를 사용하려고 합니다

api 개발이 완료되지 않아서 프론트 개발 중에 mock데이터를 사용하시려는 분들은 링크를 참고해주세요
MSW Browser

저처럼 테스트에서만(Node 환경) 사용하실 분들은 링크를 참고해주세요
MSW Node

전체적으로 카카오엔터 기술 블로그:MSW를 참고한 후
서버 셋업 부분은 공식문서를 참고해서 작성했습니다.
MSW setup-server


루트 폴더에서 vitest.setup.ts를 만든 다음 해당 설정을 추가 해주세요

//vitest.setup.ts
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from './src/mocks/server';

beforeAll(() => {
  server.listen({ onUnhandledRequest: 'error' });
});
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

루트 폴더에서 vitest.config.ts를 만든 다음 해당 설정을 추가 해주세요

//vitest.config.ts
import { mergeConfig } from 'vite';
import { defineConfig } from 'vitest/config';
import viteConfig from './vite.config.ts';

export default mergeConfig(
  viteConfig,
  defineConfig({
    test: {
      globals: true,
      environment: 'jsdom',
      setupFiles: ['./vitest.setup.ts'],
    },
  }),
);

테스트코드 작성하기

MSW 사용 안하고 테스트

우선 테스트 할 부분

  1. 키워드가 1번 클릭되면 키워드 액티브
  2. 키워드가 2번 클릭되면 키워트 선택 취소
  3. 키워드는 최대 3개까지 클릭 가능
  4. 플리 만들기 버튼을 누르면 선택된 키워드들이 쿼리 스트링에 담겨져서 네비게이팅

import Record from '@components/record/Record';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { withRouter } from '@tests/withRouter';
import { Route, useLocation } from 'react-router-dom';
import '@testing-library/jest-dom';

describe('Record', () => {
  
  //1. 키워드가 1번 클릭되면 키워드 액티브
  it('activate a tag when click once', async () => {
    
    //컴포넌트를 렌더링 한 다음
    render(withRouter(<Route path='/' element={<Record />} />));

    //tag 하나를 선택
    const tag = document.querySelector('.tag')!;

    //클릭이벤트를 발동한 다음
    await userEvent.click(tag);
    
    //선택된 태그에 active 클래스가 추가됐는지 확인
    expect(tag.classList.contains('active')).toBe(true);
  });

  
  //2. 키워드가 2번 클릭되면 키워트 선택 취소
  it('inactive a tag when click twice', async () => {
    render(withRouter(<Route path='/' element={<Record />} />));

    const tag = document.querySelector('.tag')!;

	//클릭이벤트를 두 번 발동한 다음
    await userEvent.click(tag);
    await userEvent.click(tag);
    
    //선택된 태그에 active 클래스가 없는 것을 확인
    expect(tag.classList.contains('active')).toBe(false);
  });

  
  //3. 키워드는 최대 3개까지 클릭 가능
  it('tags can not be selected more then 3', async () => {
    render(withRouter(<Route path='/' element={<Record />} />));
	
    
    //태그 리스트를 불러와서 
    const tags = document.querySelectorAll('.tag');

    //총 5개의 태그를 클릭
    await userEvent.click(tags[0]);
    await userEvent.click(tags[1]);
    await userEvent.click(tags[2]);
    await userEvent.click(tags[3]);
    await userEvent.click(tags[4]);

    //액티브된 태그들을 모두 불러온 다음
    const selectedTags = document.querySelectorAll('.tag.active');

    //태그들의 개수가 3개임을 확인
    expect(selectedTags.length).toBe(3);
  });

  
  //4. 플리 만들기 버튼을 누르면 선택된 키워드들이 쿼리 스트링에 담겨져서 네비게이팅
  it('navigate with selected tags query string', async () => {
    
    //네비게이트될 임시 컴포넌트를 하나 만듭니다
    //해당 컴포넌트는 받은 쿼리스트링 값을 보여줍니다
    function TmpRecordResult() {
      return <div>{JSON.stringify(useLocation().search)}</div>;
    }

    //컴포넌트 렌더링
    render(
      withRouter(
        <>
          <Route path='/' element={<Record />} />
          <Route path={`/result`} element={<TmpRecordResult />} />
        </>,
      ),
    );

    //태그들을 불러와서
    const tags = document.querySelectorAll('.tag');

    
    //태그 2개를 선택하고
    await userEvent.click(tags[0]);
    await userEvent.click(tags[1]);

    
    //플리 만들기 버튼 클릭
    const makePlaylistBtn = screen.getByText('🎵 플리 만들기');
    await userEvent.click(makePlaylistBtn);

    
    //네비게이팅 된 페이지에서 태그 id들이 보여짐을 확인
    expect(screen.getByText(`"?tags=${tags[0].id},${tags[1].id}"`))
      .toBeInTheDocument;
  });
});


MSW 사용하여 테스트

테스팅 할 부분
1. 그룹 태그를 넣으면 그룹에 해당하는 노래만 제공
2. 방구석콘서트 태그를 넣으면 방구석콘서트가 있는 노래만 제공
3. 일반 태그들을 넣으면 해당 태그가 하나라도 존재하는 노래들을 제공

우선 이 프로젝트는 백엔드 없이 json파일에서 음악 리스트를 불러와서 프론트단에서 로직을 처리를 하기 때문에
테스트에 사용하는 axios 인스턴스와 프로덕션에서 사용하는 axios 인스턴스를 분리했습니다.

테스트를 할 때는 가상의 baseURL을 지정한 axios 인스턴스를 사용했고
프로덕션에서는 별도의 baseURL을 가지지 않은 axios 인스턴스를 사용했습니다.

import { Playlist } from '@components/recordResult/libs/playlist';
import { PlaylistClient } from '@components/recordResult/api/playlistClient';
import { TagType } from '@components/recordResult/types/record.result.types';


describe('playlist lib', () => {
  
  it('그룹 태그를 넣으면 그룹에 해당하는 노래만 제공', async () => {
    
    //플레이리스트 인스턴스를 만들기 위해서
    //그룹 키워드와 
    //네트워크 로직이 들어있는 PlaylistClient(isTest:boolean=false) 인스턴스를 생성하여 주입해주었습니다
    //저처럼 json 파일이 아니라 api호출을 할 경우에는 따로 인스턴스를 주입하지 않아도 될 듯합니다.
    const playlist = new Playlist(
      new Set(['NCT DREAM']),
      new PlaylistClient(true),
    );
    
    //리스트를 만들고
    const list = await playlist.create();

    //리스트에 있는 모든 곡들의 아티스트가 NCT DREAM이 맞는 지 확인합니다
    expect(list.every((song) => song.artist === 'NCT DREAM')).toBe(true);
  });

  
  it('방구석콘서트 태그를 넣으면 해당 태그를 가지고 있는 노래만 제공', async () => {
    const playlist = new Playlist(
      new Set(['방구석콘서트']),
      new PlaylistClient(true),
    );
    const list = await playlist.create();

       //리스트에 있는 모든 곡들이 방구석콘서트 태그를 가지고 있는지 확인합니다
    expect(list.every((song) => song.tags.includes('방구석콘서트'))).toBe(true);
  });

  it('일반 태그들을 넣으면 해당 태그가 하나라도 존재하는 노래들을 제공', async () => {
    const tags: TagType[] = ['봄노래', '여름노래', '가을노래'];
    const playlist = new Playlist(new Set(tags), new PlaylistClient(true));
    const list = await playlist.create();

    //하나의 곡이 1개 이상의 태그를 가지고 있기 때문에 
    //선택한 태그가 하나라도 포함되어 있는지 확인합니다.
    expect(
      list.every((song) => {
        let check = false;
        for (let tag of song.tags) {
          if (tags.includes(tag)) {
            check = true;
            break;
          }
        }
        return check;
      }),
    ).toBe(true);
  });
});

참고를 위해 playlistClient.ts 파일도 추가합니다

//playlistClient.ts

import { client, testClient } from '@api/api';
import {
  ArtistType,
  ConcertSongListType,
  SongType,
} from '../types/record.result.types';
import { AxiosInstance } from 'axios';

export class PlaylistClient {
  client: AxiosInstance;

  constructor(isTest: boolean = false) {
    this.client = isTest ? testClient : client;
  }

  fetchSongs = async (
    artist: Exclude<ArtistType, '방구석콘서트'>,
  ): Promise<SongType[]> => {
    return await this.client
      .get(SongFile[artist])
      .then((data) => data.data['songList']);
  };

  fetchConcertSongs = async (): Promise<ConcertSongListType> => {
    return this.client.get(SongFile['방구석콘서트']).then((data) => data.data);
  };

  // 모든 파일 패치 (방구석 콘서트 제외)
  fetchAllData = async () => {
    const songFilesExceptConcert: Exclude<ArtistType, '방구석콘서트'>[] = [
      'NCT 127',
      'NCT U',
      'NCT DREAM',
      'WayV',
      'SOLO',
    ];

    return Promise.all(
      songFilesExceptConcert.map(
        async (artist) => await this.fetchSongs(artist),
      ),
    ).then((list) => list.flat(1));
  };
}

export const SongFile: { [group in ArtistType]: string } = {
  'NCT 127': '/assets/data/songs/nct127.json',
  'NCT U': '/assets/data/songs/nctU.json',
  'NCT DREAM': '/assets/data/songs/nctDream.json',
  WayV: '/assets/data/songs/wayV.json',
  SOLO: '/assets/data/songs/solo.json',
  방구석콘서트: '/assets/data/songs/concert.json',
};
profile
프론트엔드 개발자입니다

0개의 댓글