Please enable JavaScript to view the comments powered by Disqus.

React hook 테스트 코드에서 Recoil snapshot 참조하기

컴포넌트 props의 조작

React의 커스텀 훅 함수를 테스트하다가 아래와 같은 상황을 만나게 되었다.

  • 커스텀 훅 C는 Recoil 상태를 업데이트하는 로직을 가지고 있다.
  • Recoil 상태 A는 커스텀 훅 함수 안에서 업데이트되고, 리턴된다.
  • Recoil 상태 B는 커스텀 훅 함수 안에서 업데이트되지만, 리턴은 되지 않는다. 즉 커스텀 훅 C를 통해서는 상태 B에 접근할 수 없다.
  • 테스트 코드에서 커스텀 훅 C에 의한 상태 A의 변경은 물론, 상태 B의 변경도 확인하려고 한다.

구체적인 예를 들자면 이미지 목록과 선택된 이미지를 관리하는 상태가 있다고 가정한다. 커스텀 훅에서는 이미지 목록을 백엔드 서버로부터 가져온 후 목록 상태를 업데이트하고, 선택된 이미지는 초기화하는 로직을 가진다.

import { atom } from 'recoil';

// 이미지 목록
export const imageListState = atom<string[]>({
  key: 'imageListState',
  default: [],
});

// 선택된 이미지
export const selectedImageState = atom<string | null>({
  key: 'selectedImageState',
  default: null,
});
function useImageList() {
  const [images, setImages] = useRecoilState(imageListState);
  const setSelectedImage = useSetRecoilState(selectedImageState);

  useEffect(() => {
    try {
      axios.get('https://test.api.com/images').then(({ data }) => {
        setImages(data); // 이미지 목록 상태 업데이트 
        setSelectedImage(null); // 선택된 이미지 초기화
      });
    } catch (error) {
      console.error(error);
    }
  }, [setImages, setSelectedImage]);

  return {
    images,
  };
}

그러면 위의 훅 함수로 이미지를 가져온 후 상태 업데이트를 확인하는 테스트 코드는 아래와 같이 작성할 수 있다. 네트워크 리퀘스트의 mocking에는 msw를 사용하고, 커스텀 훅의 실행에는 @testing-library/react-hooks를 사용한다.

describe('useImageList', () => {
  beforeAll(() => {
    mockServer.listen(); // start msw server
  });

	afterEach(() => {
	  mockServer.resetHandlers(); // reset msw server
	});

  afterAll(() => {
    mockServer.close(); // close msw server
  });

  test('이미지 목록 fetch 후 선택된 이미지는 초기화된다.', async () => {
    mockServer.use(
      rest.get('https://test.api.com/images', (req, res, ctx) =>
        res(
          ctx.json([
            'https://cdn.images.com/collectionA/1.png',
            'https://cdn.images.com/collectionA/2.png',
          ]),
        ),
      ),
    );

    const { result } = renderHook(() => useTest(), {
      wrapper: ({ children }) => <RecoilRoot>{children}</RecoilRoot>,
    });

    await waitFor(() => {
      expect(result.current.images.length).toBe(2);

      // TODO: 그런데, selectedImageState의 확인은 어떻게?
    });
  });
});

이 테스트 코드로는 이미지를 가져오는 것은 확인할 수 있지만, selectedImageState 상태가 null 값으로 바뀌었는지는 확인할 수 없다. useImageList 훅이 그 상태 값을 리턴하지 않기 때문이다.

그럼 필요한 값을 훅에서 리턴하면 간단하지 않은가?라고 생각할 수 있다. 하지만 그 말은 실제로는 사용하지 않으면서 오직 테스트를 위해 Recoil 상태를 구독해야 한다는 의미가 된다.

function useImageList() {
	const selectedImage = useRecoilValue(selectedImageState);

	return {
	  selectedImage
	}
}

