본문 바로가기

테스트

스토리북 인터렉션 테스트는 왜 이렇게 잘 깨질까?

세줄 요약

  1. 테스트를 한꺼번에 돌리면 깨진다.
  2. 전체 테스트와 개별 테스트의 차이를 이해하고, 스토리 간 독립성을 보장했다.
  3. 테스트결과가 파랗게 물들었다!

 

동기

TDD를 도입했습니다. 그런데 전체 테스트를 돌리면 자꾸 깨져서 수시로 테스트를 돌릴 수가 없었습니다.

그래서 문제가 발생시 빠르게 확인하는 이점을 얻을 수가 없었습니다.

 

저는 스토리북으로 UI를 만들고 컴포넌트 테스트를 작성합니다.

그동안 개별 테스트를 통과시키고 컴포넌트 명세의 용도로 사용하고 있었는데, 사실 테스트 코드를 쓰는 본질은 그게 아니라고 생각합니다. 지금은 마치 맥북을 사놓고 인터넷과 유튜브만 하는 느낌이랄까요..?

테스트 코드를 통해 기존 기능과 관련된 기능을 개발한다던지 수정한다던지 리팩토링을 진행할 때

기존 테스트가 깨지지않는지 수시로 체크하고 배포하기 전에 문제가 있는지 확인하고 싶었습니다.

하지만 제가 작성한 테스트는 개별 테스트를 돌리면 문제가 없는데, 전체 테스트를 돌리면 실패하는 게 최소 2개씩은 매번 나왔습니다. 그걸 고치면 두더지마냥 또 2개가 튀어나오고를 반복하며 에러가 수 없이 터져 나왔습니다. 가장 힘든 건 고친 코드가 계속 터지는 거였습니다. 그래서 한동안 테스트코드를 기능 명세의 용도로만 사용했습니다.

제 코드에는 왜 이런 문제가 발생하는지, 어떤 요소들이 영향을 주는지를 확인해보겠습니다.

 

개별 테스트? 전체 테스트?

문제 상황에 들어가기에 앞서, 각 테스트 방법에는 어떤 차이가 있는지 정리하고 넘어가겠습니다.

  개별 테스트 (yarn storybook) 전체 테스트 (yarn test-storybook)
실행 환경 실제 브라우저에서 렌더링 Headless Chromium에서 병렬 실행
테스트 실행 리렌더링과 비동기 이벤트가 실제처럼 실행 여러 테스트를 병렬 또는 연속 실행
테스트 간 간섭 독립적으로 실행 간섭 또는 race condition 발생

 

 

문제 상황 1 - 비동기 렌더링

// 문제 코드
export const TestReassignLinkBook: Story = {
  ...
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

	...

    await userEvent.click(canvas.getByRole("button", { name: "폴더이동" }));

    const dialog = within(canvas.getByRole("dialog")); <-------------------- 에러가 난 지점
    await waitFor(async () => {
      ...
    });

    ...
  },
};

 

제가 가장 많은 에러를 냈던 유형 중 하나입니다. 버튼 클릭을 클릭했지만, 다이얼로그를 찾는 다음 행에서 '다이얼로그를 찾을 수 없다'는 에러를 일으킵니다.

 

버튼이 눌리면서 리액트는 상태를 변경시키고 비동기로 렌더링 스케줄을 잡고 작업합니다. 이렇게 DOM에 반영되기 전까지 딜레이가 발생하면서 일련의 비동기 작업이 진행되는 사이, 테스트는 더 이상 기다려주지 않고 다음 줄에서 다이얼로그를 찾게 됩니다. 아직 렌더링되지 않았으니 에러를 발생하는 거죠. 브라우저에서 실제로 렌더링하는 개별 테스트에서는 문제가 되지 않습니다.

 

그래서 테스트가 비동기 작업을 기다리도록 해야 합니다.

// 수정 코드
export const TestReassignLinkBook: Story = {
  ...
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    await waitFor(async function Check() {
      ...

      await userEvent.click(canvas.getByRole("button", { name: "폴더이동" }));
    });

    await waitFor(async function HanldeReassignLinkBooks() {
      const dialog = within(canvas.queryByRole("dialog")!);
      
      ...
    });

    ...
  },
};

내부적으로 waitFor를 쓰는 findByRole이나, waitFor + queryByRole을 쓰는 방법이 있습니다.

 

waitFor은 에러 없이 통과될 때까지 콜백 함수를 반복합니다. 에러가 나면 기본적으로 1,000ms 뒤 재시도합니다.

내부적으로 setTimeout을 씁니다. 이로 인해 이벤트 루프를 돌면서 쌓여있는 비동기 상태 변화와 마이크로태스크 큐가 처리되면서 그 결과가 테스트에 반영될 수 있습니다.

