본문 바로가기

FrontEnd

[Epic React][Build an Epic React App][Testing Hooks and Components][훅과 컴포넌트 테스트]

반응형
bookshelf/INSTRUCTIONS.md at exercises/12-testing-hooks-and-components · kentcdodds/bookshelf (github.com)
 

GitHub - kentcdodds/bookshelf: Build a ReactJS App workshop

Build a ReactJS App workshop. Contribute to kentcdodds/bookshelf development by creating an account on GitHub.

github.com

Background


React 애플리케이션의 두 가지 빌딩 블록은 Hooks와 Components입니다.

훅을 조합해서 커스텀 훅을 만들 수도 있고,

컴포넌트를 다른 조각으로 분할할 수도 있습니다.

하지만 이 컴포넌트 전부를 테스트해야 한다는 것은 아닙니다.

불필요하게 너무 낮은 수준에서 테스트하지 않도록 하고 싶습니다(작은 붓으로 벽을 칠하거나 페인트 통으로 모서리를 칠하는 것 같은).

 

일반적으로 다른 컴포넌트와의 상호작용을 테스트 할 때, 테스트에서 더 큰 확신을 얻을 수 있습니다.

너무 간단한 컴포넌트(ex 순수한 컴포넌트, 순수함수)와 훅은 테스트의 필요성을 느끼지 못할 수도 있습니다.

하지만 재사용 가능성이 높거나 복잡한 컴포넌트나 훅은 테스트하는게 좋을 수 있습니다.

 

종속성을 전부 모킹하지 않으면 UNIT TEST가 아니라는 사람들이 있습니다.

하지만 이는 전혀 중요한게 아닙니다.

컴포넌트 테스트, 훅 테스트 따위로 부르거나 혹은 저수준 Integration test라 불러도 상관없습니다.

중요한 것은 테스트를 통해 코드에 대한 자신감을 확보하는 것입니다.

 

컴포넌트 테스트

가장 간단한 컴포넌트 테스트 예제.

import * as React from 'react'
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {MyComponent} from '../my-component'

test('renders click me button', () => {
  render(<MyComponent />)
  const button = screen.getByRole('button', {name: /click me/i})
  userEvent.click(button)

  expect().toBeInTheDocument()
})

훅 테스트

대부분의 커스텀 훅은 이를 사용하는 컴포넌트를 테스트해야 합니다.

(역 : 훅 개별 테스트는 딱히 필요 없음 - useState를 테스트하지는 않음. Implementation Detail임)

커스텀 훅을 과도하게 추상화하는 자연스러운 경향을 피하는 데 도움이 됩니다.

하지만 복잡한 훅은 개별적으로 테스트하는게 좋을 수도 있음.

훅은 테스트하려면 훅을 사용하는 컴포넌트가 필요함.

@testing-library/react-hooks 라이브러리를 사용할 수도 있음

 

가장 간단한 훅 테스트 예제

import {renderHook, act} from '@testing-library/react-hooks'
import useCounter from '../use-counter'

test('should increment counter', () => {
  const {result} = renderHook(() => useCounter())
  expect(result.current.count).toBe(0)
  act(() => {
    result.current.increment()
  })
  expect(result.current.count).toBe(1)
})

 

act 함수

테스트 내부에서 React의 컴포넌트의 state를 변경하는 부분은 act로 감싸줘야 한다.

이는 사용자가 action을 발생시켜 React의 렌더링 상태를 변경함을 의미한다.

즉 상태와 UI의 동기화를 의미한다.

즉, 인터랙션(부수효과) 반영 결과를 테스트와 동기화함을 act 함수로 명시한다.

비동기 훅의 경우 비동기 호출 / resolve or reject 부분을 둘 다 act로 감싸준다.

에러 발생 여부는 간단하게 console.error 여부로 확인할 수 있다.
 jest.spyOn(console, 'error')

실습


1. 컴포넌트(모달) 테스트

bookshelf/modal.final.js at exercises/12-testing-hooks-and-components · kentcdodds/bookshelf (github.com)

 

GitHub - kentcdodds/bookshelf: Build a ReactJS App workshop

Build a ReactJS App workshop. Contribute to kentcdodds/bookshelf development by creating an account on GitHub.

github.com

모든 컴포넌트의 관심사는 잘 렌더링이 되는지.

사용자 인러랙션에 잘 반응하는지 단 두개다.

그 이상의 관심사는 통합 테스트의 범위다. 즉 유닛테스트(리액트 컴포넌트, 훅)의 범위가 아니다.

 

bookshelf/modal.js at exercises/12-testing-hooks-and-components · kentcdodds/bookshelf (github.com)

 

GitHub - kentcdodds/bookshelf: Build a ReactJS App workshop

