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로 감싸준다.
실습
1. 컴포넌트(모달) 테스트
모든 컴포넌트의 관심사는 잘 렌더링이 되는지.
사용자 인러랙션에 잘 반응하는지 단 두개다.
그 이상의 관심사는 통합 테스트의 범위다. 즉 유닛테스트(리액트 컴포넌트, 훅)의 범위가 아니다.
bookshelf/modal.js at exercises/12-testing-hooks-and-components · kentcdodds/bookshelf (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)
위의 비동기 훅을 보자.
return 부분을 보면 대충 해당 훅의 책임이 보인다.
이 훅은 useSafeDispatch를 이용하고 있는데, 사실 이 경우의 테스트는 안짜는게 맞는것 같다.
(이 useSafeDispatch 자체적으로 해당 케이스의 관심사를 분리하는게 맞다고 본다.
훅을 통해 테스트케이스의 관심사가 상속된 경우라 할 수 있겠다.)
훅의 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],
)
에러메세지는 스냅샷 테스트가 좋다.
참고
Static vs Unit vs Integration vs E2E Testing for Frontend Apps,
How to test custom React hooks (kentcdodds.com)
AHA Testing 💡 (kentcdodds.com)
AHA Programming 💡 (kentcdodds.com)
'FrontEnd' 카테고리의 다른 글
CSS 면접 대비 정리 1. 기본사항 (0) | 2022.01.08 |
---|---|
[React][Remix][Jokes App Tutorial][Part1] (0) | 2022.01.05 |
[Epic React][Build an Epic React App][Unit Test][단위 테스트] (0) | 2022.01.02 |
[Epic React][Build an Epic React App][Render as you fetch][렌더링 하면서 필요한 데이터 가져오기] (0) | 2022.01.02 |
[Epic React][Build an Epic React App][Performance] (0) | 2022.01.01 |