본문 바로가기
FE/모던 리액트 Deep Dive

8. 좋은 리액트 코드 작성을 위한 환경 구축하기 - 테스트(3)

by Toddler_AD 2025. 9. 18.

8.2.4. 사용자 정의 훅 테스트하기

  • 지금까지 일반적인 컴포넌트에 대해 테스트해봤다면 임의로 만든 사용자 훅을 테스트한다고 가정해보자. 훅을 테스트하는 과정도 동일하게 진행할 수 있다. 훅이 들어가 있는 컴포넌트를 만들거나, 혹은 훅이 들어 있는 컴포넌트에 대해 별도로 훅에 대한 테스트를 만들 수도 있을 것이다. 
  • 그러나 전자의 경우 테스트 코드 작성 외에 작업이 더 추가된다는 점, 후자의 경우 해당 훅이 모든 테스트 케이스를 커버하지 못 할 경우 또 다른 테스트 가능한 컴포넌트를 찾아야 한다는 단점이 있다. 
  • 이러한 불편함을 해결하기 위한 라이브러리가 바로 react-hooks-testing-library다. 이를 활용하면 훅을 더욱 편리하게 테스트할 수 있다. react-hooks-testing-library를 활용해 훅을 테스트하는 방법을 알아보자.
  • 먼저 테스트로 작성할 훅은 useEffectDebugger라는 훅이다. 이 훅은 컴포넌트명과 props를 인수로 받아 해당 컴포넌트가 어떤 props의 변경으로 인해 리랜더링 됐는지 확인해 주는 일종의 디버거 역할을 한다. 이 훅이 구현할 기능은 다음과 같다.
    • 최초 컴포넌트 랜더링 시에는 호출하지 않는다.
    • 이전 props를 useRef에 저장해 두고, 새로운 props를 넘겨받을 때마다 이전 props와 비교해 무엇이 랜더링을 발생시켰는지 확인한다.
    • 이전 props와 신규 props의 비교는 리액트의 원리와 동일하게 Object.is를 활용해 얕은 비교를 수행한다.
    • process.env.NODE_ENV === 'production'인 경우에는 로깅을 하지 않는다. 이는 웹팩을 빌드 도구로 사용할 경우 일반적으로 트리쉐이킹이 이뤄지는 일종의 최적화 기법이다. 웹팩을 비롯한 많은 번들러에서는 process.env.NODE_ENV === 'production'인 경우에는 해당 코드가 빌드 결과물에 포함되지 않는다. 이는 운영 환경에서는 해당 코드가 실행되지 않는다는 의미다.
  • 이러한 요구사항을 준수한 useEffectDebugger 훅을 살펴보자.
// src/components/hooks/useEffectDebugger.ts

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
  })
}
  • 그리고 이 훅은 다음과 같이 사용할 수 있다.
// src/App.tsx

import React from 'react';
import logo from './logo.svg';
import './App.css';
import { useState } from 'react';
import useEffectDebugger from './hooks/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;
  • 출력 결과는 다음과 같다.
[useEffectDebugger] TestComponent {"a":{"before":"짝수", "after":"홀수"}, "b":{"before":0, "after":1}}
[useEffectDebugger] TestComponent {"a":{"before":"홀수", "after":"짝수"}, "b":{"before":1, "after":2}}
[useEffectDebugger] TestComponent {"a":{"before":"짝수", "after":"홀수"}, "b":{"before":2, "after":3}}
  • useEffectDebugger는 어디까지나 props가 변경되는 것만 확인할 수 있다는 것을 염두에 둬야 한다. 다른 렌더링 시나리오, 예를 들어 props가 변경되지 않았지만 부모 컴포넌트가 리렌더링 되는 경우에는 useEffectDebugger로 확인할 수 없다.
  • props를 useRef에 저장해두고, 이후에 새롭게 들어오는 props를 비교해 변경된 값만 console.log로 로깅을 남기고 있다. 여러가지 컴포넌트에 적용해 보면서 얼추 의도한 대로 작동한다는 것을 확인할 수 있다. 
  • 하지만, 테스트 코드를 통해 확인하는 편이 훨씬 더 확실하고 실수도 줄이는 안전한 방식일 것이다. useEffectDebugger를 테스트하는 코드를 작성해보자.
import { renderHook } from '@testing-library/react'

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

const consoleSpy = jest.spyOn(console, 'log')
const componentName = 'TestComponent'
  • 먼저 해당 훅은 console.log를 사용하므로 jest.spyOn을 사용해 console.log 호출 여부를 확인한다. 그리고 테스트 대상 컴포넌트의 이름을 componentName에 저장한다.
describe('useEffectDebugger', () => {
  afterAll(() => {
    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    process.env.NODE_ENV = 'development'
   })
   // ....
 })
  • 만약 프로젝트가 리액트 18 버전 미만을 사용한다면 @testing-libarary/react 대신 @testing-library/react-hooks를 사용해야 한다. 리액트 18부터는 @testing-library/react에 통합됐다.
  • 매번 테스트가 끝난 후에는 process.env.NODE_ENV를 다시 deplotment로 변경한다. process.env.NODE_ENV 할당문을 강제로 작성한 이유는 타입스크립트에서는 NODE_ENV를 읽기 전용 속성으로 간주하기 때문이다.
