go to post list

드디어 기어나온 테스트 코드

2024. 04. 11 • react custom hook 단위 테스트 해 보기

“어떤 툴을 이해하려면 그 툴을 어떻게 테스트하는지 알아야 한다”

라는 말을 어떤 기업의 테크 세션 영상에서 본 적이 있습니다. 마음에 남은 말이어서 항상 프로젝트에서 테스트 코드를 써 봐야지 하고 생각하고 있었는데요, ‘시간이 없어서’, ‘일단 기능은 되어야 하니까’, 같은 이유로 프로젝트에 적용 하지 못하고 있었습니다. 마지막 프로젝트에서도 적용하려고 했지만 다른 산적한 기술적 문제들과 구현량에 치여서 결국 걷어냈었습니다. 그 프로젝트에서 백엔드 파트는 여러 기술들 대신 테스트 코드를 먼저 짜고 주요 로직을 작성한 뒤 빌드 과정에서 테스트를 통과했을 때에만 빌드가 성공하도록 자동화했었습니다. 작성해야 하는 코드량이 정말 많아졌지만 안정적으로 개발하는 모습을 보았고, 그게 참 부럽다고 생각했습니다. 프론트엔드 파트에서는 인증 로직에 대한 테스트 하나만 작성했다가 프로젝트 전체에서 테스팅을 걷어내게 되면서 사용해 보지 못했었습니다. 프로젝트와 프로젝트 정리가 어느 정도 끝난 시점에서 가장 먼저 해 봐야 하는 것이 뭘까 생각해 보았을 때 테스트라고 생각했고, 대표적인 테스트 라이브러리인 JestTesting-Library/React를 함께 사용하면서 커스텀 훅 하나를 배포해본 과정을 기록해 보려고 합니다.

목표

아무래도 그냥 툴만 공부해보면 재미가 없을 것 같아서, 이전 프로젝트때 만들었던 커스텀 훅 하나를 테스트해 보고 그것을 바탕으로 리팩토링을 해 보려고 합니다. 단순히 컴포넌트를 테스트하는 것보다는 커스텀 훅을 테스트할 수 있게 되면 다른 것들도 테스트는 데 무리가 없을 것 같았고, 사실 이 훅과 앞으로 구현하게 될 유틸리티 함수들을 하나씩 NPM에 배포하고 싶다는 생각을 했기 때문이기도 합니다. NPM에 배포를 하려니 코드를 잘 짜야할 것 같고, 그러면 테스트를 해야할 것 같고… 이왕 테스트 툴을 공부하는 김에 테스트 코드를 먼저 쓰고 나서 구현이 테스트를 통과해야 배포할 수 있도록 하는 방향으로 패키지를 작성해야 안정성이나 유지보수 측면에서도 좋지 않을까 하는 생각이었습니다.

useForm

npm install @syyu/util

yarn add @syyu/util

로 설치하신 뒤 아래처럼 import하면 사용해보실 수 있습니다. (subpath로 /react를 붙여야 합니다)

Typescript

import { useForm } from '@syyu/util/react';

useForm제가 마지막 프로젝트를 리팩토링하면서 폼 컴포넌트를 간편하게 다루기 위해 만들었던 custom hook입니다. 폼 컴포넌트에서 사용하는 데이터와 함수들을 한 번에 리턴해주는 훅인데요, 구체적인 사용 방법은 다음과 같습니다. 말로 풀어서 설명하면 더 어렵고 사용 예시를 먼저 보시는 게 직관적으로 와닿으실 것 같은데요,

Example

Typescript


const initialValues: User = { 
	name: '',
	email: '',
} //폼 이름과 초기값

const onSubmit = async () => await axios.post(...)
//폼 전송 함수(react hook을 포함하면 안 됨)

Typescript

//Component.tsx

...
const { values, handleChange, submit } = useForm<User>({ initialValues, onSubmit })
//간편하게 폼 컴포넌트와 상태를 양방향 바인딩할 수 있습니다

return (
	<form onSubmit={submit}>
		... 
		<input
			name="name"
			value={values.name}
			onChange={handleChange}
		/>
		<input
			name="email"
			value={values.email}
			onChange={handleChange}
		/>
		<button>회원가입</button>
		...
	</form>
)
...

