[React] Deep Dive 모던 리액트(21) 테스트
FrontEnd/Deep Dive

[React] Deep Dive 모던 리액트(21) 테스트

728x90

 

 

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

 

FE와 BE 모두 테스트 과정이 중요하지만 방법과 방법론은 조금 다르다. BE의 테스트는 서버,DB에서 원하는 데이터를 올바로 가져올 수 있는지 , 데이터 간 교착상태나 경쟁상태가 발생하지는 않는지 등등을 테스트한다. 이러한 백엔드 텍스트는 일반적으로 화이트박스 테스트이다로 GUI가 아닌 AUI에서 주로 수행하기 때문에 백엔드에 대한 이해도가 어느정도 있는 사람만 가능하다.

반면 FE는 일반적인 사용자와 동일하거나 유사한 환경에서 수행된다. 사용자가 프로그램에서 수행할 모든 경우의 수를 고려해야 하며 사용자가 FE코드를 알 필요가 굳이 없는 블랙박스 형태로 테스트가 이뤄진다. 특히 사용자는 개발자의 의도대로만 프로그램을 사용하지 않기에 이를 예측하여 테스트 하는것이 중점이다.

 

FE개발은 HTML,CSS와 같은 디자인 요소와 사용자의 인터랙션 , 버그 감지 등 다양한 시나리오를 감지해야 하기때문에 테스팅이 매우 번거롭고 손이 많이 간다. 테스팅 라이브러리 또한 단순한 유닛 테스트부터 사용자가 하는 작동을 흉내내는 복잡한 테스트까지 지원해준다.

 

 

React Testing Libray

ReactTestingLibray는 DOM Testing Libray를 기반으로 만들어졌으며 리액트를 기반으로 한 테스트를 수행하기 위해 만들어졌다. 따라서 DOM Testing Libray에 대해 알아둘 필요가 있다.

 

https://github.com/jsdom/jsdom

 

GitHub - jsdom/jsdom: A JavaScript implementation of various web standards, for use with Node.js

A JavaScript implementation of various web standards, for use with Node.js - GitHub - jsdom/jsdom: A JavaScript implementation of various web standards, for use with Node.js

github.com

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"

 

 

jsdom을 활용하면 HTML이 있는것 처럼 DOM을 불러오고 조작하는 것이 가능하다. 이 jsdom을 활용하여 JS환경에서 HTML을 사용할 수 있는 DOM Testing Libray를 기반으로 릴액트에서 테스트 할 수 있다.

 

 

 

JS 테스트의 기초

먼저 JS 테스트 코드를 어떻게 작성하는지 알아보자. 아래와 같은 단순한 함수를 만들어 보자.

 

function sum(a,b) {
	return a+b
}

 

 

테스트란 내가 작성한 코드가 의도와 목적에 맞는지 확인하는 코드이다. 따라서 아래와 같이 테스트 코드를 작성해 볼 수 있다.

 

let actual = sum(1,2)

let expected =3

if(expectd !== actual){
	throw new Error(`${expected} is not equal to ${actual}`)
}

actual = sum(2,2)

expected = 4

if(expectd !== actual){
	throw new Error(`${expected} is not equal to ${actual}`)
}

 

 

즉 기본적인 테스트 코드를 작성하는 방식은 아래 과정을 거치게 된다.

 

1. 테스트할 함수나 모듈을 선정
2. 함수나 모듈이 반환하는 기대값을 설정
3. 함수,모듈의 실제 반환값을 적음
4. 실제 반환값과 기대값을 비교
5. 같다면 성공 , 다르다면 실패를 반환

 

 

Node.js는 assert라는 모듈을 기본적으로 제공하며 이를 활용하여 아래와 같이 사용할 수 있다.

 

const assert = require('assert')

function sum(a,b){
	return a+b
}

assert.equal(sum(1,2),3)
assert.equal(sum(2,2),4)
assert.equal(sum(1,2),4) //AssertionError [ERR_ASSERTION] [ERR_ASSERTION] : 3 == 4

 

 

일반적으로 테스트 코드와 실제 코드는 분리하여 작성하는 것이 좋다. assert 모듈을 활용하여 실패에 대한 에러를 던지는 작업을 조금 더 단순화 한것을 알 수 있으며 이렇게 테스트를 도와주는 라이브러리를 assertion 라이브러리 라고 한다. Node.js는 assert이외에도 should.js , expect.js 등 다양한 어설션 라이브러리를 지원하며 equal 함수 이외에도 deepEqual , notEqual , thorws 등 다양한 메서드 또한 제공한다.

 

