본문 바로가기

FrontEnd

리액트 테스트 : 브라우저 API와 서드파티 모듈 mocking

반응형

배경

mocking을 이용하면 software가 어떻게 사용되는지(how)를 명시적으로 테스트 할 수 있습니다. 

이전 시간에 HTTP 요청을 모킹해 보았는데요,

때로는 모킹해야 하는 전체 브라우저 API 또는 모듈이 있습니다.
코드의 가짜 버전을 만들 때마다 "현실에 구멍을 뚫고" 결과적으로 자신감을 잃게 됩니다(이것이 E2E 테스트가 중요한 이유입니다).
테스트 더블은 실제 현실의 코드와 차이가 있고, 이는 테스트 용이성과 안정성의 tradeoff입니다.
 

모킹의 장점?

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

The Merits of Mocking

What are you doing when you mock something, and when is it worth the cost?

kentcdodds.com

사람들이 테스트와 관련하여 직면하는 가장 큰 문제 중 하나는 무엇을 테스트할지 결정하는 것입니다.

가장 큰 이유 중 하나는 모킹입니다.

많은 사람들이 코드의 모의 버전을 추가하거나, 테스트에서 실제 프러덕션 코드를 직접 실행해야 할 때를 모릅니다. 

우리는 수수료 지불을 피하기 위해 신용 카드 청구 서비스의 가짜 버전을 만듭니다.
즉 실존하지 않으면 테스트 할 수 없는 것을 테스트할 수 있게 해줍니다. (ex DB, 타사 서비스 등...)
  • 모킹은 테스트 대상과 모킹 대상 사이의 실제 연결을 끊습니다.
    • 실제 버전의 신용 카드 서비스와 함께 프로덕션 환경에서 코드가 작동할 것이라고 100% 확신할 수는 없습니다.
  • 모킹은 트레이드 오프입니다.
    • 신용카드 서비스를 모킹하면 무슨 장/단점이 있을까요?
  • 저는 네트워크 호출 / 애니메이션 라이브러리(대기시간 필요)외에는 모킹하지 않습니다.
    • E2E의 경우 모든 것을 모킹하지 않으려 합니다.
      • 아직 개발중인 테스트 API, 타사 서비스 이용 시는 예외
  • 테스트 시간을 절약하기 위해 모킹을 사용하지 마세요

모킹이 뭐죠?

But really, what is a JavaScript mock?
 

But really, what is a JavaScript mock?

Let's take a step back and understand what mocks are and how to use them to facilitate testing in JavaScript.

kentcdodds.com


mocking Browser APIs

우리의 테스트는 node환경에서 동작합니다.

jsdom 모듈 덕택에 브라우저 환경을 시뮬레이션이 가능합니다.

하지만 윈도우 리사이징, 미디어쿼리 등 불가능한 부분들이 있습니다.

이럴 경우 polifill을 사용합니다.

import matchMediaPolyfill from 'mq-polyfill'

beforeAll(() => {
  matchMediaPolyfill(window)
  window.resizeTo = function resizeTo(width, height) {
    Object.assign(this, {
      innerWidth: width,
      innerHeight: height,
      outerWidth: width,
      outerHeight: height,
    }).dispatchEvent(new this.Event('resize'))
  }
})

브라우저 API나 레이아웃에 강하게 (ex : 드래그 앤 드롭) 의존하는 테스트의 경우 실제 브라우저에서 테스트하는 것이 좋습니다.

(ex : cypress 이용)

 

mocking Modules

때때로, 원래 모듈이 테스트에서 원하는 방식으로 동작하지 않을 경우,

mock 객체로 구현을 대체할 수 있습니다.

// math.js
export const add = (a, b) => a + b
export const subtract = (a, b) => a - b

// __tests__/some-test.js
import {add, subtract} from '../math'

jest.mock('../math')

// now all the function exports from the "math.js" module are jest mock functions
// so we can call .mockImplementation(...) on them
// and make assertions like .toHaveBeenCalledTimes(...)

원래 모듈 기능의 일부만 대체할 수 있습니다. (mockImplementation)

jest.mock('../math', () => {
  const actualMath = jest.requireActual('../math')
  return {
    ...actualMath,
    subtract: jest.fn(),
  }
})

// now the `add` export is the normal function,
// but the `subtract` export is a mock function.

kentcdodds/how-jest-mocking-works (github.com)

 

GitHub - kentcdodds/how-jest-mocking-works

Contribute to kentcdodds/how-jest-mocking-works development by creating an account on GitHub.

github.com

jest는 바벨 플러그인을 통해 해당 모듈 resolving에 직접 관여하는 것으로 보입니다.

모킹 더 알아보기 : Manual Mocks · Jest (jestjs.io)

 

