리액트 TDD - 2. 비동기, 리덕스

jonyChoiGenius·2023년 2월 21일
0

React.js 치트 시트

목록 보기
14/22

비동기 처리

기본적으로 findBy, findAllBy라는 쿼리도 4.5초 동안 대기하기 때문에 비동기 처리의 테스트에 사용될 수 있다.

비동기 처리의 테스트는 테스팅 라이브러리의 Async Method를 통해 이루어질 수 있다.

waitFor

function waitFor<T>(
  callback: () => T | Promise<T>,
  options?: {
    container?: HTMLElement
    timeout?: number
    interval?: number
    onTimeout?: (error: Error) => Error
    mutationObserverOptions?: MutationObserverInit
  },
): Promise<T>

나타날 때까지 기다리기

test('movie title appears', async () => {
  const movie = await findByText('the lion king')
})
test('movie title appears', async () => {
  await waitFor(() => {
    expect(getByText('the lion king')).toBeInTheDocument()
  })
})

변화를 탐지하기

  it('toggles text ON/OFF', async () => {
    const { getByText } = render(<DelayedToggle />);
    const toggleButton = getByText('토글');
    fireEvent.click(toggleButton);
    const text = await waitForElement(() => getByText('ON'));
    expect(text).toHaveTextContent('ON');
  });

테스트가 실패했을 때 콜백을 다시 실행하도록 작성할 수 있다.

// Wait until the callback does not throw an error. In this case, that means
// it'll wait until the mock function has been called once.
await waitFor(() => expect(mockAPI).toHaveBeenCalledTimes(1))

waitForElementToBeRemoved

요소의 삭제를 탐지할 수 있다.

function waitForElementToBeRemoved<T>(
  callback: (() => T) | T,
  options?: {
    container?: HTMLElement
    timeout?: number
    interval?: number
    onTimeout?: (error: Error) => Error
    mutationObserverOptions?: MutationObserverInit
  },
): Promise<void>

axios-mock-adapter

yarn add axios-mock-adapter

import React from 'react';
import { render, waitForElement } from '@testing-library/react';
import UserProfile from './UserProfile';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
const mock = new MockAdapter(axios, { delayResponse: 200 });

mock.onGet('https://jsonplaceholder.typicode.com/users/1').reply(200, {
    id: 1,
    name: 'Leanne Graham',
    username: 'Bret',
    email: 'Sincere@april.biz',
    address: {
      street: 'Kulas Light',
      suite: 'Apt. 556',
      city: 'Gwenborough',
      zipcode: '92998-3874',
      geo: {
        lat: '-37.3159',
        lng: '81.1496'
      }
    },
    phone: '1-770-736-8031 x56442',
    website: 'hildegard.org',
    company: {
      name: 'Romaguera-Crona',
      catchPhrase: 'Multi-layered client-server neural-net',
      bs: 'harness real-time e-markets'
    }
  });

위와 같이 목업을 만들고

  it('calls getUser API loads userData properly', async () => {
    const { getByText } = render(<UserProfile id={1} />);
    await waitForElement(() => getByText('로딩중..')); // 로딩중.. 문구 보여줘야함
    await waitForElement(() => getByText('Bret')); // Bret (username) 을 보여줘야함
  });

위와 같이 테스트 할 수 있다.

mock.onGet().reply(200, {username: 'Bret'})에 따라 '로딩중..' -> 'bret'로 변화하는지를 테스트할 수 있다.

mock.onGet('/users').replyOnce(200, users);

replyOnce는 한 번만 다른 요청을 보낼 수 있다. 이후에는 reply에서 설정한 값이 간다.

mock
  .onGet('/users')
  .replyOnce(200, users) // 첫번째 요청
  .onGet('/users')
  .replyOnce(500); // 두번째 요청

위와 같은 방식으로 reply의 delay 간격으로 여러번 요청을 보내는 목업을 만들 수 있다.

mock.reset(); : mock에 설정된 핸들러를 모두 없엔다. 테스트 케이스별로 mock을 다르게 설정한다.
mock.restore(); : axios로 복원한다. 목업 api를 없애 사이드 이펙트를 없애는 역할을 한다.

리덕스 툴킷에서 TDD

리덕스 - Writing Tests

리액트 훅에 타입을 선언하기 위해 아래와 같이 hooks를 정의한다.

/store

