JIGGAG

TDD 아름다운 도전이였다

2022년 1월 9일

그동안의 테스트

항상 계획에만 존재하는 것, 테스트 코드 작성하기이다.

그러나 이번에 더미 데이터로 확인하는데 한계가 있는 여러 케이스를 확인해보고자 테스트를 작성해 두면 마음이 편안해질 수 있겠지 하며 테스트 코드를 작성한 것이 시작이였다. (아마 데이터가 충분했다면 그냥 지나갔을지 모른다)

그동안 작성한 테스트 코드는 단순한 유틸, 비즈니스 로직을 확인하는 정도였고 컴포넌트 렌더링 테스트도 해봤다고 하기 쑥쓰러운 수준이다. 모든게 완료된 후 로직 검증 및 리팩터링, 훗날의 코드 지킴이를 위한 차원에서 작성한 것들이 대부분이라 해본적이 없는 건 아니지만 해봤다고 하기 민망한 그 사이 어디쯤이다.


그렇게 시작한 도전기

이번에도 마찬가지로 비즈니스 로직, 헬퍼 함수를 테스트하고 있었다. 하지만 비즈니스 로직은 커스텀 훅으로 분리하는 작업을 하다보니 테스트 코드 작성하는게 조금 불편하다고 느껴졌다.

훅을 테스트하는게 아니라면 훅 안에서 사용하는 로직만 따로 분리해서 다시 테스트를 해야하는데, 그럼 불필요하게 코드가 분리되는 모양이 되었다.

그리고 이 훅을 사용하는 컴포넌트에서 훅이 반환하는 결과에 따라 레이아웃이 변경되는 것도 테스트하면 좋겠다고 생각이 들고 있었다.

// useCustomHook.test.tsx
import useCustomHook from './useCustomHook';

...

it('커스텀 훅 테스트', () => {
  const customHook = useCustomHook({ value: 0 });
  expect(customHook.value).toBe(1);
});

근데 이처럼 훅을 테스트하려고 하면 Error: Invalid hook call. Hooks can only be called inside of the body of a function component. 에러가 발생하였다. 훅을 테스트하려면 임의의 컴포넌트로 래핑해서 호출해야하는 것이였다.

하지만 매번 훅을 감싸고 훅의 반환값을 상태로 갖는 컴포넌트를 만들어서 확인하는 것은 너무 귀찮다고 생각이 들었는데, 커스텀 훅을 테스트할 수 있는 라이브러리가 당연히 있었고 그렇게 @testing-library/react-hooks 를 이용해보기로 하였다.


@testing-library/react-hooks

import { renderHook, act } from '@testing-library/react-hooks';
import useCustomHook from './useCustomHook';

interface CustomHook {
  value: number;
  increment: () => void;
}

it('커스텀 훅 테스트', () => {
  const { result } = renderHook(useCustomHook, { initialProps: {
    value: 1,  
  }});
  expect(result.current.value).toBe(1);


  act(() => {
    result.current.increment();
  })
  
  expect(result.current.value).toBe(2);
})

예시처럼 renderHook으로 테스트하고자 하는 훅을 감싸면 RenderHookResult 을 반환하는데, 훅의 반환값이 result.current에 들어가게 된다.

어떻게 훅을 테스트할 수 있게 되었을까?

renderHook 내부적으로 컴포넌트 래핑하는 구조 로 보인다. (여기 서 친절하게도 이미 렌더러에 대해 이야기해주고 있었다) 모든건 라이브러리가 해주니 온전히 커스텀 훅 로직 테스트만 할 수 있게 되었다.

훅의 상태를 업데이트하는 무언가를 호출한다면 act 로 감싸서 업데이트를 확인할 수 있었다. 리액트 사이클을 동작하는 것처럼 만들어주는 모양이다. (비동기 처리에 대해서는 act를 한번 더 래핑한 waitFor* 를 사용해야한다)

그렇다면 왜 result.current 형태를 갖게 되었을까?

커스텀 훅을 래핑한 컴포넌트 에서 렌더링 될 때 마다 setValue 업데이트가 진행되는데, 이를 실제 상태 업데이트가 아니라 result.current가 참조하고 있는 value에 단순히 배열을 추가하는 형태이다(mutable).

따라서 const { value } = result.current 로 분해해서 사용하게 되면 result.current 는 테스트 훅이 렌더링 될 때 마다 새로 추가된 값을 들고 있지만 value는 분해 시점에 할당된 값을 반환하게 된다. (항상 result.current.value를 해야하는 이유이다)

const { result, rerender } = renderHook(() => useCustomHook({ value: 2 }));
const { result } = renderHook(useCustomHook, {
  initialProps: {
    value: 2,
  }
});
const { result } = renderHook(({ value }) => useCustomHook(value), {
  initialProps: {
    value: 2,
  },
  wrapper: ({ children }) => <>{children}</>,
});

다양한 형태로 renderHook을 사용할 수 있는데, renderHook이 반환하는 rerender는 어떤 상황에서 써야하는 것일까?