이렇게 폼 관련 데이터를 간단하게 받을 수 있습니다. 더 구체적인 API는 아래와 같습니다.

Params

Typescript

initialValues: `{ inputName : initialValue }` 쌍으로 이루어진 객체
onSubmit: submit 동작 시 실행되는 `(values) => any` 함수
validator: `(values) => boolean` 함수
refInputNames: `string[]` 으로 여기 input name을 입력하면 refObject를 받을 수 있음 
  • initialValues, onSubmit은 필수적으로 전달해야 합니다.
  • 이외에도 Validator를 통해 값을 검증하거나,
  • refInputNames에 input name를 전달하면 refObject를 받을 수 있게 됩니다. 비제어 input을 사용하거나 DOM 에 접근할 수 있습니다.

Returns

Typescript

values: `{ inputName : values }` 로 이루어진 객체
setValues: 내부의 input value를 강제로 변경할 수 있는 `setState fn`.
					 상태관리 라이브러리를 연동한 경우 아규먼트의 updateStore가 호출됨
handleChange: input과 useForm을 바인딩하는 `fn`
refs: `{ inputName : refObject }` 로 이루어진 객체.
			refInputNames가 있을 때 해당 input들의 `refObject`를 리턴함
refValues: `{ inputName : values }` 로 이루어진 객체,
			`refInputNames`가 있고 비제어 input이 바인드되었을 때 해당 input의 value를 리턴함
submit: 실제로 onSubmit 시 사용될 `fn`.
isLoading: `submit` 이 진행 중인지를 나타내는 `boolean`response: 파라미터로 들어온 `onSubmit` 의 결과

테스트 작성 및 결과

일반 테스트, Context 테스트

Jest를 공부하면서 어떤 항목을 테스트해야 하는지 고민했는데요, 의도한 기능을 잘 테스트하면 된다고 생각해서 조건들을 나열해 보았습니다.

  • 상태의 초기값이 잘 세팅되는가
  • setValues로 내부의 상태를 잘 바꿀 수 있는가
  • controlled input에 실제로 바인딩이 잘 되는가 (handleChange 가 잘 실행되는가)
  • uncontrolled input을 사용할 때 ref 객체가 잘 생성되고 정상적으로 바인딩되는가
  • uncontrolled input을 사용할 때 submit 시점에 values의 조회가 정상적으로 되는가
  • valid의 값이 잘 변화하는가
  • submit 이벤트가 발생했을 때 onSubmit 함수가 잘 실행되는가
  • isLoading이 잘 변화하는가
  • 제공하는 Context API와 연동했을 때 위의 테스트가 모두 잘 작동하는가

이 조건들을 테스트하기 위한 구체적인 테스트 항목을 나누어서 아래와 같이 테스트를 작성했습니다.

테스트 항목들

테스트 항목들

위 항목을 한글로 풀어 보면 아래와 같습니다.

일반 테스트

훅 초기화

  • 초기값을 적절히 설정해야 합니다
  • setValue로 values를 업데이트할 수 있어야 합니다

컴포넌트 바인딩

  • input value의 변화를 추적할 수 있어야 합니다
  • 비제어 input의 값 변화를 추적할 수 있어야 합니다
  • refValues API가 비제어 input의 값 변화를 추적할 수 있어야 합니다

폼 전송

  • 폼 전송, 값 검증, 로딩 상태, 서버 응답에 대한 상태가 적절히 변화해야 합니다.

React Context를 사용한 테스트

일반 테스트 항목에 createUseFormContextuseFormContext API를 적용하고 아래 항목 하나를 추가합니다

  • Context<null> 타입을 초기값으로 useFormContext(context)를 호출했을 때 적절한 초기값(useForm에 특정 값을 제공해 호출한 결과)을 리턴해야 합니다.

테스트 코드 작성

테스트 코드는 @testing-library/reactrenderHook을 이용해 훅의 렌더링 결과를 받은 뒤, 간단한 컴포넌트를 작성해 테스트를 진행했습니다. 예시로 두 개 정도의 테스트 코드를 가져와 봤는데요, 아래 테스트 코드는 비제어 인풋 컴포넌트의 값 추적과 바인딩이 제대로 되는지를 테스트하는 코드입니다.