it('props가 없으면 호출되지 않는다.', () => {
    renderHook(() => useEffectDebugger(componentName))

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

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

    renderHook(() => useEffectDebugger(componentName, props))

    expect(consoleSpy).not.toHaveBeenCalled()
  })
  • 훅을 렌더링하기 위해서는 renderHook을 래핑해서 사용해야 한다. 리액트 개발자라면 한 가지 이상한 점을 확인할 수 있는데, use로 시작하는 사용자 정의 훅임에도 불구하고 훅의 규칙을 위반한다는 경고 메시지를 출력하지 않는다는 것이다. 이 코드가 실행될 수 있는 비결은 renderHook 내부에 있다.
  • renderHook 함수를 살펴보면 내부에서 TestComponent라고 하는 컴포넌트를 생성하고, 이 컴포넌트 내부에서 전달받을 훅을 실행하는 것을 알 수 있다. 훅의 규칙을 위반하지 않기 위해 renderHook 내부에서 컴포넌트를 만들어 훅의 규칙을 위반하지 않는 것을 확인할 수 있다.
  • 계속해서 테스트 코드를 작성해보자
it('props가 변경되지 않으면 호출되지 않는다.', () => {
    const props = { hello: 'world' }

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

    expect(consoleSpy).not.toHaveBeenCalled()

    rerender()

    expect(consoleSpy).not.toHaveBeenCalled()
  })
  • 이번 테스트에서 컴포넌트를 다시 렌더링해 훅 내부의 console.log가 실행되지 않는 지를 확인한다. 앞서 설명했듯이 renderHook 내부에서는 컴포넌트를 하나 새로 만들어서 훅을 사용하는데, 만약 renderHook을 한번 더 실행하면 훅을 두 번 실행하는 것을 테스트 할 수 없다. 그 이유는 앞서 이야기했던 훅의 규칙을 우회하기 위한 트릭, 즉 TestComponent의 생성 작업을 두 번 하게 되기 때문이다. 다시 말해, renderHook 하나당 하나의 독립된 컴포넌트가 생성되므로 같은 컴포넌트에서 훅을 두 번 호출하려면 renderHook이 반환하는 객체의 값 중 하나인 rerender 함수를 사용해야 한다. rerender 외에도 unmount라는 함수를 반환하는데, 이름 그대로 이 함수를 실행하면 컴포넌트를 언마운트 한다.
it('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).toHaveBeenCalled()
  })
  • 이후 테스트 코드에서는 props 비교를 정확히 하고 있는지 확인하기 위해 훅에 서로 다른 props를 인수로 넘겨야 한다. 이를 위해 renderHook에서는 함수의 초기값이 initialProps를 지정할 수 있는데, 이를 사용하면 훅의 초기값을 지정할 수 있다. 그리고 이후에 rerender 함수를 호출할 때, 여기서 지정한 초기값을 변경해 다시 렌더링할 수 있다.
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()
  })
  • 마지막으로 process.env.NODE_ENV = 'production'을 설정하면 어떠한 경우에도 consoleSpy가 호출되지 않는지 확인한다. process.env.NODE_ENV 에 'production' 값을 강제로 주입했고, 그 결과 훅 내부에서 props가 변경되더라도 아무런 작동도 하지 않는 것을 확인할 수 있었다.
  • react-hooks-testing-library를 사용하면 굳이 테스트를 위한 컴포넌트를 만들지 않아도 훅을 간편하게 테스트 할 수 있다. 또한 renderHook 함수에서 훅을 편리하게 테스트하기 위한 rerender, unmount 등의 함수도 제공하고 있으므로 사용자 정의 훅을 테스트하고 싶다면 꼭 한번 사용해보자.

