프론트엔드/테스트

React 테스트 방법

정현우12 2023. 5. 7. 01:52

백엔드 서버 요청 처리 (MSW 활용)

실제로 서버에 요청하는 E2E 테스트를 할 수도 있지만,

백엔드보다 프론트엔드가 먼저 개발이 들어가거나 하는 경우나, 프론트만 분리해서 테스트 하고 싶은 경우에는 MSW를 활용할 수 있다.

서버 요청시 MSW가 가로채고 모의 응답을 보내준다.

MSW 초기 설정

테스트에서 요청들을 mock server가 가로채도록 설정해준다.

// src/mocks/server.js
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
// This configures a Service Worker with the given request handlers.
export const server = setupServer(...handlers)
// setupTests.js
require('@testing-library/jest-dom')

import { server } from '@/mocks/server.js'

import 'whatwg-fetch' // msw, jest 사용 환경에서 fetch 사용 시 fetch polyfill

beforeAll(() => server.listen())

afterEach(() => server.resetHandlers())

afterAll(() => server.close())

테스트

똑같이 테스트 코드 짜서 테스트하면 된다.

서버 데이터 페치 오류 시 동작 테스트

서버에 특정 데이터 페치 오류시에 화면상에서 오류메시지를 나타내는 것은 매우 많이 사용된다.

해당 동작을 MSW를 활용해서 테스트 코드로 테스트할 수 있다.

test('when get error fetching products, display errorMsg', async () => {
  // 오류 발생시킬 서버 데이터 페치 요청을 msw의 resetHandlers를 활용해서 임의로 오류를 발생시키도록 한다.
  server.resetHandlers(rest.get('http://localhost:5000/products', (req, res, ctx) => res(ctx.status(500)))) 

  render(<Type orderType="products" />)

  const errorBanner = await screen.findByText(/에러가 발생했습니다./i) // 에러 발생 메시지를 찾는다.

  expect(errorBanner).toHaveTextContent('에러가 발생했습니다. 새로고침해주세요.') // 에러 메시지가 정확히 표시되는지 확인.
})

서버 데이터 페치 로딩 시 동작 테스트

서버 데이터 페치후 해당 데이터를 화면에 보여주기까지 일정 시간이 걸린다.

보통 로딩 시간동안 로딩중임을 나타내는 spinner나 문구를 화면에 보여준다.

이러한 동작을 테스트할 때는 다음과 같은 방법을 사용한다.

// 1. 초기에 로딩 UI가 보여지는지 확인한다.
const loading = screen.getByText(/loading/i); 
expect(loading).toBeInTheDocument();

// 2. 로딩 완료시 완료 문구가 보여지는지 확인한다.
// findByRole은 제한시간 (default: 1초) 동안 요소를 계속 찾는다.
const completeHeader = await screen.findByRole("heading", {
    name: "주문이 성공했습니다.",
});
expect(completeHeader).toBeInTheDocument();

// 3. 로딩 완료시 로딩 UI가 사라졌는지 확인한다.
const loadingDisappeared = screen.queryByText("loading");
expect(loadingDisappeared).not.toBeInTheDocument();

폼 테스트

user-event를 활용해서 테스트한다. 주로 typeclear를 활용한다.

  • clear: 이전에 사용했을 경우 테스트 깔끔하게 하기 위해 초기화하는 용도
  • type: 사용자가 값 입력
test('update products total price when user type produt input', async () => {
  render(<Type orderType="products" />)

  const americaInput = await screen.findByLabelText('America') // input은 labelText나 placeholder로 찾자
  const englandInput = await screen.findByLabelText('England')
  userEvent.clear(americaInput) // input 창 값 없애기
  await userEvent.type(americaInput, '2') // input 창 입력

  userEvent.clear(englandInput)
  await userEvent.type(englandInput, '1')

  const productsTotal = screen.getByText('Products total:', { exact: false }) // input 입력에 따라 변경되는 부분 테스트

  expect(productsTotal).toHaveTextContent('3000')
})

Custom Render 활용 (Context, Redux, Recoil, React Router)

Custom Render를 활용해 Context, Redux, Recoil, React Router 사용시 테스트를 쉽게 할 수 있다.

예를 들어, 특정 컴포넌트에서 Context, Redux, Recoil, React Router 사용시에 테스트 코드에서도 해당 컴포넌트를 Context Provider, RecoilRoot, Provider, Router로 감싸줘야 한다.