Typescript

it('should correctly track values of uncontrolled inputs', () => {
  const initialValues = {
    nickname: 'latte'
  }
  const refInputNames: (keyof typeof initialValues)[] = ['nickname']
  const onSubmit = jest.fn()

  const { result } = renderHook(() =>
    useForm<typeof initialValues>({
      initialValues,
      onSubmit,
      refInputNames
    })
  )
  
  render(
    <form onSubmit={result.current.submit}>
      <input
        placeholder="nickname"
        type="text"
        name="nickname"
        ref={result.current.refs?.nickname}
        value={result.current.refs?.nickname.current?.value}
        onChange={result.current.handleChange}
      />
      <button type="submit">Submit</button>
    </form>
  )

  const refInput = screen.getByPlaceholderText('nickname')

  expect(result.current.values.nickname).toBe('latte')

  fireEvent.change(refInput, { target: { value: 'brewcoldblue' } })
  expect(result.current.values.nickname).toBe('brewcoldblue')
})

폼을 보내고 응답 결과를 받는 테스트의 경우, nock이라는 라이브러리를 이용해 HTTP 요청을 가로채서 특정 응답을 제공하도록 구현했습니다. 이 경우에는 form 전체의 validation과 요청-응답 처리, 응답 결과의 수신이 내부적으로 useEffect hook에 묶여 있고, submit이라는 이벤트로 모두 트리거되기 때문에 테스트를 한 단위로 작성했습니다.

Typescript

describe('Form Submission', () => {
  it('should handle form submission, validation, loading state, and server response correctly', async () => {
    const initialValues = {
      email: 'abcd',
      password: 'asdqwe'
    }

    nock('http://useformtest.com')
      .post('/login', { email: 'abcd@email.com', password: 'asdqwe123' })
      .reply(200, { message: 'success!' })

    const onSubmit = (body: typeof initialValues) =>
      axios.post('http://useformtest.com/login', body)

    const validator = (data: typeof initialValues) => {
      const isValidMail = (str: string) => {
        if (str.includes('@')) return true
        else return false
      }
      const isValidPw = (str: string) => {
        if (str.length > 8) return true
        else return false
      }
      if (isValidMail(data.email) && isValidPw(data.password)) return true
      else return false
    }

    const { result } = renderHook(() =>
      useForm<typeof initialValues>({
        initialValues,
        onSubmit,
        validator
      })
    )

    render(
      <form data-testid="testform" onSubmit={result.current.submit}>
        <input
          placeholder="email"
          type="text"
          name="email"
          value={result.current.values.email}
          onChange={result.current.handleChange}
        />
        <input
          placeholder="password"
          type="text"
          name="password"
          value={result.current.values.password}
          onChange={result.current.handleChange}
        />
        <button type="submit">Submit</button>
      </form>
    )

    const emailInput = screen.getByPlaceholderText('email')
    const passwordInput = screen.getByPlaceholderText('password')

    const testform = screen.getByTestId('testform')

		fireEvent.submit(testform)

    waitFor(() => {
      expect(onSubmit).not.toHaveBeenCalled()
      expect(result.current.isLoading).toBe(false)
      expect(result.current.response).toBe(null)
    })

    fireEvent.change(emailInput, { target: { value: 'abcd@email.com' } })
    fireEvent.change(passwordInput, { target: { value: 'asdqwe123' } })
    fireEvent.submit(testform)

    waitFor(() => {
      expect(onSubmit).toHaveBeenCalled()
      expect(result.current.isLoading).toBe(false)
      expect(result.current.response).not.toBe(null)
    })

    nock.cleanAll()
  })
})

테스트 커버리지

coverage-context

coverage-context

++4/21 coverage-context2

++4/21 coverage-context2

초기(위 사진) 테스트를 작성한 후, 실제로 복잡한 프로젝트에 모의로 적용해본 뒤에 구현을 고치고 테스트 코드도 보강하면서 아래의 커버리지가 나오게 됐습니다. 케이스를 보강했는데도 오히려 커버리지 자체는 감소한 모습을 보이는데, 구현도 같이 많이 보강했기 때문에 테스트 코드로 못 잡는 케이스가 더 생겨서 그런 것 같습니다. (리턴값으로 직접 테스트할 수 없는 부분이 존재하게 됐습니다)