Build a ReactJS App workshop. Contribute to kentcdodds/bookshelf development by creating an account on GitHub.

github.com

위 링크 소스코드의 모달에 대해 우리가 무엇을 테스트 해야 할까?

  • 인터랙션(열고 닫기)
    • 모달컴포넌트의 확장된 기능
  • 렌더링이 잘되는지
    • label,title,content

사용자 관점의 테스트 케이스를 정했으면, 테스트 케이스를 작성한다.

아래 테스트는 두개가 동시에 테스트되고 있지만, 두개의 관심사에 따라 테스트 케이스를 분리할 수 있다.

   
import * as React from 'react'
import {render, screen, within} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {Modal, ModalOpenButton, ModalContents} from '../modal'

test('can be opened and closed', () => {
  const label = 'Modal Label'
  const title = 'Modal Title'
  const content = 'Modal content'

  render(
    <Modal>
      <ModalOpenButton>
        <button>Open</button>
      </ModalOpenButton>
      <ModalContents aria-label={label} title={title}>
        <div>{content}</div>
      </ModalContents>
    </Modal>,
  )
  // 모달 열기
  userEvent.click(screen.getByRole('button', {name: /open/i}))
 
 // 모달 컴포넌트
  const modal = screen.getByRole('dialog')
  
  // 접근성 기능 검사
  expect(modal).toHaveAttribute('aria-label', label)
  // 내부 컴포넌트들로 범위 축소
  const inModal = within(modal)
  
  // 렌더링 검사
  expect(inModal.getByRole('heading', {name: title})).toBeInTheDocument()
  expect(inModal.getByText(content)).toBeInTheDocument()
  
  // 모달 닫기
  userEvent.click(inModal.getByRole('button', {name: /close/i}))

  // 모달 닫혔는지 확인.
  expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})

2. 훅 테스트

bookshelf/hooks.js at exercises/12-testing-hooks-and-components · kentcdodds/bookshelf (github.com)

 

GitHub - kentcdodds/bookshelf: Build a ReactJS App workshop

Build a ReactJS App workshop. Contribute to kentcdodds/bookshelf development by creating an account on GitHub.

github.com

위의 비동기 훅을 보자.

return 부분을 보면 대충 해당 훅의 책임이 보인다.

이 훅은 useSafeDispatch를 이용하고 있는데, 사실 이 경우의 테스트는 안짜는게 맞는것 같다.

(이 useSafeDispatch 자체적으로 해당 케이스의 관심사를 분리하는게 맞다고 본다.

훅을 통해 테스트케이스의 관심사가 상속된 경우라 할 수 있겠다.)

 

bookshelf/use-async.extra-1.js at exercises/12-testing-hooks-and-components · kentcdodds/bookshelf (github.com)

 

GitHub - kentcdodds/bookshelf: Build a ReactJS App workshop

Build a ReactJS App workshop. Contribute to kentcdodds/bookshelf development by creating an account on GitHub.

github.com

훅의 run 함수 부분

  const run = React.useCallback(
    promise => {
      if (!promise || !promise.then) {
        throw new Error(
          `The argument passed to useAsync().run must be a promise. Maybe a function that's passed isn't returning anything?`,
        )
      }
      safeSetState({status: 'pending'})
      return promise.then(
        data => {
          setData(data)
          return data
        },
        error => {
          setError(error)
          return Promise.reject(error)
        },
      )
    },
    [safeSetState, setData, setError],
  )

result.current(훅의 리턴값)과 비교한다.

import {renderHook, act} from '@testing-library/react-hooks'
import {useAsync} from '../hooks'

// 에러 발생을 콘솔에 error가 찍혔는지로 확인한다.
// 개발자의 관심사!
beforeEach(() => {
  jest.spyOn(console, 'error')
})

afterEach(() => {
  console.error.mockRestore()
})


// resolve,reject를 하나의 promise로 동기화하여 사용하기 위함.
// 호출 시 이미 비동기 호출은 실행된 상태.
function deferred() {
  let resolve, reject
  const promise = new Promise((res, rej) => {
    resolve = res
    reject = rej
  })
  return {promise, resolve, reject}
}

const defaultState = {
  status: 'idle',
  data: null,
  error: null,

  isIdle: true,
  isLoading: false,
  isError: false,
  isSuccess: false,

  run: expect.any(Function),
  reset: expect.any(Function),
  setData: expect.any(Function),
  setError: expect.any(Function),
}

const pendingState = {
  ...defaultState,
  status: 'pending',
  isIdle: false,
  isLoading: true,
}

const resolvedState = {
  ...defaultState,
  status: 'resolved',
  isIdle: false,
  isSuccess: true,
}