그리고 커스텀 훅을 목적에 따라 분리해서 개발하다 보면 어떤 상태 관리 도구를 쓰든 특정 상태를 여러 커스텀 훅에서 업데이트하게 되는 상황이 종종 생긴다. 그럴 때마다 테스트를 위해 위와 같은 코드를 추가해야 할까? useSelectedImage라는 목적이 분명한 커스텀 훅이 있는데도?

이 문제는 통합 테스트(Integration test)를 통해 자연스럽게 해결할 수도 있다. 테스트 코드에서 커스텀 훅을 사용하는 컴포넌트를 직접 렌더링 하여 서버에서 불러온 이미지 목록이 표시되고, 기존에 선택된 이미지는 해제되는지 확인하는 것이다. 하지만 통합 테스트는 리소스가 많이 사용되어 유닛 테스트보다 느리고, 유지 보수에 따른 디버깅이 더 어렵다는 단점이 있다. 예를 들어 이미지 목록 컴포넌트가 수정된다면 통합 테스트에서 이미지를 찾는데 사용하는 쿼리도 수정해야 할 가능성이 생기기 때문이다. 그리고 만약 유닛 테스트가 잘 작성되어 있다면 상태 관리 로직에 오류가 있는 것인지, 단순히 마크업이 변경되어서 생기는 테스트 실패인지 쉽게 파악할 수 있다.

Recoil 상태를 엿보기 위한 컴포넌트 구현

커스텀 훅 함수에서 리턴하지 않는 Recoil 상태를 확인하기 위해서는 다른 방법이 필요하다. 필요한 요구사항은 다음과 같다.

  • renderHook 함수가 호출된 스쿠프에서 선언된 변수에 커스텀 훅에 의한 상태 변경을 반영하는 Recoil snapshot이 할당되어야 한다.

Recoil 상태의 구독은 useRecoilSnapshot 훅을 사용해서 가능하다. Snapshot을 사용하면 RecoilRoot 컴포넌트 안에서 사용하는 모든 Recoil 상태에 접근이 가능하다.

function useSnapshot() {
  const snapshot = useRecoilSnapshot();
  console.log(snapshot.getLoadable(selectedImageState).contents)
}

renderHookwrapper 옵션에서 사용할 수 있도록 PeekSnapshot 컴포넌트를 구현했다.

export type SnapshotPeekRef = {
  current?: {
    snapshot: Snapshot;
    get: (state: RecoilValue<any>) => any;
  };
};

export const PeekSnapshot: React.FC<{ peekRef: SnapshotPeekRef }> = ({
  peekRef,
}) => {
  const snapshot = useRecoilSnapshot();

  peekRef.current = {
    snapshot,
    get: (state: RecoilValue<any>) => snapshot.getLoadable(state).contents,
  };
  return null;
};

PeekSnapshot 컴포넌트의 peekRef props는 SnapshotPeekRef 타입의 객체다. 컴포넌트 안에서 Snapshot을 구독한 후 peekRef props의 current 필드에 필요한 기능을 할당한다.

사실 컴포넌트 내부에서 props 객체를 조작하는 것은 React의 안티 패턴이긴 하다. React는 위에서 아래로의 단방향 데이터 흐름을 가지며, 2-way 바인딩을 가능하게 하는 API가 없다. 하지만 저런 방식이 아니면 테스트 코드에서 전달한 props 객체에 Snapshot을 전달할 방법을 찾기가 어렵다. 그리고 Snapshot 구독만 하기 때문에 다른 컴포넌트에 영향을 주지 않으므로 상관없다.

이제 PeekSnapshot 컴포넌트를 활용하여 Recoil 상태를 확인할 수 있다.

// 이 객체를 PeekSnapshot의 props로 보낸다.
const peekRef: SnapshotPeekRef = {}; 