%Stmts, %Funcs, %Lines보다 낮은 수치의 %Branch는 작성한 테스트 코드가 모든 시나리오(조건/분기문)를 테스트하지는 못하고 있다는 뜻입니다. 우측 하단의 Uncovered Line #s 의 노란색 글씨는 해당 부분이 부분적으로만 테스트되고 있다는 뜻이고, 빨간색 글씨는 테스트되지 않고 있다는 뜻이라고 합니다. 그런데 사실 해당 라인을 찾아가보면 빈 공간이거나 여백일 때도 있고, 꽤나 모호한 경우도 있어서…

높은 커버리지가 코드 퀄리티를 보장하는 것은 아니지만, 테스트를 잘 작성했다는 가정하에 낮은 테스트 커버리지는 테스트 대상의 코드 퀄리티가 낮을 확률이 크다는 말은 어느정도 맞는 것 같습니다. 실제로 Uncovered Lines에 대한 추가 테스트 코드를 작성하면서 테스트 코드와 대상 코드의 케이스 처리가 같이 되는 경험을 했습니다.

전체 구현 코드

Github

Typescript

export const useForm = <T extends Object>({
  initialValues,
  onSubmit,
  validator,
  refInputNames = []
}: UseFormArgs<T>): UseForm<T> => {
  const [values, setValues] = useState<typeof initialValues>(initialValues)
  const [valid, setValid] = useState<boolean>(true)
  const [isLoading, setIsLoading] = useState<boolean>(false)
  const [response, setResponse] = useState<unknown>(null)

  const handleChange = (e: ChangeEvent<HTMLInputElement & HTMLTextAreaElement>) => {
    const t = e.target
    const check = t.type === 'checkbox' || t.type === 'radio'
    setValues(prevData => ({ ...prevData, [t.name]: check ? t.checked : t.value }))
  }

  const [currRefValues, refs] = useRefInputInit<T>(refInputNames, values)

  const mergeValues = (values: T, convertedRefValues: Record<keyof T, any>) => {
    if (!refInputNames.length) setValues({ ...values })
    else setValues({ ...values, ...convertedRefValues })
  }

  const submit = () => setIsLoading(true)

  useEffect(() => {
    if (isLoading) mergeValues(values, currRefValues())
    setValid(typeof validator === 'function' ? validator(values) : true)
  }, [isLoading])

  useEffect(() => {
    async function POST() {
      const res = await onSubmit(values)
      setResponse(res)
      setIsLoading(false)
    }

    if (isLoading && valid) POST()
  }, [isLoading, valid])

  const data = {
    values,
    setValues,
    handleChange,
    refs,
    refValues: currRefValues(),
    submit,
    isLoading,
    response
  }

  return data
}

Typescript

function useRefInputInit<T>(
  refInputNames: (keyof T)[] = [],
  values: T
): [
  () => Record<(typeof refInputNames)[number], any>,
  Record<(typeof refInputNames)[number], RefObject<HTMLInputElement & HTMLTextAreaElement>>
] {
  type Refs = Record<
    (typeof refInputNames)[number],
    RefObject<HTMLInputElement & HTMLTextAreaElement>
  >
  type RefValues = Record<(typeof refInputNames)[number], any>

  const refs: Refs = {} as Refs
  refInputNames.forEach(k => (refs[k] = useRef<HTMLInputElement & HTMLTextAreaElement>(null)))

  const currRefValues: () => RefValues = () => {
    const refValues: RefValues = values
    refInputNames.forEach(k => (refValues[k] = refs[k]?.current?.value || values[k]))
    return refValues
  }

  return [currRefValues, refs]
}

외부 스토어 연동 이슈