deepEqual : 객체의 완전한 비교
notEqual : 같지 않은지 비교
thorws : 에러를 발생하는지 비교

 

 

테스트 코드는 사람이 읽기 쉽고 테스트의 목적이 분명하게 작성되는 것이 중요하다. 좋은 테스트 코드는 단순히 코드가 통과되는게 아니라 어떤 목적으로 테스트를 하는지 보여주는 것이 중요하다.

 

이러한 테스트의 기승전결을 완성해 주는 것을 도와주는 것이 테스팅 프레임워크이다. 윰여한 테스팅 프레임워크는 Jest,Mocha , Karma 등이 있다. 리액트에서는 메타에서 작성한 Jest가 널리 사용되고 있다.

 

앞서 작성했던 코드를 Jest로 바꿔보자. 먼저 테스트 코드와 실제 코드를 분리하자.

math.js
function sum(a,b) {
	return a+b
}

module.exports = {
	sum,	
}

 

 

//math.test.js
import { sum } from "./math.js";

test("덧셈이 되는가", () => {
  expect(sum(1, 2)).toBe(3);
});

test("덧셈이 되는가2", () => {
  expect(sum(2, 2)).toBe(3);
});

 

 

npm i jest
npm run test

 

실패 모습

 

 

테스팅 라이브러리를 사용하여 테스트에 대한 결과를 조금 더 폭넓게 볼 수 있음을 확인할 수 있다.

주의할 점은 test파일을 node 환경으로 실행하면 에러가 나며 종료되고 위와같은 정보를 보기위해서는 jest로 실행해야 한다는 점을 알아두자.

 

또다른 속성으로는 Jest는 expect,jest,test 등 테스트에 필요한 메서드들을 전역으로 등록시켜놓기 때문에 별다른 import 과정이 필요없다.

import {expect,jest,test} from '@jest/globals' // 필요 XXXXX

 

 

 

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

이제 컴포넌트를 테스트 해 보자. 리액트 컴포넌트 테스트는 아래와 같은 방법으로 보통 이뤄진다.

 

1. 컴포넌트 렌더링
2. 컴포넌트에서 특정 액션 수행 (필요시)
3. 컴포넌트 렌더링과 2번의 액션 기댓값과 비교

 

 

CRA를 통해 프로젝트를 하나 만들어주자. CRA에는 jest가 이미 들어있으므로 별도의 설치는 필요없다.

npx create-react-app react-test --template typescript

 

 

App.tsx파일을 잠시 보자.

 

import logo from './logo.svg'
import './App.css'
import StaticComponent from './components/StaticComponent'

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.tsx</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
    </div>
  )
}

export default App

 

 

이어서 기본적으로 App.test.tsx파일을 보면 App컴포넌트를 테스트하고 있음을 알 수 있다.

 

import { render, screen } from '@testing-library/react'

import App from './App'

test('renders learn react link', () => {
  render(<App />)
  const linkElement = screen.getByText(/learn react/i)
  expect(linkElement).toBeInTheDocument()
})

 

App을 렌더링 한 후 해당 컴포넌트에서 learn react라고 적힌 DOM 요소를 찾은 후 toBeInTheDocument로 해당 요소가 document 내부에 있는지 확인한다.

 

이렇게 리액트 컴포넌트에서 특정한 무언가를 지닌 HTML 요소가 있는지 확인하는 경우가 많다.

 

getBy... : 인수에 조건에 맞는 요소를 반환한다. 복수 개를 찾으려면 getAllBy.. 를 사용해야 한다.
findBy... :  getBy와 유사하지만 Promise를 반환하며 기본적으로 1s의 타임아웃을 가지고 있다.
queryBy... : 요소를 찾지 못한다면 null이 반환된다. 즉 찾지 못한경우에 에러를 일으키지 않고 싶을때 사용한다.

 

컴포넌트를 테스트하는 파일은 같은 디렉터리상에 위치시키는게 일반적이다. 또한 test파일은 번들링에서 제외하므로 이런 부분도 신경 쓸 필요가 없다.

 

 

정적 컴포넌트

정적 컴포넌트는 항상 결과를 반환한다. 보통 해당 컴포넌트를 렌더링 한다음 테스트를 원하는 요소를 찾아 비교한다.

 

import { memo } from 'react'

