버그 버그 또 버그, 리팩토링 이후 또 버그!

musma 프론트엔드 팀은 대부분의 프로젝트를 React 기반으로 개발하고 있습니다.

제가 두 번째로 참여하게 된 프로젝트도 React로 개발되었는데요.

프로젝트가 마무리가 되어갈 때, 사소하지만 크다면 큰 버그가 많이 발견되었습니다.

버그가 많이 발견된 이유는 무엇일까요?

개발자의 실력이 부족했을 수도 있고, 개발자가 미처 생각하지 못한 부분에서 버그가 발생했을 수도 있습니다.

그리고 일정입니다. 일정이 부족하면, 테스트할 시간이 부족하고, 질 좋은 코드를 작성할 수 없습니다.

마지막으로는 코드 개선을 진행하고 단위 테스트를 세심하게 하지 않았거나 했음에도 불구하고 버그를 발견하지 못하는 것입니다.

많은 버그 발생 사유

저는 위의 예시 중 마지막의 경우에 대해 이야기를 해볼까 합니다.

프로젝트를 맡은 팀원들과 저는 프로젝트 마감 일정을 맞추기 위해 코드의 품질은 낮지만 기능만 어떻게든 돌아가게 개발을 진행했습니다 그리고 여유시간이 날 때마다 코드 개선을 진행했습니다.

코드 개선 이후에는 테스트 서버에 배포를 하고, 테스트 서버에서 기능을 직접 테스트했습니다.

로그인 코드를 개선하고, 사용자가 로그인하는 시나리오를 작성해 보겠습니다.

1️⃣ 로그인 페이지가 올바르게 보여야 합니다.

2️⃣ 회원가입 버튼을 클릭하면, 회원가입 페이지로 이동합니다.

이미 가입한 아이디가 있다면 아래를 진행합니다.

3️⃣ 사용자가 아이디를 입력하면, 유효성 검증을 하고, 맞지 않으면 사용자에게 알려주어야 합니다.

4️⃣ 사용자가 패스워드를 입력하면, 유효성 검증을 하고, 맞지 않으면 사용자에게 알려주어야 합니다.

5️⃣ 사용자가 아이디와 비밀번호를 올바르게 입력했다면, 버튼이 활성화되어야 합니다.

6️⃣ 버튼이 활성화되었을 때, 로그인 버튼을 클릭하면 서비스 페이지로 이동하게 됩니다.

7️⃣ 아이디가 기억나지 않는다면, 아이디 찾기 버튼을 클릭하여 아이디 찾기 페이지로 이동합니다.

8️⃣ 비밀번호가 기억나지 않는다면, 비밀번호 찾기 버튼을 클릭하여 비밀번호 찾기 페이지로 이동합니다.

단순한 로그인 과정만 해도 이렇게 8가지의 유형을 테스트해야 합니다.

하지만 우리의 프로젝트에는 한 페이지에 50개 이상의 유형을 테스트해야 하는 곳도 있었습니다.

개발자가 사람이라면 실수를 할 수밖에 없는 환경이었던 것입니다.

이런 환경 속에서 우리 중 누군가가 테스트 코드를 거론하게 되었지만 일정이 부족했기 때문에 거론만! 하고 넘어가게 됩니다.

통합 테스트의 허점

우리는 일정이 촉박했으나 다행히도 서비스를 오픈 일정에 맞춰 개발을 완료했습니다.

그러나 많은 버그가 발견되어 클라이언트에서 문의가 빗발치게 되었습니다.

지속적으로 발생하는 버그로 인해 정신을 못 차릴 무렵, 클라이언트의 요청으로 다시 한번 통합 테스트를 진행하게 됩니다.

아! 여기서 말하는 통합 테스트란 애플리케이션의 모든 구성 요소가 예상대로 함께 작동하는지 확인하는 소프트웨어 테스트 유형입니다.

맨 처음으로 돌아가서 하나하나 손수 눌러보는 것이죠.

저와 팀원들은 정확한 통합 테스트를 위한 명세서를 더 세심하게 수정 작성하게 되었는데,

작성하는 것조차 허점이었습니다. 제가 느낀 문제점은 이것입니다.

1️⃣ 통합 테스트를 위한 명세서를 작성하는 시간이 너무 많이 소요된다.

2️⃣ 해당 페이지의 기능을 전부 알고 있지 않거나 기억이 나지 않는 내용이 있다면, 허점 투성이인 명세서가 된다.

3️⃣ 문서로 주고받은 기획서 외의 구두로 전달받은 내용이 누락됐다면, 그것도 허점이다.

4️⃣ 통합 테스트를 진행하는 시간이 너무 오래 걸린다.

실제로 작성하고 테스트하는 기간을 합치니 7일이 소요되었습니다.

테스트 코드의 장점