사실 처음에는 외부 라이브러리를 연동하는 setter 함수를 받아 외부 라이브러리에 이 훅의 리턴값을 연동시켜주는 로직을 만들었다가 해당 로직을 다 삭제하고 Context를 제공하는 방향으로 선회했습니다. 예를 들어 useForm({setExternalStore, ..args}) 같이 setter function을 넣으면 상태관리 라이브러리와 연동할 수 있는 로직이 들어가 있었고 실제로 zustand를 사용한 유닛 테스트를 진행한 후 심각한 문제점 몇 가지를 발견하게 됐습니다.

coverage-zustand

coverage-zustand

  • 이 훅이 리턴하는 것들 중 refObject같은 경우 current 프로퍼티를 통해 그 참조값에 접근하게 되는데요, 이런 성질 때문에 값 변화 추적이 어려워 Redux(toolkit)처럼 refObject 타입을 타입 차원에서 상태관리 라이브러리에 아예 넣지 못하게 막은 라이브러리가 있었습니다.
  • 기능을 만들다 보니 내외부 상태를 이중으로 업데이트해야 하는 문제들도 생겨 라이브러리의 렌더링 최적화에 의존할 수밖에 없었고, 그렇다고 해도 함수 호출 자체는 두 번씩 이루어지기 때문에 성능 문제가 있었습니다. (그러지 않으려면 직관적이지 않은 아규먼트를 전달 받아야 하는 상황이었습니다)

처음에는 이 문제들을 구현으로 해결할 생각을 하다가 아예 이 API 자체를 삭제해야 한다는 결론을 내리게 됐는데요, API의 사용성을 우선하고 싶었기 때문이었습니다. 기존에는 훅의 리턴 타입을 interface로 제공하는 방향이었는데, 그래도 훅의 리턴 타입이 매우 복잡해서, 외부 스토어를 연동하는 테스트를 작성하면서 초기값을 설정하는 과정이 매우 불편했습니다. Context API를 사용하면 직관적이지 못한 파라미터와 불편한 초기값 설정 과정은 없앨 수 있었지만, 여전히 복잡한 리턴값 타입 문제가 발목을 잡았습니다.

Typescript

export function createUseFormContext<T extends Object, K = {}>() {
  const FormContext = createContext<((UseForm<T> | Partial<UseForm<T>>) & K) | null>(null)
  return FormContext
} //저장해야 하는 것이 커스텀 훅의 결과물이어야 해서 초기값 지정이 어렵습니다.

export function useFormContext<T extends Object, K = {}>(
  arg: Context<((UseForm<T> | Partial<UseForm<T>>) & K) | null>
) {
  const ctx = useContext<((UseForm<T> | Partial<UseForm<T>>) & K) | null>(arg)
  if (!ctx) return useForm<T>({ initialValues: {} as T, onSubmit: () => {} })
  return ctx as UseForm<T> & K
}

그래서 저는 기존 Context API에 타입을 씌워서 제공하는 게 제일 나은 대안이라고 생각했고, Zustand와 연동해서 작성했던 테스트와 기능을 모두 삭제하고 Context로 테스트를 모두 다시 작성했습니다. 기존 Context API와 비슷한 사용법과 이름으로 제공하면 직관적으로 쓸 수 있을 것이라고 생각했습니다. 피드백을 받아봐야겠지만… 제가 느끼기에는 사용성이 정말 많이 좋아졌습니다. 외부 라이브러리와 연동하는 것은 직접 해야 하지만 이 방향성이 맞다고 생각합니다.

미구현된 부분

지금 지원하는 포맷은 textarea와 input type 중 text, number, checkbox, radio뿐인데요, 이것 말고도 file이나 date, color같은 다양한 type들은 어떤 방식으로 지원해야 하는지도 알아봐야 합니다. 간단히 쓰려고 만들었는데 생각보다 일이 커지는 중인 것 같다는 생각이 듭니다.

배운 것과 느낀 점

테스트 케이스를 작성하고 나서 구현과 리팩토링을 진행하는 방식은 훨씬 더 정교하게 기능 구현을 할 수 있는 방식인 것 같습니다. 눈으로 테스트해볼 때 생각하지 못했던 헛점이나 버그를 발견하게 되는 것이 (뻔한 말이지만) 정말 좋은 점이라고 생각합니다. useEffect hook을 다루면서 dependency를 빼먹어서 생기는 문제부터 상태관리 라이브러리와 연동했을 때 생길 수 있는 동기화 문제까지 눈으로만 확인해서는 몰랐을 수도 있는 문제들을 테스트를 작성했을 때 알 수 있게 되어서 전체적으로 시간을 많이 절약하면서 구현을 수정할 수 있었습니다.