waitFor + getByRole 조합은 괜찮을까?
getByRole은 즉시 에러를 던지고, waitFor 안에서 써도 내부적으로 에러를 계속 던집니다. 다시 말해, 겉보기엔 잘 돌아가지만 내부적으로 waitFor이 계속 try-catch 하면서 에러를 삼키고 반복호출합니다. 그래서 getByRole은 요소가 확실히 있어야 할 때만 사용하기를 권장합니다.
반면 queryByRole은 에러가 아닌 null을 반환하여, 이벤트로 인해 UI가 변경되는 경우에는 queryByRole을 쓰는 걸 권장합니다.

 

 

문제 상황 2 - 스토리 간 독립성(feat. react-query)

// 문제 코드
const meta = {
  ...
} satisfies Meta<typeof LinkList>;

type Story = StoryObj<typeof meta>;


// 테스트 1
export const TestSortRequestURI_MostViewd: Story = {
  ...
};

// 테스트 2
export const TestDeleteLinks: Story = {
  ...
};

// 테스트 3
export const TestReassignLinkBook: Story = {
  ...
};

 

테스트(스토리)가 여러 개 있습니다. 문제는 위에서부터 순서대로 진행하거나 개별로 테스트하면 모두 통과하지만, 아래에서부터 테스트를 하면 두 번째 테스트에서 에러가 발생합니다. 즉, 테스트를 병렬로 진행하면 문제가 발생할 수 있습니다.

 

반대 방향으로 테스트 실행 중, 두 번째 테스트의 결과를 확인했습니다. 링크가 총 4개에서 2개가 남아야 하는데, 1개가 되면서 에러를 발생시켰습니다. 처음에 더 헷갈렸던 건 어떤 테스트에서도 테스트 결과로 링크가 1개가 되는 경우는 없습니다. 링크를 관리하는 상태가 테스트마다 초기화된 상태가 아니면서 꼬였습니다.

 

서버 api를 통해 받은 링크 데이터는 react-query 캐시를 원격 저장소로 사용하여 관리했습니다. 그래서 링크를 삭제하면 캐시를 업데이트하여 가져다 씁니다. 테스트 간에 캐시를 초기화되지 않은 상태로 그다음 테스트가 진행되면서 문제가 생긴 것입니다.

// 수정 코드
const meta = {
  ...,
  beforeEach: () => {
    ...
    queryClient.clear();
  },
} satisfies Meta<typeof LinkList>;

queryClient.clear()를 통해서 각 테스트가 시작되기 전, 캐시를 초기화했습니다.

스토리북에서도 컴포넌트의 상태를 변경하는 경우, 다른 스토리를 렌더링하기 전에 상태를 리셋하는 게 중요하다고 합니다.

 

preview.ts 파일에서 전역 설정으로 매번 캐시를 삭제하는 걸 고려했지만, 이 설정은 테스트뿐만 아니라 UI 렌더링 스토리에서도 실행됩니다. 그래서 퍼포먼스에 안 좋은 영향을 줄 수 있다고 판단해서 개별 테스트에만 적용하기로 했습니다.

 

 

문제 상황 3 - DOM과 테스트 환경

// 문제 코드
const meta = {
  ...,
  decorators: [
    (Story) => (
      <form
        className="flex flex-col gap-2"
      >
        <Story />
        <button type="submit" className="bg-primary-500 text-white outline">
          submit
        </button>
      </form>
    ),
  ],
} satisfies Meta<typeof TitleInput>;

...

 

마지막으로 form 컴포넌트에서 발생한 에러입니다. 에러 메시지를 보면 브라우저 컨텍스트가 리셋되었다고 합니다. 이 에러는 주로 DOM의 변화에 의해 발생합니다. 예를 들면 navigation에 의해 페이지 새로고침이나, 이동 그리고 컴포넌트의 언마운트 상황에서 발생합니다. 

 

저는 form 컴포넌트를 래퍼 컴포넌트로 사용했습니다. 그래서 버튼을 누리면 폼을 제출하고 페이지가 새로고침되면서 에러가 발생합니다..

form 태그의 onSubmit 메서드에 onSubmit={(e) => e.preventDefault)}를 넣어서 페이지 새로고침을 막았습니다.

// 수정 코드
const meta = {
  ...,
  decorators: [
    (Story) => (
      <form
        className="flex flex-col gap-2"
        onSubmit={(e) => e.preventDefault()}
      >
        <Story />
        <button type="submit" className="bg-primary-500 text-white outline">
          submit
        </button>
      </form>
    ),
  ],
} satisfies Meta<typeof TitleInput>;

...

 

 

짠~!

전체 테스트를 돌리니 아주 만족스러운 결과가 나왔습니다. 이제 수시로 테스트를 돌릴 수 있게 되었습니다😁

 

 

참고

https://storybook.js.org/docs/writing-stories/play-function#working-with-the-canvas

'테스트' 카테고리의 다른 글

Storybook으로 시각적 요소 테스트하기  (0) 2024.06.08