일반적으로 훅 상태가 업데이트 되는 상황이라면 내부 함수를 호출할텐데, rerender가 필요한 시점은 언제일까? 훅을 사용하는 외부 상황이 변경되었을때, 훅의 콜백 상태 또는 cleanup(unmount라는 함수로 따로 테스트 할 수 있다)을 테스트하고자 리렌더를 필요로 하게 된다.

내부 상태 업데이트 함수를 호출할때에는 act로 감싸는 것과 다르게, rerender는 업데이트된 props를 파라미터로 넘겨주는 (실제 훅이 다시 렌더되는 것 처럼) rerender({ value: 0 }) 형태로 호출하기만 하면 된다.
(변경된 props를 넘기지 않았더니 처음 renderHook을 호출한 props가 그대로 유지되었다)

expect(result.current.value).toBe(2);

rerender(); // 이렇게만 호출하면 초기 상태 그대로 props가 업데이트 되지 않았다 
expect(result.current.value).toBe(2);

rerender({ value: 99 });
expect(result.current.value).toBe(99);

그 외에 renderHook의 옵션으로 wrapper가 있는데, 이는 훅을 래핑할 수 있는 옵션이다. 예시에서는 Context.Provider가 나왔는데, 다른 컴포넌트로 래핑할 수 있을 듯 하다. (그럴거면 그냥 컴포넌트를 테스트하는게 좋지 않을까)


모든 훅을 테스트할 수 있을 것 같다

그러나 모든 커스텀 훅을 @testing-library/react-hooks으로 테스트 해야하는 것은 아니다. 여기 에 쓰여있는 것처럼 단순하거나 단 하나의 컴포넌트에서만 쓰이는 훅이라면 컴포넌트 자체를 테스트하는 것이 더 좋다고 생각한다.

복잡한 비즈니스 로직이 포함된 경우나 컴포넌트와 연결되지 않는 커스텀 훅이라면 훅만 따로 테스트하는 것이 좀 더 명확하게 확인 할 수 있을 것이다.

(이것저것 다 테스트 해보기 전에 정리할 수 있어서 다행이다)


아름답게 시작한 컴포넌트 테스트

하다보니 컴포넌트 렌더링 테스트도 할 수 있겠는데? 하는 알 수 없는 자신감이 부풀어 TDD를 한번 해봐야지! 라는 마음에 도달하였다.

그동안 컴포넌트 테스트하는 척 사용했던 @testing-library/react-native (콜스택 👍) 를 이용해서 안되면 빼면 되지! 하는 마음으로 최대한 부담없이 시작은 해보려고 하였다. (여기까지가 아름다웠던 것 같다)

컴포넌트 스냅샷 말고는 별다른 테스트를 하고 있지 않았고, 그나마 해봤다고 하기에는 지금 돌아보면 별다른 의미가 없었던 (커스텀 훅이나 비즈니스 로직으로 테스트했다면 필요하지 않았을) 텍스트가 잘 렌더링 되는지 정도였다.

그렇기 때문에 컴포넌트 테스트를 시작하기 위해서 컴포넌트를 어떻게 테스트할 수 있는거지?를 알아보아야했다. 콜스택의 문서 잘되어있었고 안되는거 뺴고 쓸 줄 모르는거 빼고 다 되는 것 같았다!

막연하게 시작하려고 하니 testId를 사용해서 해야하는 것 같은데, 그걸 찾으려면 getByTestIdqueryByTestId로 하면 될 것 같은 느낌이다. (❌문서를 읽는 것보다) 먼저 getByTestId로 찾은 컴포넌트 엘리먼트를 가져온다는 것을 시험해봤는데, 당연하게도 에러가 발생했다.

import { render } from '@testing-library/react-native';

const Component = ({ isVisible = false }) => {
  return isVisible ? (
    <View testId="component">
      <Text>test</Text>
    </View>
  ) : null;
};

it('컴포넌트 테스트', () => {
  const {
    getByTestId,
  } = render(<Component />);

  expect(getByTestId('component')).toBeFalsy();
});

전달된 props가 없었으니 Component는 null을 반환하고 testId='component'의 엘리먼트를 찾지 못했기 때문에 falsy하다고 테스트를 작성하면 될 것 같았다. 그러나 이 테스트는 Error: Unable to find an element with testID: component 에러를 내뿜으며 실패했다.

왜? 찾지 못한게 맞는데 에러가 나는거지?


@testing-library/react-native

우선 실패하는 케이스에서 사용했던 것들 먼저 확인해본다.

render는 예상했던대로 컴포넌트를 렌더링 시켜주고 있었다.

import { render } from '@testing-library/react-native';

it('컴포넌트 테스트', () => {
  const { toJSON, queryByTestId } = render(<Component />, {
    wrapper: ({ children }) => <Wrapper>{children}</Wrapper>,
    createNodeMock: (element) => {
      // 테스트하는 컴포넌트의 ref를 제어한다
      return null;
    },
  });

  expect(toJSON()).toMatchSnapshot();
  expect(queryByTestId('component')).not.toBe(null);
  expect(queryByTestId('fake_component')).toBe(null);
});