Manual Mocks · Jest

Manual mocks are used to stub out functionality with mock data. For example, instead of accessing a remote resource like a website or a database, you might want to create a manual mock that allows you to use fake data. This ensures your tests will be fast

jestjs.io


테스트 대상 컴포넌트

import * as React from 'react'
import {useCurrentPosition} from 'react-use-geolocation'
import Spinner from '../components/spinner'

function Location() {
  const [position, error] = useCurrentPosition()

  if (!position && !error) {
    return <Spinner />
  }

  if (error) {
    return (
      <div role="alert" style={{color: 'red'}}>
        {error.message}
      </div>
    )
  }

  return (
    <div>
      <p>Latitude: {position.coords.latitude}</p>
      <p>Longitude: {position.coords.longitude}</p>
    </div>
  )
}

export default Location

Geolocation 모킹

사용자의 위치를 ​​요청한 다음 화면에 위도 및 경도 값을 표시하는 Location 컴포넌트가 있습니다.
짐작하셨겠지만 window.navigator.geolocation.getCurrentPosition은 jsdom에서 지원하지 않으므로 이를 모킹해야 합니다.
우리는 mockImplementation을 호출하고
그 함수가 특정 테스트를 위해 하는 일을 모의할 수 있도록 jest mock 함수로 그것을 모킹할 것입니다.
또한 act를 직접 사용해야 하는 몇 가지 상황 중 하나에 부딪힐 것입니다. (Learn more)

해당 객체는 host (브라우저 - window, nodejs - global)에서 제공합니다.

JSDOM에서 제공하지 않으므로, 모킹이 필요합니다.

React는 테스트 코드에서 컴포넌트 렌더링, 사용자 이벤트, 데이터 페치 등
UI 상호작용으로 인한 DOM의 변경이 assertion 전에 완료되도록 하는 act() 함수를 제공합니다.
// 모킹
beforeAll(() => {
  window.navigator.geolocation = {
    getCurrentPosition: jest.fn(),
  }
})

// 테스트 대상 컴포넌트에 비동기 코드가 있으므로, await위해 사용
// 외부에서 내부 프로미스에 접근하여 resolve, reject 가능하게 해줌
function deferred() {
  let resolve, reject
  const promise = new Promise((res, rej) => {
    resolve = res
    reject = rej
  })
  return {promise, resolve, reject}
}

test('displays the users current location', async () => {
  const fakePosition = {
    coords: {
      latitude: 35,
      longitude: 139,
    },
  }
  const {promise, resolve} = deferred()

  // jest.fn을 가짜로 구현
  window.navigator.geolocation.getCurrentPosition.mockImplementation(
    callback => {
      promise.then(() => callback(fakePosition)) // 콜백을 받으면 비동기로 실행
    },
  )

  render(<Location />)

  expect(screen.getByLabelText(/loading/i)).toBeInTheDocument()

  // await deffered().promise; 도 가능함.
  await act(async () => {
    resolve()
    await promise
  })

  expect(screen.queryByLabelText(/loading/i)).not.toBeInTheDocument()

  expect(screen.getByText(/latitude/i)).toHaveTextContent(
    `Latitude: ${fakePosition.coords.latitude}`,
  )
  expect(screen.getByText(/longitude/i)).toHaveTextContent(
    `Longitude: ${fakePosition.coords.longitude}`,
  )
})

act 함수 안쓰면 발생함. useGeoLocation(서드파티) 내부 useEffect에 의해 상태 업데이트가 되기 때문

Test Utilities – React (reactjs.org)

 

Test Utilities – React

A JavaScript library for building user interfaces

reactjs.org

Fix the "not wrapped in act(...)" warning (kentcdodds.com)

 

Fix the "not wrapped in act(...)" warning

There are a few reasons you're getting this warning. Here's how you fix it.

kentcdodds.com

 

컴포넌트 외부에서,

리액트 컴포넌트의 상태를 업데이트하는 함수, 또는 상태 업데이트 함수를 호출하는 결과를 초래하는 함수를 직접 호출하는 경우

act로 감싸줘야 합니다.

(이 경우, geolocation에 의해 상태가 업데이트됩니다.)

2. mock the module(third party package)

때떄로 특정 모듈이 모킹하기 너무 어려운 캔버스와 같은 브라우저 API와 인터랙션 하는 경우가 있습니다.

이 때 이런 API를 사용하는 모듈 자체의 테스트를 믿고 가는 방법도 있습니다. (서드파티)

이 경우 해당 브라우저 API 대신 사용 모듈을 모킹합니다.

모킹 대상이 훅이면, 훅을 모킹해야합니다.

