Typescript + React + React Testing Library + Jest 환경 구성 및 몇가지 간단한 테스트 코드 예시

Explanation

오늘은 제목 처럼 react-testing-library를 이용한 간단한 테스트 환경과 몇가지 예시?를 적어보려 합니다!
(대략 오늘의 프로젝트 구성은, typescript, react, webpack, jest, react-testing-library 입니다.)

늘 그렇듯 포스팅에 사용된 코드는 깃헙에서 확인하실 수 있습니다.
https://github.com/falsy/blog-post-example/tree/master/react-testing-library

1. 패키지 설치

순서가 좀 멋대로긴 한데.. 우선 우리 바벨 친구들 먼저 설치해줄게요.

$ npm i -D @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript

다음으로 리액트를 설치해주고

$ npm i react react-dom
$ npm i -D @types/react @types/react-dom

타입스크립트와 웹팩도 설치해주고

$ npm i -D typescript webpack webpack-cli webpack-dev-server

이렇게 하나하나 적다보니, 분명히 전에는 package.json의 디펜던시 부분 그냥 복.붙.했었는데 오늘은 왜 이렇게 적고 있는 걸까요.
(이상함을 느꼈지만 이미 시작했으니...)

이어서 웹팩에서 사용할 플러그인과 로더를 설치해 줄게요.

$ npm i -D html-webpack-plugin babel-loader html-loader

이제 거의 다 왔습니다! 끝으로 jest와 react-testing-library를 설치해줄게요

$ npm i -D jest @types/jest @testing-library/react @testing-library/jest-dom @testing-library/user-event

2. 패키지 설정

패키지 설정들은 아주아주 간단하게 보편적으로(아마도?)

// .babelrc
{
  "presets": [
    "@babel/preset-typescript",
    "@babel/preset-env",
    "@babel/preset-react"
  ]
}

타입스크립트 설정

// tsconfig.json
{
  "compilerOptions": {
    "target": "es2015",
    "lib": ["dom", "dom.iterable", "esnext"],
    "skipLibCheck": true,
    "strict": false,
    "forceConsistentCasingInFileNames": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "jsx": "react-jsx",
    "declaration": true,
    "declarationDir": "types",
    "outDir": "dist",
    "allowSyntheticDefaultImports": true,
    "baseUrl": ".",
    "experimentalDecorators": true
  },
  "include": [
    "src/**/*.*"
  ],
  "exclude": [
    "node_modules"
  ]
}

웹팩 설정

// webpack.config.js
const HTMLWeebPackPlugin = require('html-webpack-plugin')

module.exports = {
  entry: ['./src/index.tsx'],
  module: {
    rules: [
      {
        test: /\.(js|ts)x?$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader'
        }
      },
      {
        test: /\.html$/,
        use: [
          {
            loader: 'html-loader',
            options: { minimize: true }
          }
        ]
      }
    ]
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.jsx', '.js']
  },
  plugins: [
    new HTMLWeebPackPlugin({
      template: './src/index.html',
      filename: './index.html'
    })
  ],
  devServer: {
    host: 'localhost',
    historyApiFallback: true
  }
}

3. 테스트 코드 작성

// src/__test__/main.test.tsx
import React from 'react'
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import '@testing-library/jest-dom'
import App from '../App'

test('App에서 hello를 클릭하면 world의 출력을 토글할 수 있다.', async () => {

  render(
    <App />
  )

  const user = userEvent.setup()

  // hello 라는 텍스트가 문서에 있고
  expect(screen.getByText('hello')).toBeInTheDocument()
  // text 라는 클래스를 가진 엘리먼트는 없다.
  expect(document.getElementsByClassName('text').length).toBe(0)

  // hello 라는 텍스트를 클릭하면
  await user.click(screen.getByText('hello'))
  // text 라는 클래스를 가진 엘리먼트가 하나 있다.
  expect(document.getElementsByClassName('text').length).toBe(1)

  // hello 라는 텍스트를 다시 클릭하면
  await user.click(screen.getByText('hello'))
  // text 라는 클래스를 가진 엘리먼트는 없다.
  expect(document.getElementsByClassName('text').length).toBe(0)
})

4. 간단한 샘플 프로젝트

// src/App.tsx
import React, { useState } from 'react'

export default () => {

  const [display, setDisplay] = useState(false)

  const handleClickDisplay = () => {
    setDisplay(!display)
  }

  return (
    <>
      <p className='toggle' onClick={handleClickDisplay}>
        hello
      </p>
      {display && (
        <p className='text'>
          world
        </p>
      )}
    </>
  )
}
// src/index.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'

const container = document.getElementById('wrap') as HTMLElement
const root = ReactDOM.createRoot(container)

root.render(
  <App />
)

5. 테스트 커맨드 추가 및 테스트

// package.json
...
  "scripts": {
    "start": "webpack-dev-server --mode development",
    "test": "jest"
  },
...
$ npm run test

하면 끝!

일 줄 알았지만,

Test environment jest-environment-jsdom cannot be found. Make sure the testEnvironment configuration option points to an existing node module.

jest-environment-jsdom 가 필요하다고 하네요! 당장 설치!

$ npm i -D jest-environment-jsdom

다시 실행

$ npm run test

하면 끝!

일 줄 알았지만(2),

The error below may be caused by using the wrong test environment, see https://jestjs.io/docs/configuration#testenvironment-string.
Consider using the "jsdom" test environment.