const AnchorTagComponent = memo(function AnchorTagComponent({
  name,
  href,
  targetBlank,
}: {
  name: string
  href: string
  targetBlank?: boolean
}) {
  return (
    <a
      href={href}
      target={targetBlank ? '_blank' : undefined}
      rel="noopener noreferrer"
    >
      {name}
    </a>
  )
})

export default function StaticComponent() {
  return (
    <>
      <h1>Static Component</h1>
      <div>유용한 링크</div>

      <ul data-testid="ul" style={{ listStyleType: 'square' }}>
        <li>
          <AnchorTagComponent
            targetBlank
            name="리액트"
            href="https://reactjs.org"
          />
        </li>
        <li>
          <AnchorTagComponent
            targetBlank
            name="네이버"
            href="https://www.naver.com"
          />
        </li>
        <li>
          <AnchorTagComponent name="블로그" href="https://yceffort.kr" />
        </li>
      </ul>
    </>
  )
}

 

 

위 정적 컴포넌트는 아래와 같이 테스트할 수 있다.

import { render, screen } from '@testing-library/react'

import StaticComponent from './index'

beforeEach(() => {
  render(<StaticComponent />)
})

describe('링크 확인', () => {
  it('링크가 3개 존재한다.', () => {
    const ul = screen.getByTestId('ul')
    expect(ul.children.length).toBe(3)
  })

  it('링크 목록의 스타일이 square다.', () => {
    const ul = screen.getByTestId('ul')
    expect(ul).toHaveStyle('list-style-type: square;')
  })
})

describe('리액트 링크 테스트', () => {
  it('리액트 링크가 존재한다.', () => {
    const reactLink = screen.getByText('리액트')
    expect(reactLink).toBeVisible()
  })

  it('리액트 링크가 올바른 주소로 존재한다.', () => {
    const reactLink = screen.getByText('리액트')

    expect(reactLink.tagName).toEqual('A')
    expect(reactLink).toHaveAttribute('href', 'https://reactjs.org')
  })
})

describe('네이버 링크 테스트', () => {
  it('네이버 링크가 존재한다.', () => {
    const naverLink = screen.getByText('네이버')
    expect(naverLink).toBeVisible()
  })

  it('네이버 링크가 올바른 주소로 존재한다.', () => {
    const naverLink = screen.getByText('네이버')

    expect(naverLink.tagName).toEqual('A')
    expect(naverLink).toHaveAttribute('href', 'https://www.naver.com')
  })
})

describe('블로그 링크 테스트', () => {
  it('블로그 링크가 존재한다.', () => {
    const blogLink = screen.getByText('블로그')
    expect(blogLink).toBeVisible()
  })

  it('블로그 링크가 올바른 주소로 존재한다.', () => {
    const blogLink = screen.getByText('블로그')

    expect(blogLink.tagName).toEqual('A')
    expect(blogLink).toHaveAttribute('href', 'https://yceffort.kr')
  })

  it('블로그는 같은 창에서 열려야 한다.', () => {
    const blogLink = screen.getByText('블로그')
    expect(blogLink).not.toHaveAttribute('target')
  })
})

 

 

 

beforeEach : 각 테스트들을 실행하기 전에 실행하는 함수
describe : 비슷한 속성을 가진 테스트를 하나로 묶음 describe 안에 describe를 사용하는것도 가능
it : test와 완전히 동일하며 축약어이다.
testId : get등의 선택자로 선택하기 어렵거나 곤란한 요소를 선택하기 위해 사용한다.

 

 

동적 컴포넌트

useState를 사용하여 상태값을 관리하는 컴포넌트를 보자.

 

import { useState } from 'react'

export function InputComponent() {
  const [text, setText] = useState('')

  function handleInputChange(event: React.ChangeEvent<HTMLInputElement>) {
    const rawValue = event.target.value
    const value = rawValue.replace(/[^A-Za-z0-9]/gi, '')
    setText(value)
  }

  function handleButtonClick() {
    alert(text)
  }

  return (
    <>
      <label htmlFor="input">아이디를 입력하세요.</label>
      <input
        aria-label="input"
        id="input"
        value={text}
        onChange={handleInputChange}
        maxLength={20}
      />
      <button onClick={handleButtonClick} disabled={text.length === 0}>
        제출하기
      </button>
    </>
  )
}

 

 