// Context 사용시 예시
import { ContextProvider } from "Context"

test('context test~', () => {
    render(<Component/>, {wrapper: ContextProvider}) // 렌더시에 감싸준다.
})

여러 컴포넌트에서 Context, Redux, Recoil사용시에 테스트마다 일일이 감싸주는 것은 너무 번거롭다.

이런 경우 Custom Render를 만들어서 사용할 수 있다.

Redux 활용 시

// Redux 공식 문서 예시
// test-utils.tsx
function renderWithProviders(
  ui: React.ReactElement,
  {
    preloadedState = {},
    // Automatically create a store instance if no store was passed in
    store = setupStore(preloadedState),
    ...renderOptions
  }: ExtendedRenderOptions = {}
) {
  function Wrapper({ children }: PropsWithChildren<{}>): JSX.Element {
    return <Provider store={store}>{children}</Provider>
  }
  return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) }
}

// override render method
export {renderWithProviders as render}
/// store.ts
export const setupStore = (preloadedState?: PreloadedState<RootState>) => {
  return configureStore({
    reducer: rootReducer,
    preloadedState
  })
}

Recoil, Context 활용 시

// test-utils.tsx
import React from 'react'
import {render} from '@testing-library/react'
import { Provider } from 'react-redux'
import { RecoilRoot } from 'recoil'
import {ThemeProvider} from 'my-ui-lib'

const AllTheProviders = ({children}) => {
  return (
    <RecoilRoot>
      <ThemeProvider theme="light">
        {children}
      </ThemeProvider>
    </RecoilRoot>
  )
}

const customRender = (ui, options) =>
  render(ui, {wrapper: AllTheProviders, ...options})

// re-export everything
export * from '@testing-library/react'

// override render method
export {customRender as render}
- import { render, fireEvent } from '@testing-library/react';
+ import { render, fireEvent } from '../test-utils';

React Router 활용 시

// test-utils.tsx
const renderWithRouter = (ui, {route = '/'} = {}) => {
  window.history.pushState({}, 'Test page', route)

  return {
    user: userEvent.setup(),
    ...render(ui, {wrapper: BrowserRouter}),
  }
}

// override render method
export {renderWithRouter}
// app.test.js
test('full app rendering/navigating', async () => {
  const {user} = renderWithRouter(<App />)
  expect(screen.getByText(/you are home/i)).toBeInTheDocument()

  await user.click(screen.getByText(/about/i))

  expect(screen.getByText(/you are on the about page/i)).toBeInTheDocument()
})

test('landing on a bad page', () => {
  renderWithRouter(<App />, {route: '/something-that-does-not-match'})

  expect(screen.getByText(/no match/i)).toBeInTheDocument()
})

test('rendering a component that uses useLocation', () => {
  const route = '/some-route'
  renderWithRouter(<LocationDisplay />, {route})

  expect(screen.getByTestId('location-display')).toHaveTextContent(route)
})

사용자 드래그 동작 테스트

RTLuserEvent.pointer를 활용해서 드래그 동작을 테스트할 수 있다.

test('cell 드래그시 해당 영역 선택, 선택된 영역 회색으로 표시, 드래그 시작한 부분 선택 상태 col, row 헤더 강조 표시', async () => {
    render(<Sheet />)
    const startCell = screen.getByTestId('0-0')
    const endCell = screen.getByTestId('2-2')

    await userEvent.pointer([
      { keys: '[MouseLeft>]', target: startCell },
      { pointerName: 'mouse', target: endCell },
      { keys: '[/MouseLeft]' },
    ])

    const editInput = screen.getByRole('textbox')
    expect(editInput).toBeInTheDocument()
    const selectBox = screen.getByTestId('select-box')
    expect(selectBox).toBeInTheDocument()
    const selectArea = screen.getByTestId('select-area')
    expect(selectArea).toBeInTheDocument()
    const rowHeaderCells = [0, 1, 2].map(num => screen.getByTestId(`row-header-${num}`))
    const columnHeaderCells = [0, 1, 2].map(num => screen.getByTestId(`column-header-${num}`))
    rowHeaderCells.forEach(cell => {
      expect(cell).toHaveClass('active')
    })
    columnHeaderCells.forEach(cell => {
      expect(cell).toHaveClass('active')
    })
  })

함수, 객체 메소드 테스트