테스트 코드를 통과한 코드가 실제로 잘 작동하지는 않을 수 있다는 사실도 알게 됐습니다. 테스트 코드를 잘못 작성했거나, 여러 컴포넌트의 상호작용이 유닛 테스트 코드에서는 발생하지 않던 이슈를 발생시킬 수 있기 때문입니다. 실제로 zustand 테스트를 통과한 코드를 예전 프로젝트에 적용시켰을 때 무한 렌더링 이슈가 발생하거나, 비동기 초기값 처리 때문에 Input 입력이 사라지는 등 고려해야 하는 이슈들이 더 있다는 걸 알게 되기도 했습니다.

또 테스트 코드를 실행하는 환경이 브라우저가 아니라는 것도 생각해야 했습니다. 테스트 실행 환경을 디버깅하면서 Node 환경이 브라우저와 다르다는 것을 고려해야 해서 환경설정을 하면서 애를 많이 먹었습니다. 예를 들어 Node 환경의 경우 DOM이나 fetch를 비롯한 몇몇 API가 없고, axios같은 라이브러리의 경우 두 런타임의 구현이 다릅니다. 제가 사용한 jest의 경우 환경 설정을 하는 과정에서 Node 환경에서 브라우저의 API를 구현한 jsdom 모듈을 사용해 DOM이 존재하는 것처럼 테스트를 실행할 수 있었습니다.

이번에 저는 유닛 테스트만 작성해 봤는데, 아무래도 모든 테스트를 독립적으로 수행하는 만큼 위와 같은 복잡한 상황에서 복합적인 테스트를 하려면 e2e테스트도 작성해 봐야 안정성을 보장할 수 있을 것 같다는 생각도 했습니다. 모든 케이스를 눈으로 확인하는 것과 툴로 검증하는 건 휴먼 에러 발생의 측면에서 확실히 다르다는 생각이기도 하고, 자동으로 문서화가 된다는 점이 개발 안정성을 많이 높여준다고 생각합니다. 실제 사이트를 배포하는 게 아닌 오픈소스들 중에서도 모의 사이트를 만들어 e2e테스트를 작성한 경우도 있었고요.

마무리

Jest와 React Testing Library로 커스텀 훅 테스트를 진행하고, 테스트를 기반으로 리팩토링을 진행해 봤는데요, 테스트 코드 짜면서 기능을 구현해 본 것이 안정성과 자신감이 많이 올라가는 느낌이어서 만족스러웠습니다. 아직 기초적인 수준만 공부한 만큼 좋은 테스트 코드는 또 다른 영역이라고 하니 협업 상황에서의 테스트 코드같은 부분들은 더 알아봐야겠지만요. 아무튼 일전에 이 글에서 프로젝트를 해 보면서 느낀 좋은 개발 환경의 중요성과 그런 환경에 대한 갈망에 대해 이야기한 적이 있었는데요, 좋은 개발 환경을 구성하는 데는 테스트 코드가 첫 단추가 되는 것 같을 정도로 좋은 경험이었습니다.

막상 NPM에 배포하려니 코드를 잘 짜고 싶은 욕심이 생겼는데, 직관적인 API를 만드는 게 (특히 이름 짓는 게) 정말 힘든 일이었습니다. 디자인을 공부할 때도 그렇고 이런 걸 구현할 때도 그렇지만 항상 UX를 생각한답시고 열심히 고민해 봐도 뒤돌아보면 얕은 수준의 생각이었을 때가 많아서 뒤늦게 고치게 됩니다. 이런 설계는 어떻게 해야 잘 할 수 있는 건지는 아직 잘 모르겠지만, 지금 만들어진 오픈소스들이 정말 멋진 것들이라는 건 확실히 알게 됐습니다. 이 간단한 훅도 배포하려니 정말 고려할 게 많고 어려운데요. 아무튼 이번에는 글이 좀 길었는데 여기까지 읽어주셔서 감사합니다.

다른 글