사용자의 입력을 받는 간단한 페이지이다! 해당 컴포넌트는 아래와 같이 테스트할 수 있다.

 

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('input의 최대길이가 20자로 설정되어 있다.', () => {
    const { input } = setup()
    expect(input).toHaveAttribute('maxlength', '20')
  })

  it('영문과 숫자만 입력된다.', () => {
    const { input } = setup()
    const inputValue = '안녕하세요123'
    userEvent.type(input, inputValue)
    expect(input.value).toEqual('123')
  })

  it('아이디를 입력하지 않으면 버튼이 활성화 되지 않는다.', () => {
    const { button } = setup()
    expect(button).toBeDisabled()
  })

  it('아이디를 입력하면 버튼이 활성화 된다.', () => {
    const { button, input } = setup()

    const inputValue = 'helloworld'
    userEvent.type(input, inputValue)

    expect(input.value).toEqual(inputValue)
    expect(button).toBeEnabled()
  })

  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)
  })
})

 

 

이번에도 추가된 테스트 함수들을 보자.

 

setup : 내부에서 컴포넌트를 렌더링하고 테스트에 필요한 button,input을 반환한다.
userEvent.type : 사용자가 타이핑 하는것을 흉내내는 메서드이다. fireEvent보다 조금더 복잡한 기능을 작동할 수 있다.
예를 들어 userEvent.click를 수행하면 fireEvent.MouseOver -> fireEvent.mouseMove -> fireEvent.mouseDown -> fireEvent.mouseUp -> fireEvent.click 을 수행한다.

jest.spyOn(window,'alert').mockImplementation() 
   spyOn : 특정 메서드를 오염시키지 않고 실행이 되었는지 , 어떤 인수로 실행되었는지 실행과 관련된 정보만 얻고 싶을때 사용한다. 즉 window 객체의 메서드 alert를 구현하지 않고 메서드가 실행되었는지를 관찰한다는 의미이다.
const calc = {
	add : (a,b) => a+b,
}

const apyFn = jest.spyOn(calc,'add')
const result = calc.add(1,2)

expect(spyFn).toBeCalledTimes(1)
expect(spyFn).toBoCalledWith(1,2)
expect(result).toBe(3)​


위와같이 spyOn으로 몇번 호출되었는지 , 원하는 인수로 호출되었는지 관찰할 수 있으며 calc.add의 작동 자체에는 영향을 미치지 않은 것을 확인할 수 있다.


  mockImplemetation : 해당 메서드에 대한 모킹을 도와준다. Jest환경에서는 window.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 handleButtonClick(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 style={{ backgroundColor: 'red' }}>에러가 발생했습니다</p>}

      <ul>
        {Array.from({ length: 10 }).map((_, index) => {
          const id = index + 1
          return (
            <button key={id} data-id={id} onClick={handleButtonClick}>
              {`${id}번`}
            </button>
          )
        })}
      </ul>
    </div>
  )
}

 

 

 

 

 

먼저 위와같은 api요청처리를 하는 컴포넌트를 처리하기 위해서는 fetch를 모킹하는 것이 필요하다. 물론 jest.spyOn등을 활용해서 fetch를 모킹하는 것도 가능할 것이다. 하지만 fetch를 모킹하는 것은 많은 메서드를 구현해야 하며 서버 응답에서 오류가 발생한 경우에는 이를 테스트 할수가 없다.

 

이러한 문제를 해결하기 위해 MSW(Mock Service Worker)란 라이브러리가 있다. 해당 라이브러리를 사용하면 실제 네트워크 요청이 이러날때 라이브러리가 가로채버린다. 즉 실제로 fetch 요청을 보내는것은 동일하며 요청을 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 />)
})

describe('FetchComponent 테스트', () => {
  it('데이터를 불러오기 전에는 기본 문구가 뜬다.', async () => {
    const nowLoading = screen.getByText(/불러온 데이터가 없습니다./)
    expect(nowLoading).toBeInTheDocument()
  })

  it('버튼을 클릭하면 데이터를 불러온다.', async () => {
    const button = screen.getByRole('button', { name: /1번/ })
    fireEvent.click(button)

    const data = await screen.findByText(MOCK_TODO_RESPONSE.title)
    expect(data).toBeInTheDocument()
  })

  it('버튼을 클릭하고 서버요청에서 에러가 발생하면 에러문구를 노출한다.', async () => {
    server.use(
      rest.get('/todos/:id', (req, res, ctx) => {
        return res(ctx.status(503))
      }),
    )

    const button = screen.getByRole('button', { name: /1번/ })
    fireEvent.click(button)

    const error = await screen.findByText(/에러가 발생했습니다/)
    expect(error).toBeInTheDocument()
  })
})

 

 

 