@testing-library/react-nativerender 옵션에는 wrappercreateNodeMock 두가지가 있는데, wrapperrenderHookwrapper와 동일하고 createNodeMock예시 처럼 DOM ref를 커스텀하는 역할을 하고 있다.

그렇다면 렌더링된 컴포넌트 안에서 확인하고자하는 testId의 엘리먼트 인스턴스를 가져와야하는데, 여기 에 써있듯 다양한 형태의 쿼리(get, query, find)가 존재하고 각각의 쿼리가 반환하는 형태에 따라 어떤 목적으로, 어떤 결과를 테스트할 것인지 적절하게 사용해야한다.

쿼리마다 반환하는 형태를 확인하고나니 왜 에러를 내뿜으며 실패했는지 알 수 있었다...

getBy*는 엘리먼트를 찾지 못하면 에러를 던지고, queryBy*는 null을 반환하게 된다. getBy*findBy*는 반환하는 형태는 동일한데, retry 여부가 차이가 있었다. (get하느냐 find하느냐의 의미와 통한다고 생각한다)


여러가지 옵션이 있는데

컴포넌트 렌더링을 테스트하다보니 이벤트가 발생했을때 변경되는 컴포넌트도 추가로 확인하고 싶어졌다. 이미지 로드되는 순간을 어떻게 트래킹할 수 있지? 생각하고 있었는데, fireEvent를 이용해 해결할 수 있었다.

import { fireEvent, render } from '@testing-library/react-native';

it('컴포넌트 테스트', () => {
  const { queryByTestId } = render(<RenderImage />);
  
  expect(queryByTestId('image_label')).toBe('loading...');
  fireEvent(queryByTestId('image') as ReactTestInstance, 'onLoad');
  expect(queryByTestId('image_label')).toBe('loaded');
});

쓰다보니 ByText, ByLabel, ByRole... 여러가지가 있는데 계속 ByTestId만 사용하고 있다! 내가 확인하고 싶은 엘리먼트를 가장 명확하게 접근할 수 있어서 인 것 같은데, 텍스트만 가져와서 테스트하고 싶을때 써봐야지!

testing-library render 테스트 코드 예시 를 보는 것도 재미있었다! (그럼에도 debug는 어떤 용도로 쓸 수 있을지 생각이 이어지지 않고 있다...)


어김없이 눈이 가는

혹시나 설마하며 eslint-plugin-testing-library... lint는 없는 것이 없다!

필요하다고 생각이 드는 시점을 단순하게 생각해보면, 예시로 나와있듯 testID 컨벤션을 유지하기 위한 것 아닐까


내 친구 시나리오

이런 테스트 과정을 통해 컴포넌트 스냅샷, 초기 상태와 사용자의 동작에 따라 업데이트된 상태를 확인할 수 있었다.

또한 중간에 변경되는 로직을 확인하기 위해 테스트 시나리오는 내 친구를 경험해봤기 때문에 믿고 시작했고 역시나 이번에도 도움을 주었다.

그러나 여기서 놓치고 지나갔던 부분을 리뷰를 통해 다시 돌아볼 수 있었는데, 비즈니스 로직을 모두 훅으로 분리한 상태에서 컴포넌트를 테스트해야지 하는 욕심으로 불필요한 테스트 코드를 작성하고 있었다. 컴포넌트가 갖고 있는 상태가 모두 커스텀 훅에 의존하고 있었기 때문에, 훅을 테스트하는 것만으로도 검증할 수 있었던 것이였다.

위에 단순하거나 단 하나의 컴포넌트에서만 쓰이는 훅이라면 컴포넌트 자체를 테스트하는 것이 좋겠다와 같은 맥락으로 복잡한 컴포넌트이지만 비즈니스 로직을 담고 있는 커스텀 훅을 테스트를 통해 검증했다면 그에 따르는 컴포넌트를 중복해서 테스트할 필요가 없지 않을까?

참고: @testing-library의 솔루션


그래서 갑자기 마무리

함께 자라기의도적 수련이라는 내용이 있다. 지루함과 불안함 그 사이, 몰입이라는 단계의 전략을 가져가는 것이다. (수련이 되었는지는 모르겠지만) 이 의도를 가지고 시작하였는데, 생각만큼 아름답게 되지는 않았던 것 같고 마음의 수련을 더 많이 한 것 같다.

테스트 코드를 추가한 것 만으로도 이미 적절한 난이도를 추가했다고 스스로 생각해보며 시작은 TDD 도전기였는데, TDD를 했다고 하기 보다는 테스트 알아보기 라고 느껴진다.

시작은 TDD였는데, 어느 순간 T가 있는듯 없는듯 있긴 하지만 DD는 하고 있지 않는 그런 사이?

도전한다고 아름답게 되지는 않았다. 그냥 DD 빼고 T만이라도 함께 하는 것이 아름다운 도전이였다고 소박하게 생각해본다 🙈

(+ 한참 정리를 하고 있던 중간에 테스팅 라이브러리란? 번역 글이 올라왔는데, 너무 타이밍 적절하여 좋았다!)