프론트엔드 개발자의 기록 공간

React testing library + MSW 기본 사용법 본문

개발지식

React testing library + MSW 기본 사용법

[리우] 2022. 8. 15. 17:09

본 글은 cra react+typeScript 환경에서 테스팅을 사용하면서 기록하고 정리한 글입니다.

 

React Testing Library 

testing-libray를 사용하면 사용자 중심 방식으로 UI 구성 요소를 테스트 할 수 있습니다.

테스트가 소프트웨어 사용 방식과 비슷할수록 더 많은 자신감을 얻을 수 있습니다.

 

testing-libray는 행위 주도 테스트 방법론을 따르는 테스트를 작성하는데 매우 적합합니다.  jsdom이라는 라이브러리를 통해 실제 브라우저 DOM을 제공해주기 때문에 화면에 보여지는 실제 모습 그 상태로 테스트를 수행할 수 있게 해준다. 

jest는 자바스크립트 또는 타입스크립트 환경에서 테스트를 진행할 수 있게 해준다.

즉, 테스트 코드는 testing-libray로 작성하고 테스트를 실행하는 환경은 jest로 사용하기 때문에 둘다 필요하다.

 

환경설정

yarn create react-app my-app --template typescript

기본적으로 cra+ts로 설치하면 테스트에 필요한 react testing , jest 등의 라이브러리가 설치된다.

cra react 환경이라면 검색을 통해 별도로 설치해주면 된다.

 

테스트 파일은 각 컴포넌트와 동일한 폴더에 두어도 되고,

테스트 파일을 한 곳에서 관리하고 싶다면, test폴더를 두고 각 컴포넌트 테스팅을 진행합니다.

 

테스트 파일 global 설정

App에서 전역 설정 (emotiom, components styled, contextAPI 등)을 사용할 경우 테스트 파일에도 적용을 시켜준다.

//test-utils.tsx

import { ThemeProvider } from '@emotion/react'
import { render, RenderOptions } from '@testing-library/react'
import React, { ReactElement } from 'react'
import { theme } from 'utils/styles'

interface Props {
  children: React.ReactNode
}

export const CustomProvider = ({ children }: Props) => {
  return <ThemeProvider theme={theme}>{children}</ThemeProvider>
}

const customRender = (
  ui: ReactElement,
  options?: Omit<RenderOptions, 'wrapper'>,
) => render(ui, { wrapper: CustomProvider, ...options })

export * from '@testing-library/react'
export { customRender as render }

 

이렇게 전역에서 설정한 파일을 각각의 테스팅 파일에서 import로 사용하면 됩니다.

 

 

테스트 코드

버튼 컴포넌트 렌더링 테스팅 예시

// Button.test.tsx

import Button from 'components/atoms/Button'
import { render, screen, fireEvent } from './test-utils' // 전역에서 설정한 파일에서 import

describe('버튼 컴포넌트 테스트<Button />', () => {
  it('렌더링 테스트', () => {
    // 버튼 컴포넌트 렌더링
    render(<Button>버튼</Button>)
    // 버튼 text 내용을 가져옴
    const button = screen.getByText('버튼')
    // 화면에 '버튼' 내용이 렌더링 되었는가 테스팅
    expect(button).toBeInTheDocument()
  })
})

 

  1. render 메소드를 이용하여 컴포넌트를 렌더링 시켜준다.
  2. 필수적으로 내려줘야하는 props가 있다면 더미데이터 props 주입
  3. getByText or getBy등의 메소드를 이용하여 컴포넌트 내부에 존재하는 것을 아무거나 가져와서 잘 렌더링 되었는지 표시
  4. 여기에서는 render에서 chilrdren으로준 "버튼" 텍스트가 화면에 잘 보이는지 테스팅 한다.

 

사용자 액션 테스팅 예시

  it('클릭 이벤트 테스트', () => {
    // 가상 함수 정의
    const onClick = jest.fn()
    render(<Button onClick={onClick}>버튼</Button>)
    const button = screen.getByText('버튼')
    // 사용자 행동 이벤트
    fireEvent.click(button)
    // 클릭 후, 해당 정의한 함수가 1번 불렸는지 테스팅
    expect(onClick).toHaveBeenCalledTimes(1)
  })
  1. ex) 버튼 클릭 시 함수가 제대로 실행 되는지 테스트 한다고 가정
  2. 버튼 컴포넌트 렌더링 props로 원하는 이벤트 함수 정의 jest.fn() 키워드 이용
  3. 버튼 컴포넌트를 실행하기 위해 정보를 가져옴 screen.getByText('버튼')
  4. 이벤트 실행 메서드 fireEvent를 이용하여 원하는 동작 이벤트 발생
  5. 함수가 실행되었는지 확인 toHaveBeenCalledTimes 함수 호출되었는지 판단하는 메서드

 

