react-deep-dive 스터디(8장-8.2 리액트 테스트 라이브러리)

kdh3543·2024년 6월 7일
0
post-custom-banner

8장 좋은 리액트 코드 작성을 위한 환경 구축하기

*테스트란 개발자가 만든 프로그램이 코딩을 한 의도대로 작동하는지 확인하는 일련의 작업

8.2.1 React Testing Library

React Testing Library란 DOM Testing Library를 기반으로 만들어진 테스팅 라이브러리로, 리액트를 기반으로 한 테스트를 수행하기 위해 만들어짐

  • DOM Testing Library
    jsdom을 기반으로 하는 테스팅 라이브러리

    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"
  • 리액트 테스팅 라이브러리 이점
  1. 실제로 컴포넌트를 렌더링하지 않고도 원하는대로 렌더링되고 있는지 확인가능
  2. 테스트에 소요되는 시간을 효과적으로 단축
  3. 컴포넌트뿐만 아니라 Provider, 훅 등 리액트를 구성하는 다양한 요소들 테스트 가능

8.2.2 자바스크립트 테스트의 기초

  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) 라이브러리라고 함

  • 테스팅 프레임워크
    assertion을 기반으로 테스트를 수행하는 프레임워크
    종류
    • Jest, Mocha, Karma, Jasmine
    • 리액트에서는 Jest를 많이 사용
  • Jest 특징
    1. test, expect등의 메서드를 import나 require 같은 구문 없이 사용가능
    => Jest를 비롯한 테스팅 프레임워크는 실행 시 전역 스코프에 기본적으로 넣어주는 값들이 있음
    *Jest 예시
	functon sum(a,b) {
    	return a+b
    }
    
    module.exports = {
    	sum
    }
	const { sum } = require('./math')
    
    test('두 인수가 덧셈이 되어야 한다.', () => {
    	expect(sum(1,2)).toBe(3)
    })
	*import 구문을 사용할 수 있지만 테스트 코드 작성을 번거롭게 하므로 선호되지 않음
    

8.2.3 리액트 컴포넌트 테스트 코드 작성

정적 컴포넌트

정적 컴포넌트, 별도의 상태가 존재하지 않아 항상 같은 결과를 반환하는 컴포넌트를 테스트하는 방법으로,
테스트를 원하는 컴포넌트를 렌더링한 다음, 테스트를 원하는 요소를 찾아 원하는 테스트를 수행하면 된다.

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()
 })
})
  1. beforeEach: 각 테스트(it)를 수행하기 전 실행하는 함수다. 여기서는 StaticComponent를 렌더링한다.
  2. describe: 비슷한 속성을 가진 테스트를 하나의 그룹으로 묶는 역할. 꼭 필요한 메스드는 아니지만 테스트가 많아지고 관리가 어려워지면 describe로 묶어서 관리할 수 있다.
  3. it: test와 동일하며, test의 축약어이다. it을 사용하는 이유는 테스트 코드를 좀 더 읽기 쉽게 하기 위해서다. describe ... it(something)과 같은 형태로 작성하면 테스트 코드가 문어체같이 표현되어 읽기 쉬워진다.
  4. testId: testId는 리액트 테스팅 라이브러리의 예약어로, 웹에서 사용하는 querySelector([data-testid = "${yourId}"])와 동일한 역할을 한다.

*데이터셋
HTML의 특정 요소와 관련된 임의 정보를 추가할 수 있는 HTML 속성,
HTML의 특정 요소에 data-로 시작하는 속성은 무엇이든 사용할 수 있다.
ex)data-testid => getByTestId

동적 컴포넌트

  • 사용자가 useState를 통해 입력을 변경하는 컴포넌트
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)
})
  1. setup 함수
    setup 함수는 내부에서 컴포넌트를 렌더링하고, 또 테스트에 필요한 input, button을 반환한다.
  2. userEvent.type
    userEvent.type은 사용자가 타이핑하는 것을 흉내내는 메서드다. 이 메서드를 사용하면 사용자가 키보드로 타이핑하는 것과 동일한 작동을 만들 수 있다. userEvent는 @testing-library/react에서 제공하는 fireEvent와 차이가 있다. 기본적으로 userEvent는 fireEvent의 여러 이벤트를 순차적으로 진행해 좀 더 자세하게 사용자의 작동을 흉내 낸다. userEvent.click 수행 시 다음과 같은 fireEvent가 실행된다.
    - fireEvent.mouseOver
    - fireEvent.mouseMove
    - fireEvent.mouseDown
    - fireEvent.mouseUp
    - fireEvent.click
  3. jest.spyOn
    Jest가 제공하는 spyOn은 어떠한 특정 메서드를 오염시키지 않고 실행과 관련된 정보만 얻고 싶을 때 사용한다. 여기서는 window 객체의 메서드 alert를 구현하지 않고 해당 메서드가 실행됐는지만 관찰한다는 의미이다.
  4. mockImplementation
    해당 메서드에 대한 모킹 구현을 도와준다. Node.js에는 window.alert가 존재하지 않으므로 해당 메서드를 모의 함수로 구현해아 하는데, 이것이 바로 mockImplementation의 역할이다.
    ==> 여기서는 Node.js 에 존재하지 않는 window.alert를 테스트하기 위해 jest.spyOn을 사용해 alert를 관잘하게 하고, mockImplementation을 통해 alert가 실행됐는지 등의 정보를 확인할 수 있도록 처리한 것이다.

비동기 이벤트가 발생하는 컴포넌트

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()
    })
})
  1. setupServer는 MSW에서 제공하는 메서드로, 서버를 만드는 역할을 한다.
    해당 함수 내부에서 라우트를 선언할 수 있으며, 라우트 내부에서 서버 코드를 작성하는 것과 동일하게 코드를 작성하고, 응답하는 데이터만 미리 준비해 둔 모킹 데이터를 반환하면 된다.

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

  2. 테스트 코드를 시작하기 전 서버를 기동하고, 테스트 코드 종료시 서버를 종료시킨다.
    resetHandlers()는 앞에서 설정한 setupServer의 기본 설정으로 되돌리는 역할을 함

  3. 일반적인 테스트 코드와 비슷하지만 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이기 때문에
})


테스트 작성할 때 고려할 점

  1. 테스트 커버리지는 얼마나 많은 코드가 테스트되고 있는지를 나타내는 지표일 뿐, 테스트가 잘되고 있는지를 나타내는 것은 아니다.
  2. 테스트 코드를 작성하기 전에 애플리케이션에서 가장 취약하거나 중요한 부분을 파악해야함

참고하기 좋은 자료 https://yozm.wishket.com/magazine/detail/2435/

profile
북한코뿔소
post-custom-banner

0개의 댓글