앞의 이야기를 정리해 보겠습니다.

1️⃣ 인간은 완벽하지 않기 때문에 테스트를 진행해도 허점이 있을 수밖에 없다.

2️⃣ 통합 테스트를 작성하는 내용조차 허점이 존재할 수 있으며, 시간이 너무 오래 걸리는 테스트 방법이다.

하지만 테스트 코드를 작성하는 것도 인간 즉 개발자입니다. 완벽하지 않겠죠.

그래서 테스트 코드를 작성하는 이점은 무엇이 있을까 고민해 보았습니다.

제가 생각한 장점은 두 가지입니다.

한번 작성했을 때, 완벽하지 않아도 된다

여러분은 개발 하면서 가장 바보 같다고 생각한 적이 언제인가요?

저는 같은 실수를 두 번 이상 반복했을 때입니다. 즉, 같은 버그가 두 번 이상 발생했을 때입니다.

다음은 예시입니다. 예시로선 부족할 수 있으나 제가 무슨 말을 전달하고 싶은지는 설명이 될 것입니다.

일자 이슈내용
2023/1/09 로그인 로직 수정
2023/1/11 이전에 로그인 로직 수정 후 로그인 버튼이 활성화되지 않는 문제 발생
2023/1/17 로그인 코드 개선
2023/1/17 로그인 버튼이 활성화되지 않는 문제 발생

테스트 코드를 따라가다보면, 팀원이 작성한 페이지의 기능을 파악하기 용이하다

회사에서 한 프로젝트를 온전히 혼자 작업하게 되는 경우도 있지만, 둘 이상의 팀원이 같이 개발을 진행하게 될 수 있습니다.

팀원이 작성한 페이지를 수정해야 한다고 했을 때, 팀원이 맡은 페이지의 기능을 어디까지 알고 있을까요? 보통은 절반도 모를 것입니다.

API 명세서와 기획서 등 여러 문서를 보며 기능을 수정하고 여러 가지 유형에 대한 테스트를 진행해야 합니다.

테스트 코드는 팀원이 작성한 기능을 파악하는데 도움을 주며, 안정성도 더해줍니다.

test

작성하며 느낀 테스트 코드의 단점

테스트 코드를 실제로 작성하기 전까지 제가 생각한 단점은 모두와 같은 생각이니 넘어가겠습니다.

다만 실제로 작성해 보니 여러 시행착오를 겪으며, 이런 단점이 있다는 것을 알게 되었습니다.

그것에 대해 이야기해 보겠습니다

일관된 테스팅 방법이 없다.

React를 사용하는 프론트엔드는 많은 라이브러리에 의존하고 있습니다.

i18, Apollo client, react-hook-form 등등 의존하고 있는 라이브러리를 비즈니스 로직에 사용했을 경우 일반적인 테스팅 방법과 다릅니다.

예를 들면, react-hook-form을 사용한 입력 상태 관리를 했을 때입니다.

코드는 중요한 게 아니니 집중해서 보시지 않으셔도 됩니다.


import { Controller, SubmitHandler, useForm } from 'react-hook-form'

import { yupResolver } from '@hookform/resolvers/yup'
import { object, string } from 'yup'

import { FormInput } from './helpers'

const schema = object().shape({
  id: string()
    .required('아이디를 입력해주세요')
    .matches(/^[a-zA-Z0-9]{1,10}$/, '유효한 형식이 아닙니다'),
  password: string()
    .required('비밀번호를 입력해주세요')
    .matches(/^[a-zA-Z0-9]{1,10}$/, '유효한 형식이 아닙니다'),
})

export const FormTest = () => {
  const {
    control,
    formState: { isValid },
    handleSubmit,
  } = useForm<FormInput>({ resolver: yupResolver(schema) })

  const onSubmit: SubmitHandler<FormInput> = ({ id, password }) => {
    // ...code
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Controller
        name="id"
        control={control}
        render={({ field }) => <input type="text" placeholder="아이디를 입력해주세요" {...field} />}
      />

      <Controller
        name="password"
        control={control}
        render={({ field }) => <input type="password" placeholder="비밀번호를 입력해주세요" {...field} />}
      />

      <button type="submit" disabled={!isValid}>
        로그인
      </button>
    </form>
  )
}

여기서 로그인 button의 disabled 속성에 isValid 값에 대해 집중해 주세요.

isValid라는 값은 Boolean 타입의 값이며, id Input과 password Input의 상태를 관찰하고 유효성 검증을 통과하면 false 값을 반환하여, 결론적으로 버튼은 활성화 상태가 됩니다.

그것이 제가 바라는 테스트 결과입니다.


<button type="submit" disabled={!isValid}>
  로그인
</button>

그럼 이제 테스트 코드를 작성해 보겠습니다. 먼저 말씀드리면 이건 틀린 방법입니다. 틀린 방법에 유의해주세요.