그 외 테스트

  it('버튼 활성화 테스트', () => {
    const { rerender } = render(<Button disabled={true}>버튼</Button>)
    const button = screen.getByText('버튼')
    expect(button).toBeDisabled()

    // rerender를 통해 컴포넌트 재렌더링 후 테스팅
    rerender(<Button disabled={false}>버튼</Button>)
    expect(button).toBeEnabled()
  })

 

msw라이브러리를 이용한 API 데이터 모킹

msw

  • MSW(Mock Service Worker)는 서비스 워커(Service Worker)를 사용하여 네트워크 호출을 가로채는 API 모킹(mocking) 라이브러리입니다.
  • 백엔드 API인 척하면서 프론트엔드의 요청에 가짜 데이터를 응답해주는 것으로 볼 수 있습니다.
  • 애플리케이션 수준이 아닌 네트워크 수준에서 요청을 가로챕니다.
  • 동일한 모의 정의를 단위, 통합, E2E 테스트 및 디버깅에 재사용할 수 있습니다.

대표적으로 두가지 사례에서 많이 사용됩니다.

  • 백엔드 API 구현이 완료될 때까지 프론트엔드 팀에서 임시로 사용하기 위한 가짜 API로 활용
  • 테스트 코드 작성 시, 직접적인 네트워크 호출 대신 빠르고 안정적인 가짜 API 서버를 구축하기 위함

 

msw를 활용하여 api요청 후, 컴포넌트 렌더링 테스트 코드

yarn add msw

// 모의 api 요청 선언
const server = setupServer(
  // api 요청
  rest.get(`${API_END_POINT}categories`, (req, res, ctx) => {
    return res(ctx.status(200), ctx.json(categoriesData))
  }),
 // api 요청
  rest.get(`${API_END_POINT}menus/1`, (req, res, ctx) => {
    return res(ctx.status(200), ctx.json(menuData[0]))
  }),
)

// 테스트 전에 API 모킹을 사용하도록 설정합니다.
beforeAll(() => server.listen())
// 테스트 중에 추가할 수 있는 런타임 요청 처리기를 재설정합니다.
afterEach(() => server.resetHandlers())
//  테스트가 완료된 후 API 모킹을 비활성화합니다.
afterAll(() => server.close())

// 첫 번째 선언한 카테고리 api를 정상적으로 불러왔을 때 main-test id값을 가진 컴포넌트가 렌더링 된다
test('전체 테이터 패칭 테스트', async () => {
  render(<App></App>)
  await waitFor(() => {
    expect(screen.getByTestId('main-test')).toBeInTheDocument()
  })
})

// 메뉴 버튼을 눌렀을 때, 두 번째 선언한 메뉴 정보 api를 불러왔을 때 '1menuComponent' id값을 가진 컴포넌트가 렌더링 된다.
test('메뉴 디테일 정보 패칭 테스트', async () => {
  render(<Main menus={categoriesData[0].menus} />)

  const selectedMenu = screen.getByTestId('1menuComponent')
  fireEvent.click(selectedMenu)

  await waitFor(() => {
    expect(screen.getByText('수량')).toBeInTheDocument()
  })
})

 

참고자료

https://testing-library.com/docs/react-testing-library/example-intro/#mock

https://github.com/mswjs/msw

https://www.daleseo.com/react-testing-library/

 

그 외에 테스트 코드 작성한 사례

import MenuOption from 'components/molecules/MenuOption'
import { render, screen, fireEvent } from './test-utils'
import optionData from '../../../common/optionData.json'

const menuPrice = 10000

const component = (
  <MenuOption
    options={optionData}
    menuPrice={menuPrice}
    onClose={() => null}></MenuOption>
)

