본문 바로가기

FrontEnd

리액트 테스트 : MSW를 사용하여 HTTP Requests 모킹하기

반응형

TLDR : MSW를 이용하여, 개발 서버와 테스트 서버를 동일하게 사용하자.

 

Mock Service Worker

Seamless API mocking library for browser and Node.

mswjs.io

 

백엔드 상호작용 테스트는 중요합니다.

사용자가 앱을 사용하는 방식이기 때문입니다

  • e2e 테스트는 cypress와 같은 도구로 대체
  • Unit Component Test, (server) Integration Test는 windows.fetch를 모킹하는 것부터 시작

MSW – Seamless API mocking library for browser and Node | Mock Service Worker (mswjs.io)

window.fetch는 JSDOM/Node에서 지원되지 않기 때문에
테스트 환경에서 fetch를 폴리필하는 whatwg-fetch 모듈이 설치되어 있어야 MSW가 이러한 요청을 처리할 수 있습니다.
이것은 CRA의 경우 react-scripts 덕분에 jest 설정에서 자동으로 설정됩니다.

MSW를 사용하는 이유

일단 mocking을 왜 사용할까요?

  • mocking을 이용하면 software가 어떻게 사용되는지(how)를 명시적으로 테스트 할 수 있습니다. 
    • 즉 코드 블럭 내부를 테스트 하는 것이고, 이는 implementation detail이 누출된다 볼 수 있습니다.

아시다시피 클라이언트에서 서버 데이터를 가져오려면 axios나 fetch 함수를 호출해야 하는데요.

따라서 해당 함수를 흉내내는 무언가가 있어야 fetch, axios, 서버에 대한 의존성 없이 테스트가 가능합니다.

그런데 모킹을 하지 말라고요? 그러면 사실 켄트의 글에서도 잘 설명되어 있는데요

fetch의 모킹을 그만두세요

 

Stop mocking fetch

Why you shouldn't mock fetch or your API Client in your tests and what to do instead.

kentcdodds.com

프론트엔드에서 진정한 테스터블한 아키텍처를 구축하려면 VAC 패턴을 따라야 합니다.

https://itchallenger.tistory.com/593

 

The Difference Between VAC Pattern and Container/Presenter Pattern

https://www.patterns.dev/posts/presentational-container-pattern/ Container/Presentational Pattern Enforce separation of concerns by separating the view from the application logic www.patterns.dev ht..

itchallenger.tistory.com

View Asset 컴포넌트는 다음과 같은 역할을 합니다.

  • 반복이나 조건부 노출, 스타일 제어와 같은 렌더링과 관련된 처리만을 수행합니다. (반복 조건 분기)
  • 오직 props를 통해서만 제어되며 스스로의 상태를 관리하거나 변경하지 않는 stateless 컴포넌트입니다.
  • 이벤트에 함수를 바인딩할 때 어떠한 추가 처리도 하지 않습니다.

즉 의존성은 콜백과 데이터컨테이너 컴포넌트에서 View Asset Component로 props를 통해 내려줘야 합니다.

이는 스프링으로 치면 IOC 컨테이너와 같은 역할을 합니다.

여기서 우리는 둘 중 하나를 선택할 수 있습니다.

  • 컨테이너 컴포넌트에서 VAC를 떼어내서, FakeContainer를 만들기
    • fetch와 axios를 모킹
  • 테스트 할때 우리가 운영환경에서 사용하는 Containter를 그대로 사용하기!
    • MSW가 request와 response를 intercept
    • 프록시와 같은 역할을 함

백엔드에 익숙하신 분들은 전자가 더 자연스럽다 생각할 수 도 있는데요.

kent는 후자를 추천하며 다음과 같이 이야기 합니다.

UI의 흐름자체가 백엔드와 다르게 굉장히 복잡하고
(순수한 데이터만이 아닌 View라는 형태를 다루는 로직이 계속 섞여들어가기때문에)
계속 여러가지 방법들이 시도되고 있는 듯 하고 각 제품에 맞는 방법론을 잘 적용해서 개발해나가는것이 좋겠지요.
  • fetch 응답 속성 및 헤더의 구현 세부 정보에 대해 걱정할 필요가 없습니다.
  • fetch를 호출하는 방식에 문제가 있으면 내 서버 핸들러가 호출되지 않고 테스트가 (정확하게) 실패하여 깨진 코드를 배송하지 않아도 됩니다.
  • 개발할 때 운영과 똑같은 서버 핸들러를 재사용할 수 있습니다!

Mock Service Worker

실제 서버 응답 방식(how)를 모킹하는것이 중요합니다.

최대한 견고한 테스트를 만들기 위함입니다.

msw를 이용한 mocking이 실제 세계와 더 유사하기 때문에 우리는 조금 더 자신감을 얻을 수 있습니다.

나중에 암호를 제출하지 않았기 때문에 400 응답을 받았을 때

UI가 수행하는 작업을 확인하기 위해 몇 가지 추가 테스트를 추가할 수 있습니다.

// 서버 요청 인터셉트 위한 가짜 서버 생성
const server = setupServer(
  rest.post(
    'https://auth-provider.example.com/api/login',
    async (req, res, ctx) => {
      if (!req.body.password) {
        return res(ctx.status(400), ctx.json({message: 'password required'}))
      }
      if (!req.body.username) {
        return res(ctx.status(400), ctx.json({message: 'username required'}))
      }
      return res(ctx.json({username: req.body.username}))
    },
  ),
)

이를 이용한 테스트 코드 예제입니다.

// 가라 데이터 생성
const buildLoginForm = build({
  fields: {
    username: fake(f => f.internet.userName()),
    password: fake(f => f.internet.password()),
  },
})