오류 메시지의 링크를 타고 가서 보면, 파일 상단에 주석을 넣으면 된다고 하네요!
https://jestjs.io/docs/configuration#testenvironment-string

// src/__test__/main.test.tsx
/**
 * @jest-environment jsdom
 */
import React from 'react'
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import '@testing-library/jest-dom'
import App from '../App'

test('App에서 hello를 클릭하면 world의 출력을 토글할 수 있다.', async () => {
  ...

다시 실행

$ npm run test

하면 성공!

끝

 

이라고 하기엔.. 글의 정보가 너무 누가 칼들고 협박해서 어쩔 수 없이 급하게 쓴 글 같으니..

몇가지 상황을 조금 적어볼게요!

6. 즉각적으로 변하지 않아서 조금 기다려야 하는 경우

// src/__test__/waitFor.test.tsx
...
  const handleClickDisplay = () => {
    setTimeout(() => {
      setDisplay(!display)
    }, 500)
  }
...

만약에 위처럼 이벤트가 바로 반영되지 않는 경우 테스트가 실패하게 되는데요. 이럴때는

// src/__test__/main.test.tsx
...
test('App에서 hello를 클릭하면 world의 출력을 토글할 수 있다.', async () => {
  render(
    <App />
  )

  const user = userEvent.setup()

  expect(screen.getByText('hello')).toBeInTheDocument()
  expect(document.getElementsByClassName('text').length).toBe(0)

  await user.click(screen.getByText('hello'))
  await waitFor(() => expect(document.getElementsByClassName('text').length).toBe(1))
})

이렇게 waitFor를 사용하면 변화를 기다렸다가 평가해준답니다.

변화를 무조건 기다리는 건 아니고 기본 타임아웃은 1000ms 라고 하네요!
(참고)
https://testing-library.com/docs/dom-testing-library/api-async/

7. useContext를 사용한 커스텀 훅 테스트하기

// src/__test__/context.test.tsx
/**
 * @jest-environment jsdom
 */
import React, { useContext, useState } from 'react'
import { act, renderHook } from '@testing-library/react'
import '@testing-library/jest-dom'

const AppContext = React.createContext(null)

const AppProvider = ({ children }) => {
  const [isBoolean, setBoolean] = useState(false)

  return (
    <AppContext.Provider value={{ isBoolean, setBoolean }}>{children}</AppContext.Provider>
  )
}

const useAppHook = () => {
  const { isBoolean, setBoolean } = useContext(AppContext)

  return { isBoolean, setBoolean }
}

test('useAppHook 을 사용하여 isBoolean 값을 토글할 수 있다.', async () => {
  const wrapper = ({ children }) => <AppProvider>{children}</AppProvider>
  const { result } = renderHook(() => useAppHook(), { wrapper })
  
  expect(result.current.isBoolean).toBe(false)

  act(() => {
    result.current.setBoolean(true)
  })

  expect(result.current.isBoolean).toBe(true)
})

짜잔! 이렇게 renderHook 과 act 를 사용하면 useContext를 사용한 커스텀 훅을 테스트 할 수 있답니다.

(참고)
https://react-hooks-testing-library.com/usage/advanced-hooks

8. window 객체 속성 정의하기

음... 테스트를 하다보면 window 객체의 특정 값이 필요할 때가 있는데요, 예를 들어 innerHeight 값이 필요하다면!

// src/__test__/window.test.tsx
/**
 * @jest-environment jsdom
 */
import React from 'react'
import {render, screen} from '@testing-library/react'
import '@testing-library/jest-dom'

const App = () => {
  return (
    <p>{window.innerHeight}</p>
  )
}

test('window의 innerHeight 값을 설정할 수 있다.', async () => {
  window = Object.create(window)
  Object.defineProperty(window, 'innerHeight', {
    value: 200,
    writable: true
  })

  render(
    <App />
  )

  expect(screen.getByText('200')).toBeInTheDocument()
})

9. 이벤트 테스트 하기

간단하게 터치 이벤트를 만들어서 테스트 해볼게요.

// src/__test__/evnet.test.tsx
/**
 * @jest-environment jsdom
 */
import React, { TouchEvent, useState } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'

const App = () => {
  const [touchStatus, setTouchStatus] = useState('')

  const handleTouchStart = (e: TouchEvent<HTMLDivElement>) => {
    const touchY = e.touches[0].clientY
    setTouchStatus(`start clientY: ${touchY}`)
  }

  const handleTouchMove = (e) => {
    const touchY = e.touches[0].clientY
    setTouchStatus(`move clientY: ${touchY}`)
  }

  const handleTouchEnd = () => {
    setTouchStatus('end')
  }

  return (
    <div 
      className='event-area' 
      onTouchStart={(e) => handleTouchStart(e)}
      onTouchMove={(e) => handleTouchMove(e)}
      onTouchEnd={handleTouchEnd}
    >
      hello world
      <p>{touchStatus}</p>
    </div>
  )
}

test('터치 이벤트 상태의 변화에 따른 값을 테스트 할 수 있다.', async () => {
  render(
    <App />
  )

  const eventArea = document.querySelector('.event-area')

  fireEvent.touchStart(eventArea, { touches: [{ clientY: 100 }] })
  expect(screen.getByText('start clientY: 100')).toBeInTheDocument()

  fireEvent.touchMove(eventArea, { touches: [{ clientY: 50 }] })
  expect(screen.getByText('move clientY: 50')).toBeInTheDocument()
  
  fireEvent.touchEnd(eventArea)
  expect(screen.getByText('end')).toBeInTheDocument()
})

끝