describe('메뉴 옵션 컴포넌트<MenuOption />', () => {
  it('렌더링 테스트', () => {
    render(component)
    const menuOption = screen.getByText('수량')
    expect(menuOption).toBeInTheDocument()
  })

  it('수량 카운트 MAX 테스트', () => {
    render(component)
    const plusButton = screen.getByTestId('plusButton')

    for (let i = 0; i < 15; i++) {
      fireEvent.click(plusButton)
    }
    // 10개 이상 수량 선택을 못하도록 정의하여 위에서 10번 이상 클릭해도
    // 수량에 변함없고 "최대 10 개"를 출력하는 테스팅
    expect(screen.getByText('최대 10 개')).toBeInTheDocument()
  })

  it('수량 카운트 MIN 테스트', () => {
    render(component)
    const minusButton = screen.getByTestId('minusButton')

    for (let i = 0; i < 20; i++) {
      fireEvent.click(minusButton)
    }
    // 최소 수량은 1개로 지정하여 20번 이상 감소 버튼을 눌러도
    // 최소 1개가 남아있는지 테스팅
    expect(screen.getByText('1 개')).toBeInTheDocument()
  })

  it('초기 상품 가격 테스트', () => {
    render(component)

    const optionPrice = optionData.reduce(
      (acc, cur) => acc + cur.details[0].price,
      0,
    )

	// 상품 선택 시, 초기 수량이 맞는지 테스트
    expect(
      screen.getByText(`${(optionPrice + menuPrice).toLocaleString()}원 담기`),
    ).toBeInTheDocument()
  })

  it('옵션 및 수량 선택에 따른 가격 테스트', () => {
    render(component)

    optionData.forEach(({ details }) => {
      const selectedOption = screen.getByTestId(
        `option-detail-${details[details.length - 1].id}`,
      )
      fireEvent.click(selectedOption)
    })

    const optionPrice = optionData.reduce(
      (acc, cur) => acc + cur.details[cur.details.length - 1].price,
      0,
    )

    const plusButton = screen.getByTestId('plusButton')
    const count = 4
    for (let i = 0; i < count - 1; i++) {
      fireEvent.click(plusButton)
    }
    
    // 옵션이 달라지거나, 수량이 달라질 때, 총 수량이 달라지는지 테스팅
    expect(
      screen.getByText(
        `${((optionPrice + menuPrice) * count).toLocaleString()}원 담기`,
      ),
    ).toBeInTheDocument()
  })
})

 

실제 코드 레포 -packages-client-src-test 폴더에 존재

https://github.com/minsu-zip/web-kiosk-parkminsu

 

어려운 점

사용자의 이벤트에 따라 상태가 바뀌고 재렌더링 되면서 함수 같은 것들이 다시 실행되고, 그래서 화면에 보여지는 부분도 다르게 된다.
테스트 코드는 이를 직접 해줘야 한다는 불편함과 어떻게 접근하고 확인할지 어려웠다. TS에 정의한 props도 맞춰주기위해 json객체를 불러와 넣어줬고, 액션 이벤트를 직접 하나하나 실행시키고, 컴포넌트 내부에 접근해 내가 예상한 결과값을 미리 계산하는 함수를 별도로 작성하여 (컴포넌트에서 사용 되는 함수라고 생각하면 되서 가져다 쓰면 된다.) 일치하는지 확인하는 과정이 어렵고 까다로웠다.

+ msw를 이용한 네트워크 테스팅은 학습 난이도가 살짝 높았다. 네트워크 후킹작업과 테스팅을 함께 하다보니, 비동기 문제, 사용법 문제에 삽질을 다소 오래 진행했다.

사용후기

내가 원하던 테스팅은 개발된 화면을 이리저리 클릭 할 때마다 내가 정의한 테스팅이 잘 동작하는지 테스트하는거였는데 사용자 액션도 전부 테스팅 함수 호출을 통해 실행시키고 판단했어야 했다. 아마 예전에 어디서 얼핏 듣기론 가능했던거 같은데 처음 테스팅해보는 입장으로서 매우 어렵게 느껴졌다.
그리고 테스팅을 할려면 컴포넌트 내부에 일일히 접근하여 액션을 실행시키고 내가 원하는 값이 텍스트로 잘 보이는지 확인해야한다. 컴포넌트를 다 만들고 나서 테스팅을 진행해서 그런지 까다로웠다. TDD에 대해서는 잘 모르지만 테스트를 쉽고 견고하게 가져가기 위해 테스트 주도 개발이라는 얘기가 나온지 얼핏 알 것 같았다.

728x90
Comments