const { result } = renderHook(() => useImageList(), {
  wrapper: ({ children }) => (
    <RecoilRoot
			initializeState={({ set }) => {
        // 확인할 Recoil 상태의 초기값을 설정한다.
        set(selectedImageState, 'https://cdn.images.com/another.png');
      }}
		>
      {children}

			{/* Snapshot을 구독할 것이므로 RecoilRoot 아래에 추가해야 한다 */}
      <PeekSnapshot peekRef={peekRef}></PeekSnapshot>;
    </RecoilRoot>
  ),
});

await waitFor(() => 
	expect(result.current.images.length).toBe(2);

	// peekRef 객체를 통해 Recoil 상태가 null로 초기화 되었는지 확인한다.
	peekRef.current?.get(selectedImageState).toBeNull()
});

컴포넌트의 props를 조작하면 어떤 문제가 생기는가?

문제는 해결되었지만 생각해 볼 만한 주제가 있다. 부모 컴포넌트의 props를 수정했을 때 어떤 문제가 생길까?

props로 객체가 아닌 리터럴 데이터를 전달했다면 props를 조작해도 상위 컴포넌트에 영향을 미칠 수 없다. 자바스크립트에서 함수를 호출할 때 파라미터에 리터럴 값을 사용하면 변수의 값 자체가 전달될 뿐 변수의 레퍼런스가 전달되는 것이 아니기 때문이다.

test('call with value does not modify original', () => {
  let original = 1;

  function resetter(target) {
    target = 2;
  }

  resetter(original);

  // 함수 안에서 파라미터를 덮어썼지만 original 값은 변하지 않았다.
  expect(original).toEqual(1);
});

하지만 파라미터로 객체를 넘긴다면 이야기가 다르다. 파라미터로 객체를 사용하면 값이 아닌 객체를 할당한 변수의 레퍼런스가 전달되고, 함수 안에서 그 객체의 필드를 수정하면 상위 스쿠프의 변수에 영향을 미치게 된다.

test('call with reference modifies original', () => {
  let original = {
    value: 1,
  };

  function mutator(target) {
    target.value = 2;
  }

  mutator(original);

  // 함수 호출에 의해 객체의 필드가 수정되었다.
  expect(original.value).toBe(2);
});

예를 들어 부모 컴포넌트에서 상태 객체 A의 필드 value를 렌더링에 사용하고 있는데, 자식 컴포넌트에서 그 객체를 props로 받은 후 value 필드를 다른 값으로 수정해서 렌더링에 사용했다고 가정하자. 부모와 자식 모두 각은 객체의 같은 필드를 사용해서 렌더링 했는데, 화면에는 다른 값이 표시되는 상황이 벌어진다. React는 2-way 바인딩을 지원하지 않으며, 객체의 조작(mutation)은 리렌더링을 유발하지 않기 때문이다.

만약 부모의 상태 객체를 자식 컴포넌트에서 수정하고 싶다면 업데이터 함수를 작성한 후 그것도 자식 컴포넌트에 props로 전달해야 한다.

const Parent = () => {
  const [original, setOriginal] = useState({ value: 1 })

  const updateOriginal = useCallback(
    (next) => {
      setOriginal(next)
    },
    [],
  )

  return (
    <div>
      value is {original.value}
      <Child original={original} onUpdateOriginal={updateOriginal}></Child>
    </div>
  );
};

2-way 바인딩을 지원한다면 저런 추가적인 함수와 props가 필요 없어진다. 그리고 앱에서는 컴포넌트를 무식하게 큰 덩어리로 작성하지 않는 이상 부모 상태를 하위에서 수정해야 하는 케이스는 언제나 발생한다. 그러다 보면 자연스럽게 유연한 상태 관리를 위해 Redux, Mobx, Recoil 같은 라이브러리를 도입하게 된다.

단방향 데이터 흐름은 데이터의 소유, 관리 주체를 확실히 구분하게 된다는 장점은 있지만 코드에서 해야 할 말이 더 많아진다는 단점도 가지고 있다.

References