테스트 코드에 각 설명을 주석으로 달아두겠습니다.


describe('로그인', async () => {
  afterEach(cleanup)

  const dynamicRender = () => {
    return render(
      <BrowserRouter>
        <Routes>
          <Route path="*" element={<P_로그인 />} />
          <Route path="/sign-in" element={<P_로그인 />} />
        </Routes>
      </BrowserRouter>
    )
  }

  describe('Form 제출', () => {
    it('유효성 검사 통과하면, 로그인', async () => {
      const usernameRegex = /^[a-zA-Z0-9]{1,20}$/
      const passwordRegex = /^[a-zA-Z0-9]{1,20}$/
      const user = userEvent.setup()

      const { getByPlaceholderText, getByRole } = dynamicRender() //~> 검사를 실행할 컴포넌트를 렌더링합니다.

      const 아이디_인풋 = getByPlaceholderText(t('아이디를 입력해주세요')) as HTMLInputElement  //~> placeholder에 입력된 텍스트가 '아이디를 입력해주세요'인 Element를 찾습니다.

      user.click(아이디_인풋) //~> 위에서 찾은 Element를 클릭합니다.

      fireEvent.change(아이디_인풋, { target: { value: 'abcdef11' } }) //~> id 텍스트 박스에 'abcdef11'이라는 값을 입력합니다. 

      const usernameInputValue = 아이디_인풋.value //~> id 텍스트 박스의 값을 읽어옵니다.
      expect(usernameRegex.test(usernameInputValue)).toBe(true) //~> 유효성 검증을 하고 통과하면 Pass 입니다.

      user.tab() //~> 사용자가 탭을 누릅니다.

      const 비밀번호_인풋 = getByPlaceholderText(t('비밀번호를 입력해주세요')) as HTMLInputElement //~> placeholder에 입력된 텍스트가 '비밀번호를 입력해주세요'인 Element를 찾습니다.

      user.click(비밀번호_인풋) // 위에서 찾은 Element를 클릭합니다. 

      fireEvent.change(비밀번호_인풋, { target: { value: 'abcdef12' } }) //~> password 텍스트 박스에 'abcdef12'라는 값을 입력합니다.

      const passwordInputValue = 비밀번호_인풋.value //~> password 텍스트 박스의 값을 읽어옵니다.
      expect(passwordRegex.test(passwordInputValue)).toBe(true) //~> 유효성 검증을 하고 통과하면 Pass 입니다.

      const 로그인_버튼 = getByRole('button', { name: '로그인' }) as HTMLButtonElement //~> textContent가 '로그인'인 button을 찾습니다. 

      expect(로그인_버튼).toBeEnabled()   //~> Error: expect(element).toBeEnabled()

      fireEvent.submit(로그인_버튼)
    })
  })
})

제일 마지막의 ‘expect(로그인_버튼).toBeEnabled()’는 버튼의 활성화 여부를 검사하는 것인데 검사를 통과하지 못했습니다.

이에 대해 여러 가지 검증을 진행하였으나 로그인 코드에 문제는 없었습니다.

그렇다면 id값과 password 값이 정상적으로 입력됐으면 버튼이 활성화되도록 코드를 수정하면 어떨까요?

이 경우에는 버튼이 활성화되었습니다. 즉 react-hook-form 모듈에서 가져온 IsValid라는 값에 문제가 있었습니다.

알아보니 이런 형태의 라이브러리는 공식 문서에 테스팅 방법이 따로 기술되어 있습니다.

라이브러리에서 제시하는 테스팅 방법을 따르지 않았기에 겪은 것입니다.

이처럼 API 테스팅을 해야 하는 Apollo client도 공식문서에 테스팅 방법을 따로 기술하고 있으며,

라우트 테스팅를 해야하는 React-router-dom도 따로 기술하고 있습니다.

테스트 코드를 잘 모르는 사람일수록 의미가 없을 가능성이 크다

테스트 코드에 잘 모르는 사람일수록 이런 많은 시행착오를 겪을 것이며, 기능 구현 코드를 작성하는 것보다 더 오랫동안 코드를 작성할 수도 있습니다.

일정으로 인해 테스트 코드까지 작성할 여력은 없을 것이고, 기능을 손수 테스트 하는 것이 더 쉽고 빠르기 때문에 테스트 코드를 포기할 가능성이 높습니다.

글을 마치며

그렇다면 테스트 코드를 포기해야 할까요? 제가 생각하는 답은 ‘아니요’입니다.

회사 제품에 적용할 시간은 부족할 수 있으나 개인적으로 공부를 하여, 열심히 갈고닦아 시도하다 보면,

안정성 높은 제품을 만들 수 있을 것이며, 그것은 곧 퇴근 시간을 보장해 줄 것입니다.