함수나 객체 메소드가 몇번 호출되었는지 테스트할 수 있다.
jest.fn() - 함수, jest.spyon() - 객체를 활용해서 함수를 mock하고 toHaveBeenCalled(), toHaveBeenCalledWIth() 등의 matcher를 활용하여 함수가 호출되었는지, 어떤 매개변수와 호출되었는지를 확인할 수 있다.
각 테스트가 종료될 때에 mock한 함수, 객체 메소드는 fn - mockReset, spyon - mockRestore를 활용해 정리를 해줘야한다.
일일이 mock한 친구들을 정리하기가 귀찮기 때문에 자동으로 정리하는 방법을 쓰는 것이 좋다.

함수 테스트 예시) 버튼 클릭시 onClick 호출 테스트

버튼, 탭 등 유저들이 클릭할 수 있는 컴포넌트의 경우
클릭시 정상적으로 onClick 콜백함수가 작동하는지 테스트할 필요가 있다.
userEvent.click과 jest.fn()을 활용해서 테스트할 수 있다.

test('클릭시 onClick함수가 호출된다', async () => {
  const onClick = jest.fn()
  render(<MemoizedButton title="메모버튼" onClick={onClick} />)
  const button = screen.getByRole('button', { name: /메모버튼/i })
  await userEvent.click(button)
  expect(onClick).toHaveBeenCalled()
  onClick.mockReset() // mock 정리
})

객체 메소드 테스트 예시) window.open 호출 테스트

함수 테스트와 spyOn 메소드를 활용하는 것만 다르고 똑같다

let mockWindowOpen: jest.SpyInstance

beforeEach(() => {
  mockWindowOpen = jest.spyOn(window, 'open')
})

afterEach(() => {
  cleanup()
  mockWindowOpen.mockRestore() // mock 정리
})

test('제작자 정보 드롭다운 깃허브 버튼 클릭시 깃허브 이동', async () => {
  render(<DocsBar />)
  const producerInfoButton = screen.getByRole('button', { name: /제작자: 정현우/i })
  await userEvent.click(producerInfoButton)
  const githubButton = screen.getByText('깃허브')
  await userEvent.click(githubButton)
  expect(mockWindowOpen).toHaveBeenCalledWith('https://github.com/fe-jhw')
})

이렇게 했을 때 테스트는 정상적으로 할 수 있지만 테스트 후에
error: not implemented: window.open 이런 에러문구가 뜬다.
이 에러는 JSDOM이 window.open을 실행하기 때문이다.
이 에러문구를 없애려면 jest.config.js에서 setupFilesAfterEnv로 설정해놓은 파일(보통 setupTests.js)에 해당 메소드를 mock 해주면 된다.

// JSDOM 이 open 메소드 실행하지 않게 하기
window.open = jest.fn()

자동으로 mock한 친구들 정리

beforeEach를 활용 - 해당 파일에 적용

beforeEach(() => {
  jest.restoreAllMocks()
})

config 설정으로 자동으로 정리할 수 있다. - 테스트 전체 적용

/// jest.config.js
`restoreMocks`: true

자주 발생하는 오류 해결

Not Wrapped in act 경고 해결하기

컴포넌트가 비동기 API 호출하거나, 렌더링되기 전에 테스트가 종료된다면 따로 act로 감싸줘야 해당 에러가 발생하지 않는다.

act(() => {
  //동작
})

이럴 때마다 일일이 감싸주는 것은 귀찮다.

이럴 때 리액트에서 렌더링 후에 일어나는 동작을 waitFor을 활용해서 기다려준다.

waitFor: 콜백안의 expect나 쿼리들이 끝날 때까지 기다린다.

ex) 페이지 이동 후 테스트 종료하지 않고, 해당 페이지에서 나타나는 UI 요소 쿼리로 확인 후 종료

// 페이지 이동
const nextPageButton = screen.getByRole("button", {name: "다음"})
userEvent.click(nextPageButton)

// 바로 종료하지 않고, 렌더링된 UI 확인
await waitFor(() => {
    screen.getByText("저는 다음페이지 입니다.", {exact: false})
})

scss import 에러 해결하기

babel-jest 설치후 configmoduleNameMapper에 설정을 추가한다.

/// jest.config.js
module.exports = {
  moduleNameMapper: {
    '^.+\\.(css|less|scss)$': 'babel-jest',
  },
}

앞으로 할 일

  • 배포시 단위 테스트, 통합 테스트 자동화 해보기