import {
  combineReducers,
  configureStore,
  PreloadedState
} from '@reduxjs/toolkit'
import userReducer from '../features/users/userSlice'
// Create the root reducer independently to obtain the RootState type
const rootReducer = combineReducers({
  user: userReducer
})
export function setupStore(preloadedState?: PreloadedState<RootState>) {
  return configureStore({
    reducer: rootReducer,
    preloadedState
  })
}
export type RootState = ReturnType<typeof rootReducer>
export type AppStore = ReturnType<typeof setupStore>
export type AppDispatch = AppStore['dispatch']

/hooks

import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import type { RootState, AppDispatch } from './store'
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector

테스트할 컴포넌트

import React from 'react'
import { useAppDispatch, useAppSelector } from '../../app/hooks'
import { fetchUser, selectUserName, selectUserFetchStatus } from './userSlice'

export default function UserDisplay() {
  const dispatch = useAppDispatch()
  const userName = useAppSelector(selectUserName)
  const userFetchStatus = useAppSelector(selectUserFetchStatus)

  return (
    <div>
      {/* Display the current user name */}
      <div>{userName}</div>
      {/* On button click, dispatch a thunk action to fetch a user */}
      <button onClick={() => dispatch(fetchUser())}>Fetch user</button>
      {/* At any point if we're fetching a user, display that on the UI */}
      {userFetchStatus === 'loading' && <div>Fetching user...</div>}
    </div>
  )
}

버튼을 클릭하면 액션이 실행되고, 이에 따라 selector가 참조하는 스토어 값이 변화한다.

테스트용 스토어와 provider 만들기

React Testing Library에서는 렌터 트리가 적용된다. 그리고 실제 환경과 마찬가지로 별도의 store와 이를 적용하는 provider가 필요하다.

react-testing-library의 custom render도 참조할 것.

아래의 react-testing-library의 예시를 먼저 보자

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

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

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

// override render method
export {customRender as render}

기본적으로 렌더 함수는 ui와 options를 가진다.
여기에 wrapper를 직접 지정해주면,
children이 wrapper 안으로 들어와서 렌더링 되는 커스텀 렌더를 만들 수 있다.

여기에 renderWithProviders(컴포넌트) 함수를 정의하여, 컴포넌트의 store를 인자로 받아 provider로 wrapping한다.

import React from 'react'
import { render } from '@testing-library/react'
import { configureStore } from '@reduxjs/toolkit'
import { Provider } from 'react-redux'
// As a basic setup, import your same slice reducers
import userReducer from '../features/users/userSlice'

export function renderWithProviders(
  ui,
  {
    preloadedState = {},
    store = configureStore({ reducer: { user: userReducer }, preloadedState }),
    ...renderOptions
  } = {}
) {
  function Wrapper({ children }) {
    return (
      <Provider store={store}>
        {children}
      </Provider>
      )
  }

  // Return an object with the store and all of RTL's query functions
  return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) }
}

구조가 다소 복잡해보이지만,

renderWithProviders로 컴포넌트를 호출하면,
해당 컴포넌트로부터 초기 스테이트(preloadedState) 혹은 현재 스테이트(store)를 반환받아 store를 구성하고

<Provider store={store}>
  컴포넌트
</Provider>

형태의 wrapper를 만들어준다.

초기 테스트 예시

test('Uses preloaded state to render', () => {
  const initialTodos = [{ id: 5, text: 'Buy Milk', completed: false }]

  const { getByText } = renderWithProviders(<TodoList />, {
    preloadedState: {
      todos: initialTodos
    }
  })
})

preloadedState에 초기 상태를 넣어주어 렌더링되도록 하는 예시이다.

test('Sets up initial state state with actions', () => {
  const store = setupStore()
  store.dispatch(todoAdded('Buy milk'))

  const { getByText } = renderWithProviders(<TodoList />, { store })
})

이번에는 직접 store에 dispatch하여 스토어를 구성한 후 테스트하는 예시이다.

유닛 단위 테스트

import reducer, { todoAdded, Todo } from './todosSlice'

test('should handle a todo being added to an existing list', () => {
  const previousState: Todo[] = [
    { text: 'Run the tests', completed: true, id: 0 }
  ]

  expect(reducer(previousState, todoAdded('Use Redux'))).toEqual([
    { text: 'Run the tests', completed: true, id: 0 },
    { text: 'Use Redux', completed: false, id: 1 }
  ])
})

특정 액션(todoAdded)이 일어났을 때, reducer의 상태가 변화했는지를 확인하는 테스트이다.

통합 테스트