https://github.com/kentcdodds/testing-react-apps/blob/main/src/__tests__/final/06.extra-1.js

 

GitHub - kentcdodds/testing-react-apps: A workshop for testing react applications

A workshop for testing react applications. Contribute to kentcdodds/testing-react-apps development by creating an account on GitHub.

github.com

// mocking Browser APIs and modules
// 💯 mock the module
// https://github.com/kentcdodds/testing-react-apps/blob/main/src/__tests__/final/06.extra-1.js

import * as React from 'react'
import {render, screen, act} from '@testing-library/react'
import {useCurrentPosition} from 'react-use-geolocation'
import Location from '../../examples/location'

jest.mock('react-use-geolocation')

test('displays the users current location', async () => {
  const fakePosition = {
    coords: {
      latitude: 35,
      longitude: 139,
    },
  }

  let setReturnValue
  function useMockCurrentPosition() {
    const state = React.useState([])
    setReturnValue = state[1]
    return state[0]
  }
  useCurrentPosition.mockImplementation(useMockCurrentPosition)

  render(<Location />)
  expect(screen.getByLabelText(/loading/i)).toBeInTheDocument()

  act(() => {
    setReturnValue([fakePosition])
  })

  expect(screen.queryByLabelText(/loading/i)).not.toBeInTheDocument()
  expect(screen.getByText(/latitude/i)).toHaveTextContent(
    `Latitude: ${fakePosition.coords.latitude}`,
  )
  expect(screen.getByText(/longitude/i)).toHaveTextContent(
    `Longitude: ${fakePosition.coords.longitude}`,
  )
})

3. Test the unhappy Path

항상 에러 처리를 염두에 두세요

https://github.com/kentcdodds/testing-react-apps/blob/main/src/__tests__/final/06.extra-2.js

 

GitHub - kentcdodds/testing-react-apps: A workshop for testing react applications

A workshop for testing react applications. Contribute to kentcdodds/testing-react-apps development by creating an account on GitHub.

github.com

// mocking Browser APIs and modules
// 💯 test the unhappy path
// https://github.com/kentcdodds/testing-react-apps/blob/main/src/__tests__/final/06.extra-2.js

import React from 'react'
import {render, screen, act} from '@testing-library/react'
import Location from '../../examples/location'

beforeAll(() => {
  window.navigator.geolocation = {
    getCurrentPosition: jest.fn(),
  }
})

function deferred() {
  let resolve, reject
  const promise = new Promise((res, rej) => {
    resolve = res
    reject = rej
  })
  return {promise, resolve, reject}
}

test('displays the users current location', async () => {
  const fakePosition = {
    coords: {
      latitude: 35,
      longitude: 139,
    },
  }
  const {promise, resolve} = deferred()
  window.navigator.geolocation.getCurrentPosition.mockImplementation(
    callback => {
      promise.then(() => callback(fakePosition))
    },
  )

  render(<Location />)

  expect(screen.getByLabelText(/loading/i)).toBeInTheDocument()

  await act(async () => {
    resolve()
    await promise
  })

  expect(screen.queryByLabelText(/loading/i)).not.toBeInTheDocument()

  expect(screen.getByText(/latitude/i)).toHaveTextContent(
    `Latitude: ${fakePosition.coords.latitude}`,
  )
  expect(screen.getByText(/longitude/i)).toHaveTextContent(
    `Longitude: ${fakePosition.coords.longitude}`,
  )
})

test('displays error message when geolocation is not supported', async () => {
  const fakeError = new Error(
    'Geolocation is not supported or permission denied',
  )
  const {promise, reject} = deferred()

  window.navigator.geolocation.getCurrentPosition.mockImplementation(
    (successCallback, errorCallback) => {
      promise.catch(() => errorCallback(fakeError))
    },
  )

  render(<Location />)

  expect(screen.getByLabelText(/loading/i)).toBeInTheDocument()

  await act(async () => {
    reject()
  })

  expect(screen.queryByLabelText(/loading/i)).not.toBeInTheDocument()

  expect(screen.getByRole('alert')).toHaveTextContent(fakeError.message)
})

모킹은 현실에 구멍을 뚫는 일입니다.

이는 테스트가 우리에게 주는 안정감을 감소시킵니다.

하지만 때때로 실용성을 위해 필요합니다.

모킹에 대해 좀 더 알아보기

The Merits of Mocking (kentcdodds.com)

 

The Merits of Mocking

What are you doing when you mock something, and when is it worth the cost?

kentcdodds.com

But really, what is a JavaScript mock? (kentcdodds.com)

 

But really, what is a JavaScript mock?

Let's take a step back and understand what mocks are and how to use them to facilitate testing in JavaScript.

kentcdodds.com

 

반응형