NodeJS에서 Firebase의 RemoteConfig 사용하기
Typescript + React + React Testing Library + Jest 환경 구성 및 몇가지 간단한 테스트 코드 예시
2023-11-26
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 |
다음으로 리액트를 설치해주고
1 |
$ npm i react react-dom |
1 |
$ npm i -D @types/react @types/react-dom |
타입스크립트와 웹팩도 설치해주고
1 |
$ npm i -D typescript webpack webpack-cli webpack-dev-server |
이렇게 하나하나 적다보니, 분명히 전에는 package.json의 디펜던시 부분 그냥 복.붙.했었는데 오늘은 왜 이렇게 적고 있는 걸까요.
(이상함을 느꼈지만 이미 시작했으니…)
이어서 웹팩에서 사용할 플러그인과 로더를 설치해 줄게요.
1 |
$ npm i -D html-webpack-plugin babel-loader html-loader |
이제 거의 다 왔습니다! 끝으로 jest와 react-testing-library를 설치해줄게요
1 |
$ npm i -D jest @types/jest @testing-library/react @testing-library/jest-dom @testing-library/user-event |
패키지 설정들은 아주아주 간단하게 보편적으로(아마도?)
1 2 3 4 5 6 7 8 |
// .babelrc { "presets": [ "@babel/preset-typescript", "@babel/preset-env", "@babel/preset-react" ] } |
타입스크립트 설정
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
// 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" ] } |
웹팩 설정
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
// 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 } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
// 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) }) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// 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> )} </> ) } |
1 2 3 4 5 6 7 8 9 10 11 |
// 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 /> ) |
1 2 3 4 5 6 7 |
// package.json ... "scripts": { "start": "webpack-dev-server --mode development", "test": "jest" }, ... |
1 |
$ npm run test |
하면 끝!
일 줄 알았지만,
1 |
Test environment jest-environment-jsdom cannot be found. Make sure the testEnvironment configuration option points to an existing node module. |
jest-environment-jsdom 가 필요하다고 하네요! 당장 설치!
1 |
$ npm i -D jest-environment-jsdom |
다시 실행
1 |
$ npm run test |
하면 끝!
일 줄 알았지만(2),
1 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
1 2 3 4 5 6 7 8 9 10 11 12 |
// 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 () => { ... |
다시 실행
1 |
$ npm run test |
하면 성공!
이라고 하기엔.. 글의 정보가 너무 누가 칼들고 협박해서 어쩔 수 없이 급하게 쓴 글 같으니..
몇가지 상황을 조금 적어볼게요!
1 2 3 4 5 6 7 8 |
// src/__test__/waitFor.test.tsx ... const handleClickDisplay = () => { setTimeout(() => { setDisplay(!display) }, 500) } ... |
만약에 위처럼 이벤트가 바로 반영되지 않는 경우 테스트가 실패하게 되는데요. 이럴때는
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// 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/
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
// 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
음… 테스트를 하다보면 window 객체의 특정 값이 필요할 때가 있는데요, 예를 들어 innerHeight 값이 필요하다면!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
// 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() }) |
간단하게 터치 이벤트를 만들어서 테스트 해볼게요.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
// 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() }) |