Post

Vitest - React hook form 테스트 에러

📌상황

나를 너무나 고뇌하게 만들었던(…) react hook form을 이용한 Signin Component 테스트에 관한 내용을 작성해보고자 한다.😇 (a.k.a. 코드 한 줄이 불러온 에러 파티^^)

먼저 로그인 페이지를 만들기 위해 Signin Component를 만들었다. ID와 Password가 입력 되어야 하는 input 태그는 react hook form을 사용해 만들어 둔 Input Component를 활용했다.

사실 아래 Input Component에서 작성한 코드 한 줄로 인한 에러였지만, 아래 부분에는 해당 코드를 주석처리 해두었다.

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
55
56
57
58
59
60
61
62
import React, { HTMLAttributes } from "react"
import { FieldValues, Path, UseFormRegister } from "react-hook-form"
import { FiSearch } from "react-icons/fi"
import { IoIosClose } from "react-icons/io"

interface InputProps<T extends FieldValues>
  extends HTMLAttributes<HTMLDivElement> {
  name: Path<T>
  label?: string
  placeholder?: string
  register: ReturnType<UseFormRegister<T>>
  error?: string
  reset?: () => void
  icon?: boolean
}

const Input = <T extends FieldValues>({
  name,
  label,
  placeholder = "",
  register,
  error,
  reset,
  icon = false,
  ...rest
}: InputProps<T>) => (
  <div className="h flex w-full flex-col gap-2" {...rest}>
    {label && (
      <label htmlFor={name} className="ml-1 text-sm font-medium text-white">
        {label}
      </label>
    )}
    <div
      className={`flex w-full items-center gap-3 rounded-full bg-white px-6 py-[12px]`}
    >
      {icon && <FiSearch className="text-gray-800" />}

      <input
        id={name}
        placeholder={placeholder}
        type="text"
        {...register}
        // onChange={(e) => {
        //   console.log(e.target.value)
        // }}
        className="flex-1 bg-transparent outline-none placeholder:font-medium placeholder:text-gray-500"
      />

      {reset && (
        <button onClick={reset} type="button" data-testid={`reset-${name}`}>
          <IoIosClose
            // reset 함수 호출
            className="cursor-pointer text-2xl text-gray-800"
          />
        </button>
      )}
    </div>
    {error && <p className="ml-1 text-xs text-red-500">{error}</p>}
  </div>
)

export default Input

✅테스트를 시도하다.

테스트는 총 4가지를 시도했다. 하지만 모든 테스트에서 오류만 발생할 뿐 다음으로 진행되지 않았다…😇

1
2
3
4
5
6
7
8
9
10
11
12
13
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react"
import { vi } from "vitest"
import SigninForm from "@/components/SigninForm"

describe("SigninForm 테스트", () => {
  it("폼 제출 시 올바른 데이터를 수집한다", async () => {})

  it("입력이 비어있을 때 에러 메시지가 표시된다", async () => {})

  it("유효성 검사가 통과되지 않을 경우 에러 메세지가 표시된다", async () => {})

  it("resetField 호출 시 입력값이 초기화된다", async () => {})
})

✅문제를 찾아보자

사실 npm run dev를 이용해 확인하면 외관상(?) 으로는 전혀 문제가 없었다. 사용자가 input에 입력을 하고 submit 하면 사용자가 작성한 id와 password 값 모두 정확히 받아오고 있었기에 원인은 더더욱 미궁으로 빠져들었으나..

dom을 확인하는 screen.debug()가 생각나 input에 값을 입력하고 submit 하기 전을 기준으로 앞뒤 상황을 살펴보았을 때 힌트를 발견할 수 있었다.

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
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react"
import { vi } from "vitest"
import SigninForm from "@/components/SigninForm"

describe("SigninForm 테스트", () => {
  it("폼 제출 시 올바른 데이터를 수집한다", async () => {
    const onSubmit = vi.fn()
    render(<SigninForm onSubmit={onSubmit} />)

    const id = screen.getByPlaceholderText("아이디를 입력해주세요")
    const password = screen.getByPlaceholderText("비밀번호를 입력해주세요")
    const submitButton = screen.getByTestId("signin")

    screen.debug()

    // 입력값 변경
    await act(async () => {
      fireEvent.change(id, { target: { value: "id@test.com" } })
      fireEvent.change(password, { target: { value: "password1!" } })
    })
    await act(async () => {
      fireEvent.click(submitButton)
    })

    screen.debug()
  })
})

현재 내가 생각하는대로 작동하려면, id와 password 모두 유효성 검사를 통과하기 때문에 에러메시지가 발생해서는 안된다. 하지만 둘 다 ‘필수값입니다’ 라는 에러메세지 가 표시되어 있었고, 이를 통해 input의 값이 제대로 변경되지 않는다는 것을 알 수 있었다. 그래서 onChange에 문제가 생겼나… 라고 짐작하며 Input 컴포넌트를 찬찬히 살펴본 결과…

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
const Input = <T extends FieldValues>({
  name,
  label,
  placeholder = "",
  register,
  error,
  reset,
  icon = false,
  ...rest
}: InputProps<T>) => (
  //생략
  <input
    id={name}
    placeholder={placeholder}
    type="text"
    {...register}
    onChange={(e) => {
      // 👈 여기 왜 있니...
      console.log(e.target.value)
    }}
    className="flex-1 bg-transparent outline-none placeholder:font-medium placeholder:text-gray-500"
  />
  //생략
)