8.2.5. 테스트를 작성하기에 앞서 고려해야 할 점

  • 소프트웨어에서 테스트에 대해 논할 때 테스트 커버리지라고 해서 해당 소프트웨어가 얼마나 테스트됐는지를 나타내는 지표에 대해 들어본 적이 있을 것이다. 흔히들 알고 있는 사실 중 하나는 테스트 커버리지가 높을 수록 좋고 꾸준히 테스트 코드를 작성하라는 것이다. 그러나 테스트 커버리지가 만능은 아니다. 먼저 테스트 커버리지는 단순히 얼마나 많은 코드가 테스트되고 있는 지를 나타내는 지표일 뿐, 테스트가 잘 되고 있는 지를 나타내는 것은 아니다. 그러므로 절대 테스트 커버리지를 맹신해서는 안된다.
  • 또 한가지 알아둬야 할 점은 테스트 커버리지를 100%까지 끌어올릴 수 있는 상황은 생각보다 드물다는 것이다. 이른바 TDD(Test Driven Development : 테스트 주도 개발)라고 하는 개발 방법론을 차용해서 테스트를 우선시하더라도 서버 코드와는 다르게 프론트엔트 코드는 사용자의 입력이 매우 자유롭기 때문에 이러한 모든 상황을 커버해 테스트를 작성하기란 불가능하다. 그리고 실무에서는 테스트 코드를 작성하고 운영할 만큼 여유로운 상황이 별로 없다. 때로는 테스트를 QA(Quality Assurance)에 의존해 개발을 빠르게 진행해야 할 수도 있고, 이후에 또 개발해야 할 기능이 산적해 있을 수도 있다. 따라서 테스트 코드를 작성하기 전에 생각해 봐야 할 최우선 과제는 애플리케이션에서 가장 취약하거나 중요한 부분을 파악하는 것이다.
    • 예를 들어, 결제를 위해 사용자가 입력하는 절차, 장바구니, 주소 입력, 결제까지의 과정을 모두 사용자와 최대한 비슷한 입장에서 테스트를 작성하는 것이 중요하다.
  • 이처럼 애플리케이션에서 가장 핵심이 되는 부분부터 먼저 테스트 코드를 하나씩 작성해 나가는 것이 중요하다. 테스트 코드는 소프트웨어의 코드를 100% 커버하기 위해, 혹은 테스트 코드가 모두 그린 사인(테스트가 모두 통과했다)을 보기 위해 작성하는 것이 아니다. 테스트 코드는 개발자가 단순 코드 작성만으로는 쉽게 이룰 수 없는 목표인 소프트웨어 품질에 대한 확신을 얻기 위해 작성하는 것이다. 무작정 테스트 코드를 작성하기보다는 반드시 이 점을 명심하고 테스트 코드를 작성해야 한다.

8.2.6. 그 밖에 해볼 만한 여러 가지 테스트

  • 이번 전의 내용은 create-react-app과 함께 제공되는 리액트 테스팅 라이브러리를 위주로 작성됐지만 프론트엔드 개발에 있어 테스트를 수행할 수 있는 방법은 굉장히 다양하다. 사용자도 한정적이고, 사용할 수 있는 케이스도 어느 정도 제한적인 백엔드에 비해 프론트엔드는 무작위 사용자가 애플리케이션에서 갖가지 작업을 할 수 있으므로 이를 테스트하기 위한 여러가지 방법이 있다.
    • 유닛 테스트(Unit test) : 각각의 코드나 컴포넌트가 독립적으로 분리된 환경에서 의도된 대로 정확히 작동하는 지 검증하는 테스트
    • 통합 테스트(Integration test) : 유닛 테스트를 통과한 여러 컴포넌트가 묶여서 하나의 기능으로 정상적으로 작동하는지 확인하는 테스트
    • 엔드 투 엔드(End to End test) : 흔히 E2E 테스트라 하며, 실제 사용자처럼 작동하는 로봇을 활용해 애플리케이션의 전체적인 기능을 확인하는 테스트
  • 리액트 테스팅 라이브러리는 유닛 테스트 내지는 통합 테스트를 도와주는 도구이며, E2E 테스트를 수행하려면 Cypress 같은 다른 라이브러리의 힘을 빌려야 한다. 각 테스트 설명에서도 알 수 있지만 유닛 테스트에서 통합 테스트, 엔드 투 엔드 테스트로 갈수록 실패할 지점이 많아지고, 테스트 코드도 복잡해지며, 테스트해야 할 경우의 수도 많아지고, 테스트 자체를 구축하는 것도 어려워진다. 그러나 유닛 테스트에서 통합 테스트, 엔드 투 엔드 테스트로 갈수록 개발자에게 있어 코드에 대한 자신감을 심어 줄 수 있는 가능성 또한 커진다.

8.2.7. 정리

  • 지금까지 create-react-app에서 함께 제공되는 리액트 애플리케이션에서 가장 쉽게 접할 수 있는 테스팅 라이브러리인 리액트 테스트 라이브러리에 대해 알아봤다. 테스트는 구현 결과물이 어느 정도 정해져 있는 애플리케이션과는 다르게 다양한 방법으로 시도해 볼 수 있다. 테스트할 수 있는 방법은 여러 가지가 있지만 테스트가 이뤄야 할 목표는 애플리케이션이 비즈니스 요구사항을 충족하는지 확인하는 것 뿐이다. 의존해야 할 QA 여건이 부족하거나, 애플리케이션의 취약한 부분이 걱정된다면 조금씩 테스트 코드를 추가해보자. 한 번에 E2E 테스팅 라이브러리를 설치해 사용자의 작동을 흉내 내서 완벽한 자신감을 얻는 것도 좋은 방법이 될 수 있지만, 처음부터 너무 많은 준비가 필요한 E2E 테스트를 시작하려다 보면 테스트 코드를 작성하기도 전에 지치거나 혹은 다른 급한 일에 테스트 코드 작성의 우선순위가 밀려날지도 모른다. 조금씩, 그러나 핵심적인 부분부터 테스트 코드를 작성하다 보면 소프트웨어의 품질에 확신을 갖게 될 것이다.