*테스트란 개발자가 만든 프로그램이 코딩을 한 의도대로 작동하는지 확인하는 일련의 작업
React Testing Library란 DOM Testing Library를 기반으로 만들어진 테스팅 라이브러리로, 리액트를 기반으로 한 테스트를 수행하기 위해 만들어짐
jsdom이란 순수하게 자바스크립트로 작성된 라이브러리로 Node.js와 같은 환경에서 HTML과 DOM을 사용할 수 있게 하는 라이브러리
*jsdom을 사용해 DOM 조작
const jsdom = require('jsdom')
const {JSDOM} = jsdom
const dom = new JSDOM(`<!DOCTYPE html><p>Hello world</p>`)
console.log(dom.window.document.querySelector('p').textContent)
// "Hello world"
function sum(a,b) {
return a+b
}
// 테스트
// 함수 실행시 실제 결과
let actual = sum(1,2)
// 함수 실행시 기대하는 결과
let expected = 3
if(expected !== actual){
throw new Error(`${expected} is not equal to ${actual}`)
}
기본적인 테스트 코드 작성 방식
1. 테스트할 함수나 모듈을 선정
2. 함수나 모듈이 반환하길 기대하는 값 작성
3. 함수나 모듈의 실제 반환 값 작성
4. 3번의 기대에 따라 2번의 결과가 일치하는지 확인
5. 기대하는 결과를 반환한다면 성공, 다르면 에러
Node.js에는 assert라는 모듈을 제공하며 테스트 결과를 확인할 수 있음
*assert를 사용한 코드
const assert = require('assert')
function sum(a,b){
return a+b
}
assert.equal(sum(1,2),3)
assert.equal(sum(1,2),2) // AssertionError [ERR_ASSERTION][ERR_ASSERTION]: 3 == 4
=> 테스트 결과를 확인할 수 있게 도와주는 라이브러리를 어설션(assertion) 라이브러리라고 함
functon sum(a,b) {
return a+b
}
module.exports = {
sum
}
const { sum } = require('./math')
test('두 인수가 덧셈이 되어야 한다.', () => {
expect(sum(1,2)).toBe(3)
})
*import 구문을 사용할 수 있지만 테스트 코드 작성을 번거롭게 하므로 선호되지 않음
정적 컴포넌트, 별도의 상태가 존재하지 않아 항상 같은 결과를 반환하는 컴포넌트를 테스트하는 방법으로,
테스트를 원하는 컴포넌트를 렌더링한 다음, 테스트를 원하는 요소를 찾아 원하는 테스트를 수행하면 된다.
StaticComponent.tsx
export default function StaticComponent() {
return (
<>
<div>유용한 링크</div>
<ul data-testid="ul">
<li>
<a href="https://reactjs.org" target="_blank">
리액트
</a>
</li>
<li>
<a href="https://www.naver.com" target="_blank">
네이버
</a>
</li>
</ul>
</>
)
}
// StaticComponent.test.tsx
import {render, scrren} from '@testing-library/react'
import StaticComponent from './index'
beforeEach(() => {
render(<StaticComponent />)
})
describe('링크 확인',() =>{
it('링크가 2개 존재한다.', () => {
const ul = screen.getByTestId('ul')
expect(ul.children.length).toBe(2)
})
})
describe('리액트 링크 테스트',() =>{
it('리액트 링크가 존재한다.', () => {
const reactLink = screen.getByText('리액트')
expect(reactLink).toBeVisible()
})
})
*데이터셋
HTML의 특정 요소와 관련된 임의 정보를 추가할 수 있는 HTML 속성,
HTML의 특정 요소에 data-로 시작하는 속성은 무엇이든 사용할 수 있다.
ex)data-testid => getByTestId
export function InputComponent() {
const [text, setText] = useState('')
function change(event: React.ChangeEvent<HTMLInputElement>) {
const value = event.target.value
setText(value.replace(/[^A-Za-z0-9]/gi, '')
}
function buttonClick() {
alert(text)
}
return (
<>
<input aria-label="input" id="input" value={text} onChange={change} />
<button onClick={buttonClick} disabled={text.length===0}>
제출
</button>
</>
)
}
*test 코드
import {fireEvent, render} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {InputComponent} from '.'
describe('InputComponent 테스트',() => {
const setup = () => {
const screen = render(<InputComponent />)
const input = screen.getByLabelText('input') as HTMLInputElement
const button = screen.getByText(/제출/i) as HTMLButtonElement
return {
input,
button
...screen
}
}
})
it('input의 초깃값은 빈 문자열이다.', () => {
const {input} = setup()
expect(input.value).toEqual('')
})
it('영문과 숫자만 입력된다.',() => {
const {input} = setup()
const inputValue = '안녕하세요 123'
userEvent.type(input, inputValue)
expect(input.value).toEqual('123')
})
it('아이디 미입력시 버튼은 활성화되지 않는다.',() => {
const {button} = setup()
expect(button).toBeDisabled()
})
it('버튼 클릭시 alert가 해당 아이디로 표시된다.',() => {
const alertMock = jest.spyOn(window,'alert').mockImplementation((_:string) => undefined)
const {button,input} = setup()
const inputValue = 'helloworld'
userEvent.type(input, inputValue)
fireEvent.click(button)
expect(alertMock).toHaveBeenCalledTimes(1)
expect(alertMock).toHaveBeenCalledWith(inputValue)
})
import {MouseEvent, useState} from 'react'
interface TodoResponse {
userId: number
id: number
title: string
completed: false
}
export function FetchComponent() {
const [data, setData] = useState<TodoResponse | null>(null)
const [error, setError] = useState<number | null>(null)
}
async function buttonClick(e: MouseEvent<HTMLButtonElement>) {
const id = e.currentTarget.dataset.id
const response = await fetch(`/todos/${id}`)
if(response.ok) {
const result: TodoResponse = await response.json()
setData(result)
} else {
setError(response.status)
}
}
return (
<div>
<p>{data === null ? '불러온 데이터가 없습니다.' : data.title}</p>
{error && <p>에러가 발생했습니다</p>
<ul>
{Array.from({length: 10}).map((_,index) => {
const id = index + 1
return (
<button key={id} data-id={id} onClick={buttonClick}>
{`${id}번`}
</button>
)
})}
</ul>
</div>
)
fetch와 같은 비동기 컴포넌트를 테스트하기 위해서는 MSW(Mock Service Worker)를 사용한다.
MSW는 Node.js나 브라우저에서 모두 사용할 수 있는 모킹 라이브러리로, 실제 네트워크 요청을 가로채는 방식으로 모킹을 구현한다.
Node.js나 브라우저에서는 fetch 요청을 하는 것과 동일하게 네트워크 요청을 수행하고, 이 요청을 중간에 MSW가 감지하고 미리 준비한 모킹 데이터를 제공한다.
*MSW를 활용한 테스트 코드
import {fireEvent, render, screen} from '@testing-library/react'
import {rest} from 'msw'
import {setupServer} from 'msw/node'
import {FetchComponent} from '.'
const MOCK_TODO_RESPONSE = {
userId:1,
id: 1,
title: 'delectus aut autem',
completed: false,
}
const server = setupServer(
rest.get('/todos/:id', (req,res,ctx) => {
const todoId = req.params.id
if(Number(todoId)) {
return res(ctx.json({ ...MOCK_TODO_RESPONSE, id: Number(todoId) }))
} else {
return res(ctx.status(404))
}
}
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
beforeEach(() => { render (<FetchComponent />) })
dsecribe('FetchComponent 테스트', () => {
it('데이터 불러오기 전 기본 문구가 뜬다.', async () => {
const nowLoading = screen.getByText(/불러온 데이터가 없습니다./)
expect(nowLoading).toBeInTheDocument()
})
it('버튼을 클릭하면 데이터를 불러온다.', async() => {
const button = await screen.getByRole('button', {name:/1번/})
fireEvent.click(button)
const data = await screen.findByText(MOCK_TODO_RESPONSE.title)
expect(data).toBeInTheDocument()
})
})
setupServer는 MSW에서 제공하는 메서드로, 서버를 만드는 역할을 한다.
해당 함수 내부에서 라우트를 선언할 수 있으며, 라우트 내부에서 서버 코드를 작성하는 것과 동일하게 코드를 작성하고, 응답하는 데이터만 미리 준비해 둔 모킹 데이터를 반환하면 된다.
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
테스트 코드를 시작하기 전 서버를 기동하고, 테스트 코드 종료시 서버를 종료시킨다.
resetHandlers()는 앞에서 설정한 setupServer의 기본 설정으로 되돌리는 역할을 함
일반적인 테스트 코드와 비슷하지만 fetch 응답이 온 뒤에 원하는 값을 동기 방식으로 찾는 get 메서드 대신, 요소가 렌더링 될 때까지 일정 시간 동안 기다리는 find 메서드를 사용해 요소를 검색한다.
react-hooks-testing-library를 이용하여 테스트 가능
import {useEffect, useRef, DependencyList} from 'react'
export type Props = Record<string, unknown>
export const CONSOLE_PREFIX = '[useEffectDebugger]'
export default function useEffectDebugger (
componentName: string,
props?: Props
) {
const prevProps = useRef<Props | undefined>()
useEffect(() => {
if(process.env.NODE_ENV === 'production') {
return
}
const prevPropsCurrent = prevProps.current
if(prevPropsCurrent !== undefined) {
const allKeys = Object.keys({ ...prevProps.current, ...props })
const changedProps: Props = allKeys.reduce<Props>((result, key) => {
const prevValue = prevPropsCurrent[key]
const currentValue = props ? props[key] : undefined
if(!Object.is(prevValue, currentValue)) {
result[key] = {
before: prevValue,
after: currentValue
}
}
return result
}, {})
if(Object.keys(changedProps).length) {
console.log(CONSOLE_PREFIX, componentName, changedProps
}
}
prevProps.current = props
})
}
// App.tsx
import { useState } from 'react'
import useEffectDebugger from './useEffectDebugger'
function Test(props: {a:string; b:number}) {
const {a,b} = props
useEffectDebugger('TestComponent', props)
return (
<>
<div>{a}</div>
<div>{b}</div>
</>
)
}
function App() {
const [count, setCount] = useState(0)
return (
<>
<button onClick={() => setCount((count) => count + 1)}>up</button>
<Test a={count % 2 === 0 ? '짝수' : '홀수'} b={count} />
</>
)
}
export default App
*test 코드
import {renderHook} from '@testing-library/react' //react18 미만일 시 @testing-library/react-hooks를 사용해야함
import useEffectDebugger, {CONSOLE_PREFIX} from './useEffectDebugger'
const consoleSpy = jest.spyOn(console,'log')
const componentName = 'TestComponent'
describe('useEffectDebugger', () => {
afterAll(() => {
process.env.NODE_ENV = 'development'
})
})
it('props 없으면 호출되지 않음',() => {
renderHook(() => useEffectDebugger(componentName))
expect(consoleSpy).not.toHaveBeenCalled()
})
it('최초에는 호출되지 않음',() => {
const props = {hello: 'world'}
renderHook(() => useEffectDebugger(componentName, props))
// 사용자 정의 훅임에도 훅의 규칙을 위반한다는 경고 메세지를 출력하지 않는다.
// renderHook 함수는 내부에서 TestComponent라는 컴포넌트를 생성하고 이 컴포넌트 내부에서 전달받은 훅을 실행하기 때문에 훅의 규칙을 위반하지 않음
expect(consoleSpy).not.toHaveBeenCalled()
})
it('props가 변경되지 않으면 호출되지 않음',() => {
// renderHook 하나당 하나의 독립된 컴포넌트가 생성되므로 같은 컴포넌트에서 훅을 두번 호출하기 위해서는
// renderHook이 반환하는 객체 중 하나인 rerender 함수를 사용해야함(unmount 라는 함수도 반환함)
const props = {hello: 'world'}
const {rerender} = renderHook(() => useEffectDebugger(componentName, props))
expect(consoleSpy).not.toHaveBeenCalled()
rerender()
expect(consoleSpy).not.toHaveBeenCalled()
})
it('process.env.NODE_ENV가 production이면 호출되지 않음',() => {
process.env.NODE_ENV = 'production'
const props = {hello:'world'}
const {rerender} = renderHook(({componentName,props}) => useEffectDebugger(componentName, props), {
initialProps: {
componentName,
props,
}
}
)
const newProps = { hello: 'world2'}
rerender({componentName, props: newProps})
expect(consoleSpy).not.toHaveBeenCalled() // 어떠한 경우에도 consoleSpy가 호출되지 않음 => production이기 때문에
})
참고하기 좋은 자료 https://yozm.wishket.com/magazine/detail/2435/