const rejectedState = {
  ...defaultState,
  status: 'rejected',
  isIdle: false,
  isError: true,
}

// 해당 테스트는 reset부분을 분리할 수 있다.
test('calling run with a promise which resolves', async () => {
  const {promise, resolve} = deferred()
  const {result} = renderHook(() => useAsync())
  expect(result.current).toEqual(defaultState)
  let p
  // run 훅을 사용자가 호출해서 UI가 변경됨을 의미함.
  act(() => {
    p = result.current.run(promise)
  })
  expect(result.current).toEqual(pendingState)
  const resolvedValue = Symbol('resolved value')
  
  // promise의 resolve를 발생시킴. resolve되면 UI가 변경됨.
  await act(async () => {
    resolve(resolvedValue)
    await p
  })
  expect(result.current).toEqual({
    ...resolvedState,
    data: resolvedValue,
  })
  
  // reset 함수 동작 확인.
  act(() => {
    result.current.reset()
  })
  
  expect(result.current).toEqual(defaultState)
})


test('calling run with a promise which rejects', async () => {
  const {promise, reject} = deferred()
  const {result} = renderHook(() => useAsync())
  expect(result.current).toEqual(defaultState)
  let p
  
  // UI 변경
  act(() => {
    p = result.current.run(promise)
  })
  expect(result.current).toEqual(pendingState)
  const rejectedValue = Symbol('rejected value')
  await act(async () => {
    reject(rejectedValue)
    await p.catch(() => {
      /* ignore error */
    })
  })
  expect(result.current).toEqual({...rejectedState, error: rejectedValue})
})

test('can specify an initial state', () => {
  const mockData = Symbol('resolved value')
  const customInitialState = {status: 'resolved', data: mockData}
  const {result} = renderHook(() => useAsync(customInitialState))
  expect(result.current).toEqual({
    ...resolvedState,
    ...customInitialState,
  })
})

test('can set the data', () => {
  const mockData = Symbol('resolved value')
  const {result} = renderHook(() => useAsync())
  act(() => {
    result.current.setData(mockData)
  })
  expect(result.current).toEqual({
    ...resolvedState,
    data: mockData,
  })
})

test('can set the error', () => {
  const mockError = Symbol('rejected value')
  const {result} = renderHook(() => useAsync())
  act(() => {
    result.current.setError(mockError)
  })
  expect(result.current).toEqual({
    ...rejectedState,
    error: mockError,
  })
})

// useSafeDispatch의 관심사.
test('No state updates happen if the component is unmounted while pending', async () => {
  const {promise, resolve} = deferred()
  const {result, unmount} = renderHook(() => useAsync())
  let p
  act(() => {
    p = result.current.run(promise)
  })
  unmount()
  await act(async () => {
    resolve()
    await p
  })
  expect(console.error).not.toHaveBeenCalled()
})

// U를 누르면 스냅샷이 바뀌는 것을 볼 수 있다.
test('calling "run" without a promise results in an early error', () => {
  const {result} = renderHook(() => useAsync())
  expect(() => result.current.run()).toThrowErrorMatchingInlineSnapshot(
    `"The argument passed to useAsync().run must be a promise. Maybe a function that's passed isn't returning anything?"`,
  )
})

맨 마지막 테스트는 useAsync 훅에서 발생시킨 에러메세지의 스냅샷이다.

  const run = React.useCallback(
    promise => {
      if (!promise || !promise.then) {
        throw new Error(
          `The argument passed to useAsync().run must be a promise. Maybe a function that's passed isn't returning anything?`,
        )
      }
      safeSetState({status: 'pending'})
      return promise.then(
        data => {
          setData(data)
          return data
        },
        error => {
          setError(error)
          return Promise.reject(error)
        },
      )
    },
    [safeSetState, setData, setError],
  )

에러메세지는 스냅샷 테스트가 좋다.

참고


 How to know what to test 

 

How to know what to test

Practical advice to help you determine what to test.

kentcdodds.com

 Static vs Unit vs Integration vs E2E Testing for Frontend Apps,

 

Static vs Unit vs Integration vs E2E Testing for Frontend Apps

What these mean, why they matter, and why they don't

kentcdodds.com

How to test custom React hooks (kentcdodds.com)

 

How to test custom React hooks

Get confidence your custom React hooks work properly with solid tests.

kentcdodds.com

AHA Testing 💡 (kentcdodds.com)

 

AHA Testing 💡

How to apply the "Avoid Hasty Abstraction" principle to your test code.

kentcdodds.com

AHA Programming 💡 (kentcdodds.com)

 

AHA Programming 💡

The dangers of DRY, the web of WET, the awesomeness of AHA.

kentcdodds.com

 

반응형