백엔드 서버 요청 처리 (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
를 활용해서 테스트한다. 주로 type
과 clear
를 활용한다.
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)
})
사용자 드래그 동작 테스트
RTL
의 userEvent.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
설치후 config
의 moduleNameMapper
에 설정을 추가한다.
/// jest.config.js
module.exports = {
moduleNameMapper: {
'^.+\\.(css|less|scss)$': 'babel-jest',
},
}
앞으로 할 일
- 배포시 단위 테스트, 통합 테스트 자동화 해보기
'프론트엔드 > 테스트' 카테고리의 다른 글
TDD (0) | 2023.05.07 |
---|---|
React 테스트 기초 (사용 툴, 환경 설정) (1) | 2023.05.07 |