[우아한테크코스 FE LEVEL2-3] 복잡한 상태관리 & RTL

Gyuhan Park·2024년 6월 1일
1

[ 학습 목표 ]

  • Recoil 을 사용하여 클라이언트 상태를 관리할 수 있다.
  • React Testing Library(RTL) 를 활용하여 주요 기능에 대한 테스트를 작성할 수 있다.
  • 복잡한 파생 상태 를 관리할 수 있다.
  • RTL 테스트를 고도화시킬 수 있다.

💭 TMI

레벨 2 세번째 미션이 마무리되었다. 이번 미션 로직이 가장 복잡했고 또 한편으로는 재밌었던 미션이였다. 테코톡과 스터디 발표가 겹치면서 step1 때는 바쁘게 보냈지만 미션 요구사항 자체가 흥미로워서 리팩터링 과정도 즐거웠던 것 같다. 이번 미션을 하기 전에 얻어가고 싶은 2가지 목표를 정했었다.

  1. recoil이 어떻게 상태관리 하는지 이해하기 (selector 제대로 이해하기)
  2. RTL로 컴포넌트 단위의 렌더링과 비동기 호출을 자유롭게 테스트할 수 있다.

어떤 것을 테스트해야 하는가? 에 대한 고민도 있었지만 테스트 코드 자체가 낯설어서 조금만 복잡해져도 손을 대지 못했다. 이번 미션을 진행하면서 mocking에 대해 좀더 이해하고, 어떤 테스트 코드를 작성할 지에 대한 고민도 많이 하게 되었다.

레벨1 때는 테스트하기 좋은 코드를 작성하기 위해 mocking을 지양하라고 했었는데, API 같은 경우 외부 시스템으로 간주하고 테스트 영역이 아니기 때문에 mocking을 하는 게 적절하다. 테스트 코드 디버깅도 점점 익숙해지고 있는 것 같아서 신기했다.

📘 배운점 및 느낀점

✅ recoil

recoil을 사용한다고 했을 때 써본 거라 쉬울 거라고 생각했는데, 내가 썼던 것은 엄청 간단한 부분이였다. atom으로 로그인 토큰 관리하는 게 전부였는데, atomFamily, selector, selectorFamily 등 recoil의 다양한 기능들을 학습할 수 있었다. 또한 복잡한 상태관리를 통해 여러가지 고민들을 할 수 있었다. 결국에는 읽기 좋은 코드, 의도가 명확한 코드를 작성하기 위해 상태를 최소화하고 불필요한 상태를 제거하는 작업을 지속적으로 진행하였다.

  1. 로컬 상태로 관리할 데이터와 전역 상태로 관리할 데이터를 구분할 수 있는가?
  2. 해당 데이터를 recoil로 관리했을 때 어떤 장점이 있는가?
  3. 특정 데이터로부터 계산할 수 있는 파생 상태인가?

API 호출도 미션에 포함되면서 낙관적 업데이트 에 대한 고민도 따라오게 되었다. 처음 구현할 때는 사용자 상호작용에 대한 즉각적인 피드백(UX)을 위해 무조건 낙관적 업데이트가 좋은 것 아닐까? 라고 생각했다. 하지만 결제라는 도메인에 대해 낙관적 업데이트는 위험하다는 생각이 들었고, 다른 쇼핑몰 래퍼런스를 찾아봤을 때 모두 로딩 상태를 표현하거나 모달을 사용하여 낙관적 업데이트를 적용하지 않았다. (쿠팡, 무신사, G마켓, 아마존, 네이버 쇼핑)

가장 고민되었던 부분은 수량을 atom에서 한번에 관리할지 atomFamily로 관리할지였다. atom으로 관리할 경우 atom이 최상단에서 선언되므로 setState하면 전체가 리렌더링되는 반면, atomFamily를 사용하면 실제로 변경된 부분만 리렌더링된다. 이러한 렌더링 최적화 측면에서의 장점이 있었지만 나는 atom으로 관리하는 방법 을 선택하였다. atomFamily를 사용하면 서버에서 내려주는 수량 데이터와 클라이언트에서 관리하는 수량 데이터가 독립적으로 동작한다는 느낌을 받아 데이터 정합성 측면에서 문제가 있다고 생각 했다. 그래서 서버 데이터와 매번 동기화시키기 위해 해당 방식을 채택하였다.

또한 이전에 atom으로 토큰만 관리할 때는 state처럼 사용하기 때문에 문제가 없었어서 recoil 공식문서를 찾아볼 필요가 없었는데 생각보다 많은 기능들을 제공하고 있었다. 그리고 생각보다 많은 기능에 UNSTABLE이 붙어있다. 아마 내부적으로 엣지케이스가 존재하고, 메모리 누수 문제가 지속적으로 발생하여 1버전으로 못올리는 것이 아닌가 조심스럽게 예측해본다.

✅ RTL (React Testing Library)

RTL로 쿠폰 할인 요구사항에 대한 테스트 코드를 모두 작성하였다. 사용자 시나리오를 고려하면서 테스트 명세를 작성하였고, 테스트 코드를 작성하면서 어떤 부분이 구현되지 않았는지를 확인할 수 있었다. 구현하다보면 요구사항의 일부를 놓치는 경우가 있는데, 이를 테스트 코드를 통해 다시 한 번 확인하는 과정을 거치게 되었다.