아래 코드를 집중해서 보자. 테스트 코드를 실행하기 전에 서버를 기동하고 테스트 코드를 실행하기 전에 서버를 종료시킨다. 

 

afterEach의 resetHandlers를 통해서 setupServer의 기본설정으로 되돌리는 것을 할 수 있다.

'서버에서 실패하는 경우' 를 테스트하기 위해 res를 임의로 ctx.status(503)과 같은 형태로 변경하게 되는데 이를 리셋하지 않으면 계속해서 실패하는 코드로 남겨 있기에 테스트 실행마다 resetHandlers()를 통해 setupServer로 초기화했던 초기값을 유지할 수 있게 된다.

 

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

beforeEach(() => {
  render(<FetchComponent />)
})

 

 

api를 사용하는 test를 작성할 시 아래어처럼 get메서드가 아닌 find 메서드를 사용해야 한다는 점을 유의하자.

it('버튼을 클릭하고 서버요청에서 에러가 발생하면 에러문구를 노출한다.', async () => {
    server.use(
      rest.get('/todos/:id', (req, res, ctx) => {
        return res(ctx.status(503))
      }),
    )

    const button = screen.getByRole('button', { name: /1번/ })
    fireEvent.click(button)

    const error = await screen.findByText(/에러가 발생했습니다/)
    expect(error).toBeInTheDocument()
  })

 

특히 우리가 MSW에서 정상적인 응답만 모킹해뒀는데 위 에러 감지 테스트에서 server.use로 모든 라우팅에 503 에러가 오도록 작성한 점을 알 수 있다.

 

물론 지금은 에러 테스트가 마지막에 있기 때문에 resetHandlers를 없앤다고 테스트 결과가 달라지지 않는다. 다만 만약 순서를 바꾸고 resetHandlers가 없다면 503 에러로 반환하는 mock api가 남아있게 된다는 점을 꼭 생각하자.

 

 

 

사용자 정의 훅 테스트하기

사용자 정의 훅을 테스트하려면 훅이 들어간 컴포넌트를 만들거나 해당 컴포넌트에서 별도의 작업을 하는 경우가 있다 하지만 이경우 코드 로직이 분산되며 번거롭다는 단점이 있다.

react-hooks-testing-libray를 활용하면 이를 해결할 수 있다.

 

useEffectDebugger라는 훅을 만들어 보자.

1. 최초 컴포넌트 렌더링 시에는 호출 X
2. 이전 props를 useRef에 저장해두고 props를 넘겨받을 때마다 이전 props와 비교해 무엇이 렌더링이 발생시켰는지 확인
3. Object.is로 얕은 비교를 수행하여 props를 비교
4. process.env.NODE_ENV=== 'production'인 경우에는 로깅을 하지 않음

 

 

보면 알겠지만 useEffect의 원리를 보여주는 훅이다.

 

 

import { useEffect, useRef } 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) {
        // eslint-disable-next-line no-console
        console.log(CONSOLE_PREFIX, componentName, changedProps)
      }
    }

    prevProps.current = props
  })
}

 

 

 

 

이런 훅은 아래와 같이 작성할 수 있다.

 

이번에는 함수에 대한 내용들을 주석으로 달아두었다!! 주석을 읽어보며 각 메서드의 역할에 대해 이해해보자.

import { renderHook } from '@testing-library/react' //react18이상부터 통합됨

import useEffectDebugger, { CONSOLE_PREFIX } from './useEffectDebugger'

const consoleSpy = jest.spyOn(console, 'log') //console.log 호출 여부 확인
const componentName = 'TestComponent' // 테스트 대상 컴포넌트의 이름을 저장