export default Input

원인을 발견했다…. 🤦‍♀️ 바로 이 코드 사용자가 input에 입력할 때마다 제대로 값이 받아오는지 확인하기 위해 사용한 코드가 문제를 일으키는 것이었다.

1
2
3
4
 onChange={(e) => {
      // 👈 (😭)
      console.log(e.target.value)
    }}

🥺문제의 원인을 파고들어 보면…

react hook form의 register를 살펴보자. react hook form의 register는 onChange, onBlur, name, ref 이렇게 4가지를 이미 사용하고 있다.

즉 내가 추가로 작성한 onChange가 react hook form측에서 적용되어야 할 onChange를 간섭하고 있었고, 때문에 제대로 input 값을 변경하지 못했던 것이다.😣

1
2
3
4
5
6
7
8
9
10
const { onChange, onBlur, name, ref } = register('firstName');

<input
  onChange={onChange}
  onBlur={onBlur}
  name={name}
  ref={ref}
/>
// 위와 동일하다.
<input {...register('firstName')} />

onChange는 디버깅을 위해 작성한 코드 일 뿐 실제 화면에는 전혀 필요없는 코드이기 때문에 해당 코드는 삭제해주었고, 테스트는 통과하는 것처럼 보였지만…! 또다시 에러를 마주하게 되었다 ^^;

그래도 드디어(!) 값이 들어오긴 했다는 에러메세지를 보고 조금 감격하고 다시 에러메시지를 살펴보았다.

✅ 두 번째 에러

제출은 올바르게 되었지만 문제는 이 데이터가 내가 예상한 데이터가 맞는지 대조하는 부분이었다.

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
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react"
import { vi } from "vitest"
import SigninForm from "@/components/SigninForm"

describe("SigninForm 테스트", () => {
  it("폼 제출 시 올바른 데이터를 수집한다", async () => {
    const onSubmit = vi.fn()
    render(<SigninForm onSubmit={onSubmit} />)

    const id = screen.getByPlaceholderText("아이디를 입력해주세요")
    const password = screen.getByPlaceholderText("비밀번호를 입력해주세요")
    const submitButton = screen.getByTestId("signin")

    screen.debug() //DOM 상태 확인

    // 입력값 변경
    await act(async () => {
      fireEvent.change(id, { target: { value: "id@test.com" } })
      fireEvent.change(password, { target: { value: "password1!" } })
    })
    await act(async () => {
      fireEvent.click(submitButton)
    })
    //on Submit이 제대로 작동한지 확인
    await waitFor(() => {
      //👈 에러 발생
      expect(onSubmit).toHaveBeenCalledWith({
        id: "id@test.com", // id 값 검증
        password: "password1!", // password 값 검증
      })
    })
  })
})

에러 메세지를 살펴보면 금방 어떤 것이 문제인지 알 수 있었다.

  1st spy call:

  Array [
    Object {
      "id": "id@test.com",
      "password": "password1!",
    },
+   SyntheticBaseEvent {
+     "_reactName": "onSubmit",
+     "_targetInst": null,
+     "bubbles": true,
(생략)

첫번째 Object{} 값으로 내가 원하는 값이 들어왔지만, 그 이후 SyntheticBaseEvent~ 등이 들어오면서 expect에는 오류가 발생하는 것이었다. 그래서 이 부분은 해당 Object{} 값만 비교하는 것으로 교체하여 테스트를 마무리할 수 있었다.😊

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
describe("SigninForm 테스트", () => {
  it("폼 제출 시 올바른 데이터를 수집한다", async () => {
    const onSubmit = vi.fn()
    render(<SigninForm onSubmit={onSubmit} />)

    const id = screen.getByPlaceholderText("아이디를 입력해주세요")
    const password = screen.getByPlaceholderText("비밀번호를 입력해주세요")
    const submitButton = screen.getByTestId("signin")

    screen.debug() //DOM 상태 확인

    // 입력값 변경
    await act(async () => {
      fireEvent.change(id, { target: { value: "id@test.com" } })
      fireEvent.change(password, { target: { value: "password1!" } })
    })
    await act(async () => {
      fireEvent.click(submitButton)
    })
    //on Submit이 제대로 작동한지 확인
    await waitFor(() => {
      // 첫 번째 인자를 가져와서 확인
      const firstCallArgs = onSubmit.mock.calls[0][0]
      expect(firstCallArgs).toEqual({
        id: "id@test.com",
        password: "password1!",
      })
    })
    screen.debug()
  })
})

🙋‍♀️ 마무리

사실 글로는 이 고민의 시간이 짧게 느껴지지만 정말 오랜 시간 헤맸고, 해결 방법은 너무 간단해서 이렇게 쓰기에는 조금 부끄럽지만, 해결하고자 노력했던 시간이 아깝게 느껴지진 않는다. 무엇보다 해결했다는 성취감이 더 크니까! 😎

앞으로 테스트에 익숙해져서 더 좋은 컴포넌트, 더 좋은 코드를 쓰고 싶다는 마음이 가득하다.🤗 파이팅하자!

This post is licensed under CC BY 4.0 by the author.