쿠폰 기능 테스트는 renderHook을 활용하여 hook 위주의 테스트를 진행하였다. Recoil의 비동기 selector를 사용하기 때문에 fetch API를 mocking 하는 것보단 RecoilRoot의 초기값을 설정하여 테스트 코드를 작성하였다.

컴포넌트 테스트는 거의 작성하지 못했지만 render를 활용하여 컴포넌트를 렌더링하고 사용자의 상호작용에 대한 화면 반응을 테스트하는 것으로 이해하였다. 삭제 버튼을 클릭했을 때 아이템이 삭제되는지를 확인하는 등의 테스트를 작성해볼 수 있다.

🚨 환경변수 import.meta 를 찾지 못하는 오류

빌드 도구를 webpack이 아닌 vite로 설정하였는데, vite는 환경변수를 process.env가 아닌 import.meta를 사용한다. 테스트 코드를 작성할 때 fetch API에 사용되는 환경변수 때문에 에러가 발생하여 테스트를 진행하지 못했었다. jest.mock 을 활용하여 함수 자체를 모킹하고 함수의 반환값을 initializeState 로 대체하여 API 계층을 테스트할 도메인 계층과 분리할 수 있었다.

또한 비동기 selector를 사용한다면 mount 되기 전에 상태를 업데이트할 수 없다는 오류가 발생한다. 이 때는 children을 Suspense 로 감싸서 해결할 수 있다.

jest.mock('fetch API 호출하는 함수 파일 경로', () => ({
  함수명: jest.fn(),
}));

describe('...', () => {
    it(`...`, async () => {
      const { result } = renderHook(() => {...},
        {
          wrapper: ({ children }) => (
            <RecoilRoot initializeState={({ set }) => { set(..., ...); }}>
              <Suspense fallback={null}>{children}</Suspense>
            </RecoilRoot>
          ),
        },
      );

      await waitFor(() => {
        expect(...).toBe(...);
      });
    });
  });

🚨 fireEvent vs userEvent

fireEvent.click

단순히 click 이벤트만 발생시킴 (오직 발생시킨 click 이벤트에 대해서만 처리)

userEvent.click

어떤 element에서 발생한 이벤트인지 체크한 후에 해당 element 에서 클릭이벤트 호출 시 실제 브라우저에서 발생하는 모든 이벤트를 같이 발생 시킴
ex) click 이벤트를 발생시킬 때 함께 동작될 수 있는 hover와 같은 이벤트들도 함께 실행

userEvent는 사용자가 이벤트를 발생시킬 때와 동일한 결과를 만들어내기 때문에 사용자 입장에서 테스트가 필요할 때 좋음

$ npm i -D @testing-library/user-event
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from'@testing-library/user-event';
import Interaction from'./interaction';

describe('interaction test',() => {
    test('fireEvent test',async () => {
        render(<Interaction />);

        fireEvent.click(screen.getByTestId('button'));
        expect(await screen.findByTestId('click-text')).toBeInTheDocument();

        fireEvent.mouseOver(screen.getByTestId('button'));
        expect(await screen.findByTestId('hover-text')).toBeInTheDocument();
    });

    test('userEvent test',async () => {
        render(<Interaction />);
        userEvent.click(screen.getByTestId('button'));

        expect(await screen.findByTestId('click-text')).toBeInTheDocument();
        expect(await screen.findByTestId('hover-text')).toBeInTheDocument();
    });
});

🚨 mockResolvedValue

Recoil을 사용하지 않는다면 RecoilRoot의 initilizeState를 사용하지 않고, jest만으로 API의 반환값을 모킹할 수 있다. 각각의 테스트 케이스에서 다른 반환값이 필요해서 나는 테스트 케이스 안에서 mockResolvedValue 를 사용하였는데, 같은 반환값인 경우 mocking 할 때부터 반환값을 지정해줄 수도 있다.

컴포넌트 테스트를 할 때 요소를 찾기 위해 screen 을 활용하는데 이때 import '@testing-library/jest-dom'; 을 추가해줘야한다. 테스트 파일이 많다면 jest.setup.js 파일을 만들어 jest.config.js 에서 연결하여 설정해주는 방식을 사용하는 게 적절할 것이다.

import '@testing-library/jest-dom';

import { render, screen, waitFor } from '@testing-library/react';

jest.mock('fetch API 호출하는 함수 파일 경로', () => ({
  fetchItems: jest.fn(),
}));

test('...', async () => {
  // 모킹된 데이터 설정
  (fetchItems as jest.Mock).mockResolvedValue(...);

  render(
    <RecoilRoot>
      <Suspense fallback={<div>Loading...<div/>}>
        <TestComponent />
      </Suspense>
    </RecoilRoot>,
  );

  // 로딩 화면 확인
  expect(screen.getByText('Loading...')).toBeInTheDocument();

  // API 호출 후 데이터가 렌더링되는지 확인
  await waitFor(() => {
    expect(screen.getByText('텍스트')).toBeInTheDocument();
  });
});
profile
단단한 프론트엔드 개발자가 되고 싶은

0개의 댓글