// 서버 요청 인터셉트 위한 가짜 서버 생성
const server = setupServer(
  rest.post(
    'https://auth-provider.example.com/api/login',
    async (req, res, ctx) => {
      if (!req.body.password) {
        return res(ctx.status(400), ctx.json({message: 'password required'}))
      }
      if (!req.body.username) {
        return res(ctx.status(400), ctx.json({message: 'username required'}))
      }
      return res(ctx.json({username: req.body.username}))
    },
  ),
)
// 가짜 서버 시작
beforeAll(() => server.listen())
// 가짜 서버 종료
afterAll(() => server.close())

// 비동기 테스트 스위트
test(`로그인시 화면에 로그인한 사용자의 이름이 나타나야 합니다.`, async () => {
  render(<Login />)
  const {username, password} = buildLoginForm()

  userEvent.type(screen.getByLabelText(/username/i), username)
  userEvent.type(screen.getByLabelText(/password/i), password)
  userEvent.click(screen.getByRole('button', {name: /submit/i}))
  // 로딩 끝날 때까지 대기
  await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i))

  expect(screen.getByText(username)).toBeInTheDocument()
})

Request 핸들러 재사용

MSW를 이용한 mock server 설정은 개발 환경과 test 환경에서 동일하게 사용할 수 있습니다.

즉 브라우저 환경(dev)와 node 환경(test)에서 동시 사용 가능한 것입니다.

개발 환경을 MSW를 통해 만들어보고, 핸들러를 테스트와 공유해 봅니다.

 

참고 : kent의 test utils를 다음 폴더에서 확인하실 수 있습니다.

server-handlers.js

import {rest} from 'msw'

const delay = process.env.NODE_ENV === 'test' ? 0 : 1500

const handlers = [
  rest.post(
    'https://auth-provider.example.com/api/login',
    async (req, res, ctx) => {
      if (!req.body.password) {
        return res(
          ctx.delay(delay),
          ctx.status(400),
          ctx.json({message: 'password required'}),
        )
      }
      if (!req.body.username) {
        return res(
          ctx.delay(delay),
          ctx.status(400),
          ctx.json({message: 'username required'}),
        )
      }
      return res(ctx.delay(delay), ctx.json({username: req.body.username}))
    },
  ),
]

export {handlers}


// 테스트에선 이렇게 쓴다!
const server = setupServer(...handlers)

unhappy path

실패 케이스도 테스트 해야 합니다.

에러메세지가 잘 뿌려지는지 확인해 봅시다.

이전에 msw로 mock server를 구성해 두었기 때문에 따로 테스트 환경 설정을 할 필요가 없습니다! (status : 400)

test('omitting the password results in an error', async () => {
  render(<Login />)
  const {username} = buildLoginForm()

  userEvent.type(screen.getByLabelText(/username/i), username)
  // don't type in the password
  userEvent.click(screen.getByRole('button', {name: /submit/i}))

  await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i))

  expect(screen.getByRole('alert')).toHaveTextContent('password required')
})

에러메세지 확인을 위한 inline snapshots 활용

스냅샷 인라인으로 에러메세지 하드코딩을 피해봅시다.

해당 코드를 직접 실행해보자.
자동으로 아래와 같이 코드를 변경해준다.

스타일, div는 구현 세부 사항일 뿐, 사용자가 신경쓰는 내용이 아닙니다.

아래와 같이(textContext) 변경 후 U 버튼을 누르면 자동으로 업데이트 해줍니다.

U 버튼으로 inline snapshot 업데이트

이를 통해 스냅샷을 자동으로 업데이트하여 직접 컨텐츠를 하드코딩하는 노력을 줄일 수 있습니다.

2번의 핸들러에서 response를 변경하면, 테스트가 깨질 것입니다.

  • response를 변경하는 것이 우리의 의도라면, U를 눌러 스냅샷을 업데이트 합니다. 그리고 테스트를 통과하면 됩니다.
  • 그렇지 않다면 테스트를 통과하도록 코드를 수정합니다.

use one-off server handlers (일회용 서버 핸들러 사용)

특정 예상못한 응답에 대한 대처를 테스트에 추가해보고 싶다면?

우리는 이미 dev와 동일한 handler를 사용하고 있는데, 이를 수정해야 할까요?

MSW는 런타임 오버라이딩을 제공합니다.

해당 테스트에서만 오버라이딩 후, 리셋합니다.

const buildLoginForm = build({
  fields: {
    username: fake(f => f.internet.userName()),
    password: fake(f => f.internet.password()),
  },
})

const server = setupServer(...handlers)

beforeAll(() => server.listen())
afterAll(() => server.close())
afterEach(() => server.resetHandlers())

test('unknown server error displays the error message', async () => {
  const testErrorMessage = 'Oh no, something bad happened'
  server.use(
    rest.post(
      'https://auth-provider.example.com/api/login',
      async (req, res, ctx) => {
        return res(ctx.status(500), ctx.json({message: testErrorMessage}))
      },
    ),
  )
  render(<Login />)
  userEvent.click(screen.getByRole('button', {name: /submit/i}))

  await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i))

  expect(screen.getByRole('alert')).toHaveTextContent(testErrorMessage)
})

Read more about the benefits of colocation.

참고

https://github.com/kentcdodds/testing-react-apps/blob/main/src/__tests__/exercise/05.md

프론트엔드 테스터블 아키텍처에 대한 글이 없다는 분들이 있어서 이곳에 링크 걸어둡니다.

  • 프론트엔드는 클린 아키텍처, 테스팅에 대한 고민이 없다?
  • 커뮤니티에 보고 배울만한 자료가 없다?

 

반응형