앞서 언급했듯, redux-testing-library는 기본적으로 state 등에 대한 접근을 피하고, 렌더링이 되는 결과에 관심사를 둔다.

아래의 예시는 state의 상태에 대한 접근이 아닌 dom의 렌더링 결과에 집중하는 예시이다.

Mock Service Worker

아래 예시를 이해하기 전에 MSW의 사용법을 익혀야 한다. 멋쟁이 토마토처럼 - Mock Service Worker 기본적 사용법 및 TypeScript 적용하기, 넥스트(리액트)에 Mock Service Worker 등록하기, MSW 사용해서 mock API 설정하기

  1. 설치
    yarn add msw

  2. handlers 배열 만들기

import { rest } from "msw";

export const handlers = [
  rest.get('http://localhost:3000/', async (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json({
        message: "Welcome to 멋쟁이 토마토처럼🍅"
      })
    )
  }),
  rest.post('http://lcoalhost:3000/user', async (req, res, ctx) => {
  	return res(
    	// ...
    )
  })
]

특정 API 주소로 요청시 응답할 내용을 res(ctx, ctx, ctx)와 같이 반환하면 된다. rest는 restfull한 api를 mocking한다는 의미히다.

import { setupWorker } from "msw";

export const worker = setupWorker(...handlers)
export const server = setupServer(...handlers);

위에서 만든 handlers 배열의 요소들을 setupWorker에 넣어주면 여러 API와 응답을 가진 mockup API가 만들어진다.

setupWorker는 브라우저 환경에서 동작한다.
setupServer는 서버 환경에서 작동한다. 즉, 리액트 서버나 next js 등에서 사용될 수 있다.

리액트에서는 index.ts에 적용해준다.

// index.ts
if (process.env.NODE_ENV === 'development') {
	// develop 환경에서만 사용
    const { worker } = require('./mocks/browser');
    worker.start();
}
​
ReactDOM.render(<App />, document.getElementById('root'));

next.js에서는 _app.tsx에 등록해준다.

const initMockAPI = async (): Promise<void> => {
  if (typeof window === 'undefined') {
    const { server } = await import('mocks/server');
    server.listen();
  } else {
    const { worker } = await import('mocks/browser');
    worker.start();
  }
};

export default initMockAPI;
// _app.tsx
import initMockAPI from "mocks";

if (process.env.NODE_ENV === 'development') {
  initMockAPI();
}

이제 msw로 테스트에 쓰일 목업을 만들고 작동시키는 예시를 보자.

import React from 'react'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { fireEvent, screen } from '@testing-library/react'
// We're using our own custom render function and not RTL's render.
import { renderWithProviders } from '../../../utils/test-utils'
import UserDisplay from '../UserDisplay'

// We use msw to intercept the network request during the test,
// and return the response 'John Smith' after 150ms
// when receiving a get request to the `/api/user` endpoint
export const handlers = [
  rest.get('/api/user', (req, res, ctx) => {
    return res(ctx.json('John Smith'), ctx.delay(150))
  })
]

const server = setupServer(...handlers)

// Enable API mocking before tests.
beforeAll(() => server.listen())

// Reset any runtime request handlers we may add during the tests.
afterEach(() => server.resetHandlers())

// Disable API mocking after the tests are done.
afterAll(() => server.close())

test('Fetching user 버튼을 누르면 회원 정보를 가져옵니다.', async () => {
  renderWithProviders(<UserDisplay />)

  // 초기 화면에는 'no user'라는 문구가 보이고, '사용자를 불러오는 중..'이라는 분구는 보이지 않습니다.
  expect(screen.getByText(/no user/i)).toBeInTheDocument()
  expect(screen.queryByText(/Fetching user\.\.\./i)).not.toBeInTheDocument()

  // /Fetch user/i 버튼을 누르는 시점에 no user/i가 보여집니다.
  fireEvent.click(screen.getByRole('button', { name: /Fetch user/i }))
  expect(screen.getByText(/no user/i)).toBeInTheDocument()

  // 요청이 끝난 후, /John Smith/i라는 글자가 텍스트에 보입니다. 
  expect(await screen.findByText(/John Smith/i)).toBeInTheDocument()
  expect(screen.queryByText(/no user/i)).not.toBeInTheDocument()
  expect(screen.queryByText(/Fetching user\.\.\./i)).not.toBeInTheDocument()
})

toBeInTheDocument()는 특정 element가 바디 안에 렌더링될 것을 의미한다.

profile
천재가 되어버린 박제를 아시오?

0개의 댓글