describe('useEffectDebugger', () => {
  afterAll(() => {
    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    process.env.NODE_ENV = 'development' //테스트가 끝나면 다시 변경. 타입스크립트에서 NODE_ENV를 읽기 속성으로 간주하기 때문
  })

  it('props가 없으면 호출되지 않는다.', () => {
    renderHook(() => useEffectDebugger(componentName))

    expect(consoleSpy).not.toHaveBeenCalled()
  })

  it('최초에는 호출되지 않는다.', () => {
    const props = { hello: 'world' }

    //훅을 렌더링 하기 위해서 renderHook을 래핑하여 사용해야 함
    //renderHook내부에서 컴포넌트를 만들고 훅을 만들어준다.
    renderHook(() => useEffectDebugger(componentName, props))

    expect(consoleSpy).not.toHaveBeenCalled()
  })

  it('props가 변경되지 않으면 호출되지 않는다.', () => {
    const props = { hello: 'world' }

    //rerender외에 unmount라는 함수도 있다. (컴포넌트를 언마운트함)
    const { rerender } = renderHook(() =>
      useEffectDebugger(componentName, props),
    )

    expect(consoleSpy).not.toHaveBeenCalled()

    //rerender함수를 활용하면 훅을 재생성하는 것을 활용할 수 있다.

    rerender()

    expect(consoleSpy).not.toHaveBeenCalled()
  })

  it('props가 변경되면 다시 호출한다.', () => {
    const props = { hello: 'world' }

    //initialProps를 통해서 훅의 초기값을 정할 수 있음
    const { rerender } = renderHook(
      ({ componentName, props }) => useEffectDebugger(componentName, props),
      {
        initialProps: {
          componentName,
          props,
        },
      },
    )

    const newProps = { hello: 'world2' }

    rerender({ componentName, props: newProps })

    expect(consoleSpy).toHaveBeenCalled()
  })

  it('props가 변경되면 변경된 props를 정확히 출력한다', () => {
    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).toHaveBeenCalledWith(CONSOLE_PREFIX, 'TestComponent', {
      hello: { after: 'world2', before: 'world' },
    })
  })

  it('객체는 참조가 다르다면 변경된 것으로 간주한다', () => {
    const props = { hello: { hello: 'world' } }
    const newProps = { hello: { hello: 'world' } }

    const { rerender } = renderHook(
      ({ componentName, props }) => useEffectDebugger(componentName, props),
      {
        initialProps: {
          componentName,
          props,
        },
      },
    )

    rerender({ componentName, props: newProps })

    // 이후 호출
    expect(consoleSpy).toHaveBeenCalled()
  })

  it('process.env.NODE_ENV가 production이면 호출되지 않는다', () => {
    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    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()
  })
})

 

 

 

테스트 커버리지란 말이 있다. 해당 소프트웨어가 얼마나 테스트되었는지를 보여주는 지표이다. 하지만 해당 커버리지가 높을 수록 좋은 코드라고는 할 수 없다. 단순히 테스트의 경우가 많은 것 뿐이지 해당 코스트의 질이 좋은지를 판단할 수 없다.

 

심지어 FE의 테스트 코드는 TDD를 차용하더라도 사용자의 활동이 매우 자유롭기 때문에 모든 경우의 수를 생각한다는 것은 사실 불가능에 가깝다. 심지어 이런 복잡한 테스트 코드를 실무에서 작성하고 운영하기엔 여유가 없는 경우도 많다.

 

따라서 테스트 코드를 작성할때는 애플리케이션에서 가장 취약하거나 중요한 부분을 파악하여 해당 부분의 테스트 코드를 짜는것을 우선으로 하는 습관을 들이는 것이 중요하다.

 

FE에는 다양한 테스트 방법들이 존재한다.

 

유닛 테스트 : 각각의 코드나 컴포넌트가 독립적으로 분리된 환경에서 제대로 동작하는지 확인
통합 테스트 : 유닛테스트를 통과한 여러 컴포넌트가 묶여서 하나의 기능으로 작동하는지 확인
엔드 투 엔드 : E2E테스트라고도 하며 실제 사용자처럼 동작하는 로봇ㅇ르 활용하여 APP의 전반적인 기능을 테스트

 

 

리액트 테스팅 라이브러리는 유닛테스트~통합테스트를 도와주는 도구이며 E2E 테스트를 수행하려면 Cypress와 같은 라이브러리의 힘을 빌려야 한다.

 

 

테스트코드의 가장 중요한 목표는 결국 아래와 같다.

 

 

애플리케이션이 비즈니스 요구사항을 충족하는지 확인 할 것

 

 

상황이 여의치 않다면 테스트 코드를 줄여도 괜찮다. 다만 비즈니스 요구사항의 취약사항을 확인해서 해당 부분의 테스트는 꼭 작성해보도록 하자. 또한 처음부터 E2E테스트를 만들지 말고 유닛테스트부터 차근차근 준비하는 것이 필요하다.

728x90