vite 프로젝트에서 msw, vitest를 이용해 테스트 코드 작성해보기 (2)

기운찬곰·2023년 3월 17일
8
post-thumbnail

Overview

저번 시간에 이어 오늘은 본격적인 테스트 코드를 작성해볼 예정입니다. 제가 원하는 테스트는 저번 시간에 말씀드렸다 싶이 다음과 같습니다. 4가지 유형에 대해 테스트 코드를 작성해보도록 하겠습니다.

  • 기본적으로 화면에 정보를 잘 보여주는지
  • 선택지를 선택했을 때 css가 잘 바뀌어서 사용자에게 선택되었다는 사실을 보여주는지
  • 제출 버튼 클릭 후 정답일 때와 오답일 때 적절한 응답이 오는지
  • 문항 정보 데이터를 못 불러왔을 때 처리는 제대로 하는지

렌더링 된 결과에 대해 기본적인 테스트를 해보자

문제 제목에 대해 일단 테스트 해보기로 했는데... 그러려면 vitest에 expect랑 react-testing-library에서의 screen 같은 것을 기본적으로 사용할 줄 알아야 합니다.

근데 해당 요소를 쿼리할 때, className으로 가져오는건 권장되지 않는다고 합니다. 그래서 그런가 screen에 getClassName 뭐 이런건 없고 대부분 많이 쓰는게 getByRole 같습니다.

아 그리고 이렇게 찾은 요소에 텍스트를 비교 테스트 하고 싶을 때 toHaveTextContent 라는게 있는데, vite는 없더군요... 생각 외로 jest랑 vite가 좀 달랐습니다. jest에는 있는게 vite에는 없고, vite에 있는게 jest는 없기도 합니다. ㅠ


일단 요소를 찾아서 비교를 해보는 간단한 테스트 코드를 작성해서 실행해봤는데 해당 요소를 못찾아서 에러가 발생하더군요.

TestingLibraryElementError: Unable to find an element with the text: 정답확인. 

그래서 도대체 render가 제대로 된 건지 확인을 해볼 필요가 있었습니다.

debug 참고 : https://blog.logrocket.com/using-react-testing-library-debug-method/

screen.debug() 하면 결과를 볼 수 있는 거 같은데 <body><div /></body> 이게 끝이더군요...

describe("기본 선다형 문항 테스트", () => {
  it("should render", () => {
    render(<CommonQuestion type="basic" questionId="1" />);
	scree.debug()
  });
});

근데 screen.debug() 는 정말 다른 컴포넌트를 해보면 제대로 나올까요? 라는 의문이 들었습니다. 그래서 한번 해봤습니다. 잘 되더군요.

render(<Loading />);
screen.debug();
<body>
  <div>
    <div
      class="flex items-center justify-center h-screen"
    >
      <div
        class="animate-spin rounded-full h-32 w-32 border-t-4 border-b-4 border-purple-500"
      />
    </div>
  </div>
</body>

CommonQuestion 에는 API 호출하는 부분이 있어서 딜레이가 있어서 그런거 아닐까 생각됩니다. 혹시 render에 async await이 있는지 찾아보니 어떤 곳에서는 await render 를 쓰기도 하는데 이건 잘못된 정보인 거 같아보입니다... 결론적으로 render에 async await은 없습니다.

혹시 setTimeout 을 사용해서 잘 되나 확인할 수 있을까 했지만, 테스트가 끝나면 그냥 끝인거 같습니다.

setTimeout(() => {
      screen.debug();
    }, 2000)

참고 : https://velog.io/@lacomaco/리액트에서-컴포넌트-테스트-수행하기

찾아보니 waitFor이 있더군요. 이걸로는 안되나 싶어서 해봤는데 잘 안되길래, 알고보니 waitFor 옵션 중에 timeout이 있는데 이게 1초이더군요. msw loading 테스트 해본다고 delay(1000)을 줬었는데 미세한 차이로 API 요청은 했지만 응답받아서 render 하는 그 시간까지는 타임아웃이 나버려서 못 찾던거였습니다.

await waitFor(
      () => {
        const submitBtnElement = screen.getByText("정답확인");
        console.log(submitBtnElement);
        screen.debug();
      },
      { timeout: 5000 }
    );

✍️ 그니까 정리해보면 API 통신과 렌더링 시간이 걸리는 작업에서는 waitFor을 사용하면 됩니다. 대신 1초가 넘어가면 못찾는다고 에러가 발생하니 1초가 넘어가는 경우에는 timeout 옵션을 늘려줍니다. 그러면 찾을 때까지 에러가 발생하지 않고 기다립니다.

react testing library - query 방법 정리

테스트 코드를 작성하려면 기본적으로 익숙해져야 할 것이 바로 react testing library에서 요소를 query 하는 방법입니다. 근데 이게 종류가 다양하다보니 처음 볼 때는 뭘 써야 할지 감이 안잡힙니다. 그래서 정리를 할 필요가 있었습니다.

알고보면 규칙이 있어서 간단합니다. 예를 들어 ‘getAllByRole’이 있으면 get(쿼리 타입) + All(타겟 개수) + ByRole(타겟유형) 입니다.

쿼리 타입에 경우는 get, find, query 3가지가 있습니다.

  • get : 동기적으로 처리되며 타겟을 찾지 못할 시 에러를 던집니다.
  • find : 비동기적으로 처리되며 타겟을 찾지 못할 시 에러를 던집니다.
  • query : 동기적으로 처리되며 타겟을 찾지 못할 시 null을 반환합니다.

타겟 개수는 다수의 엘리먼트 탐색 시에만 All을 붙이면 됩니다. 표를 확인해보면 어떤 느낌인지 바로 알 수 있을 겁니다.

타겟 유형에 대해서는 여러 함수가 존재합니다. 그 중 ByRole을 권장한다고 합니다.

  • ByRole
  • ByLabelText
  • ByPlaceholderText
  • ByText
  • ByDisplayValue
  • ByAltText
  • ByTitle
  • ByTestId

ByRole에 대해 알아보자면 다음과 같습니다.

  • <button /> 은 암시적으로 button이란 role이 있는 모양입니다. 이런 태그들이 몇개 있는 거 같습니다. h1은 heading이란 role이 있고요.
  • 그렇다면 h2는 어떻게 해야 할까요? 옵션으로 { level: 2 } 를 사용하면 됩니다. 참고 : https://stackoverflow.com/questions/72403446/how-to-check-for-level-in-react-testing-library
  • 그리고 button이 여러개 인 경우 있을 수 있잖아요? 그 때는 getByRole(expectedRole, { name: 'The name' }) 이렇게 사용하면 된다고 합니다.
  • 근데 암시적인 요소로 사용이나 더 이상 어떻게 구분하기 어려운 경우는 직접 role이나 aria-* 같은 세팅을 추가로 해줘서 사용할 수 있다고 합니다.

이슈 사항 - it 할 때마다 render 하면 계속 쌓이는 거 같다...

이건 중간에 하다가 발생한 이슈 사항인데 describe에 it이 여러개 있고 거기서 각각마다 render를 하려고 하는데, 두번째 테스트에서는 이런 에러가 발생하더군요... multimple elements 라니..

TestingLibraryElementError: Found multiple elements with the role "button" and name "정답확인"

혹시 몰라서 첫번째 it에 render 한번만 써주니까 이후 it 부터는 render을 안하고 테스트해보니 되는거 같습니다. 알고 보니 cleanup이라는 게 있더군요. 테스트가 끝날 때마다 render 결과를 싹 다 지워줘야 하는 거 같습니다.

afterEach(() => cleanup())

아니면 beforeAll을 사용해서 describe 처음 시작할 때 한번 실행되도록 해서 사용하는 방법도 있을 거 같습니다.

beforeAll(() => {
    render(<CommonQuestion type="basic" questionId="1" />);
  });

테스트 1. 기본적으로 화면에 정보를 잘 보여주는지

이건 어떻게 테스트 해야 할까요? msw 에서 던져주는 데이터를 알아야 될 거 같은데 그러면 핸들러랑 데이터를 분리해야겠군요. 카카오 엔터테이먼트 FE 기술블로그 처럼요...

그리고 나서 요소에 텍스트가 msw 에서 던져주는 데이터랑 맞는지 여부를 판단하기 위해 toBe만 사용하면 되는 줄 알았는데 그게 아니더군요. jest가 아니라 vitest라서 매번 뭔가 조금씩 다른게 귀찮네요...

- Expected  - 1
  + Received  + 5

  - 우주선 외에 우주를 탐구하기 위해서 사람들은 어떤 노력을 할까요? 다음 설명 중 틀린 것을 고르시오."
  + "<h2
  +   class="text-2xl font-bold"
  + >
  +   우주선 외에 우주를 탐구하기 위해서 사람들은 어떤 노력을 할까요? 다음 설명 중 틀린 것을 고르시오.
  + </h2>"

찾아보니 toBeDefined라는게 있습니다. 근데 이 함수는 그냥 요소가 생성되었는지 여부만 판단하는 거 같습니다.

toBeDefined : “toBeDefinedasserts that the value is not equal to undefined. Useful use case would be to check if function returned anything.”

참고 : https://github.com/testing-library/react-testing-library/issues/379

알고보니 textContent 한다음에 toBe로 비교하면 될 거 같습니다.

expect(screen.getByRole("heading", { level: 2 }).textContent).toBe(
   data[0].question
);

그렇게 해서 테스트 1 - 화면에 제대로 정보를 보여주는지 테스트를 성공적으로 수행할 수 있었습니다.

it("테스트 1. 기본적으로 화면에 정보를 잘 보여주는지", async () => {
    render(<CommonQuestion type="basic" questionId="1" />);

    const { data } = basicQuestionData;

    await waitFor(
      () => {
        // 문제 제목을 잘 보여주는지 - 근데 이걸 어떻게 테스트하지? mock data를 가져와서 비교해야 하나?
        expect(screen.getByRole("heading", { level: 2 }).textContent).toBe(
          data[0].question
        );

        // 선택지를 잘 보여주는지 - input radio가 choice의 개수만큼 있는지
		expect(screen.getAllByRole("radio")).toHaveLength(
          data[0].choices.length
        );

        // 정답확인 버튼을 잘 보여주는지
        expect(screen.getByRole("button", { name: "정답확인" })).toBeDefined();
      },
      { timeout: 5000 }
    );
  });

🤔 다만 msw 에서 직접 데이터를 가져오는 방법은 나중에 실제 API 통신을 하면서 테스트 할 때 문제가 될 부분이겠네요. 다시 한번 생각해봐야 될 부분인거 같습니다.

테스트 2. 선택지를 선택했을 때 css가 잘 바뀌어서 사용자에게 선택되었다는 사실을 보여주는지

사용자가 문제 보기 중에 선택지를 선택했을 때 테투리 색이 바뀌면서 잘 선택되었다는 걸 보여주고 있습니다. 이게 제대로 되는지 한번 테스트 해보도록 하겠습니다.

이 테스트에서는 직접 사용자처럼 이벤트를 발생시켜야 하기 때문에 fireEvent에 click을 사용해서 이벤트를 발생시켜봤습니다.

근데 css 확인 방법은 어떻게 해야할까요? 일단 아래처럼 해서 className을 가져와서 toContain으로 해당 text(className) 내 원하는 텍스트가 있는지 검사 했씁니다. 이게 최선일지는 잘 모르겠네요..

it("테스트 2. 선택지를 선택했을 때 css가 잘 바뀌어서 사용자에게 선택되었다는 사실을 보여주는지", async () => {
    render(<CommonQuestion type="basic" questionId="1" />);

    await waitFor(
      () => {
        // 선택지 요소를 찾는다
        const choiceElements = screen.getAllByRole("radio");

        // 선택지 요소를 클릭한다 - 대충 1번 클릭
        fireEvent.click(choiceElements[0]);

        // css가 잘 바뀌었는지 확인한다.
        expect(choiceElements[0].parentElement?.className).toContain(
          "border-teal-500"
        );
      },
      { timeout: 5000 }
    );
  });

테스트3. 제출 버튼 클릭 후 정답일 때와 오답일 때 적절한 응답이 오는지

제출 버튼 클릭 후 현재는 브라우저에 alert 을 사용해서 정답과 오답을 알려주고 있는데, 이게 브라우저 자체 기능이라서 node 환경에서 테스트하고 있는 vitest 상에서는 문제가 됩니다.

나중에 alert은 dialog? popup? 형태로 바꿔줄 예정이긴 해서 일단은 여기까지만 테스트 하도록 하겠습니다.

it("테스트 3. 제출 버튼 클릭 후 정답일 때와 오답일 때 적절한 응답이 오는지", async () => {
    render(<CommonQuestion type="basic" questionId="1" />);

    await waitFor(
      () => {
        // 선택지 요소를 찾는다
        const choiceElements = screen.getAllByRole("radio");

        // 선택지 요소를 클릭한다 - 대충 1번 클릭
        fireEvent.click(choiceElements[0]);

        // 제출 버튼을 클릭한다.
        fireEvent.click(screen.getByRole("button", { name: "정답확인" }));

        // alert 창이 뜨는지 확인한다.
        // expect(screen.getByRole("alertdialog")).toBeDefined();
      },
      { timeout: 5000 }
    );
  });
Error: Not implemented: window.alert

테스트 4. 서버에서 에러를 던졌을 때 적절한 처리를 하는지

마지막으로 서버에서 에러 상태코드와 에러 메시지를 던져주는 경우를 한번 테스트 해보겠습니다. 이 부분은 기존에 사용하던 msw handler랑 다르게 사용해야 하므로 새로 basicQuestionHandlerException을 만들어줬습니다.

그리고 render 전에 sever.use(basicQuestionHandlerExeception)을 넣어줘서 기존 핸들러와는 다르게 에러를 반환하도록 적용하였습니다.

it("테스트 4. 서버에서 에러를 던졌을 때 적절한 처리를 하는지", async () => {
    // 임의로 server error를 발생시키는 코드를 넣어서 테스트해보자
    server.use(basicQuestionHandlerException);
    render(<CommonQuestion type="basic" questionId="1" />);

    await waitFor(() => {
      // 에러 다이얼로그가 잘 뜨는지 확인한다.
      expect(screen.getByRole("heading", { level: 2 }).textContent).toBe(
        "에러가 발생했습니다. 다시 시도해주세요."
      );

      // expect(screen.getByRole("alertdialog").).toBeDefined();
      //에러가 발생했습니다. 다시 시도해주세요.
    });
  });

근데 이게 처음에 테스트 1 할 때 api 통신을 하고, 그 이후에는 안하는거 같더군요... 분명 에러가 넘어와야 되는데 테스트 1, 2, 3과 똑같은 응답이 넘어옵니다. CommonQuestion 자체는 render가 테스트마다 되는거 같은데...

가장 의심스러운 부분은 api가 캐시가 먹는거 같다는 생각입니다. react-query에 useQuery를 사용해서 그럴거 같다는 생각이 강하게 들더군요.

참고 : https://testing-library.com/docs/react-testing-library/example-intro/

제가 한거랑 딱히 크게 다르지 않더군요. error 테스트 시에 server.use로 다시 msw handler 세팅해서 에러가 반환되도록 했단 말이죠. 여기서 딱한가지 다른건 react-query 대신 axios만 사용했다는 겁니다.

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

test('loads and displays greeting', async () => {
  render(<Fetch url="/greeting" />)

  fireEvent.click(screen.getByText('Load Greeting'))

  await waitFor(() => screen.getByRole('heading'))

  expect(screen.getByRole('heading')).toHaveTextContent('hello there')
  expect(screen.getByRole('button')).toBeDisabled()
})

test('handles server error', async () => {
  server.use(
    rest.get('/greeting', (req, res, ctx) => {
      return res(ctx.status(500))
    }),
  )

  render(<Fetch url="/greeting" />)

  fireEvent.click(screen.getByText('Load Greeting'))

  await waitFor(() => screen.getByRole('alert'))

  expect(screen.getByRole('alert')).toHaveTextContent('Oops, failed to fetch!')
  expect(screen.getByRole('button')).not.toBeDisabled()
})

그래서 저도 그냥 axios만 사용하도록 해봤습니다. 그랬더니 결과가 다르더군요. 테스트 마다 api를 호출하고 있습니다.

const [question, setQuestion] = useState<IQuestionResponse | null>(null);
  useEffect(() => {
    const getQuestion = async () => {
      const response: IQuestionResponse = await client.get(
        `/api/question/${type}/${questionId}`
      );
      console.log("response", response);
      setQuestion(response);
    };
    getQuestion();
  }, []);

참고 : https://www.js-howto.com/testing-react-query-with-mock-service-worker/

(해당 글은 react-query 로 가져온 데이터 자체를 테스트 하는 거 같습니다. 혹은 react-query 기능 자체를 테스트)

근데 여기서 보면 “각 테스트 실행이 완료된 후 캐시를 비활성화/제거하려면 QueryCache가 필요합니다. QueryClient는 테스트가 다른 테스트와 격리되도록 하기 위해 필요합니다.” 라고 되어있는데… 그렇게 해줬는데도 안되는거 같습니다.

const queryCache = new QueryCache();
const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            retry: false,
        },
    },
});

const wrapper = ({ children }: { children: ReactNode }) => (
    <QueryClientProvider client={queryClient}>
        {children}
    </QueryClientProvider>
);

beforeAll(() => server.listen());
afterEach(() => {
    server.resetHandlers();
    queryCache.clear()  // 추가 
});
afterAll(() => server.close());

이것 저것 해보다가 queryClient.resetQueries(); 를 하면 되는 거 같더군요. 일단 이 문제는 이렇게 해결하고 넘어가도록 하겠습니다.

afterEach(() => {
  server.resetHandlers();
  //queryCache.clear();
  queryClient.resetQueries();
});

근데 또 다른 이슈가 발생했습니다. 알고 보니 현재까지 Suspense Loading이나 Error Boudnry 하나도 테스트 코드에서 render가 되지 않고 있었습니다. 결국 Suspense와 ErrroBoundry가 있는 QuestionLayout를 추가적으로 불러와서 render 해줘야 될 거 같습니다. 참 힘드네요...

render(
      <QuestionLayout title="기본 선다형">
        <CommonQuestion type="basic" questionId="1" testNumber={1} />
      </QuestionLayout>
    );

그랬더니 이번에는.. ㄷㄷ react-router-dom에 있는 useNavigate 못쓴다고 합니다. Router 로 처음 main.tsx에서 감싼다음에 시작했었는데 테스트 코드에는 그런게 없으니까 못찾는것입니다... 이러면 메인부터 다 불러와야 될 텐데 그건 좀 아닌거 같아보입니다.

⛔️ Error: Uncaught [Error: useNavigate() may be used only in the context of a <Router> component.]

해결 방법을 찾아보다가 vi.mock 한 다음에 그냥 vi.fn()을 넣어주니까 에러는 사라지더군요. 아마 useNavigate 기능은 없어지고 대충 에러만 발생하지 않도록 빈 함수를 만들어준듯합니다.

vi.mock("react-router-dom", () => ({
  //useHistory: () => ({ push: jest.fn() }),
  useLocation: () => ({ pathname: "/" }),
  useParams: () => ({ id: "1" }),
  useRouteMatch: () => ({ url: "/" }),
  useNavigate: vi.fn(),
}));

아 드디어 모든 테스트를 통과했습니다. 테스트 할 때마다 하나씩에 대해 api 통신을 하고 그러다보니 걸리는 시간이 좀 길어지는 듯 합니다. 아. 제가 msw에 delay를 준 부분도 영향이 있겠네요..

이건 어쨋거나 이 부분은 좀 개선을 해야 하지 않을까 생각이 듭니다. 아무튼 일단 성공했으니까 오늘은 여기까지...

✓ src/components/templates/BasicQuestion/BasicQuestion.test.tsx (4) 3318ms

 Test Files  1 passed (1)
      Tests  4 passed (4)
   Start at  10:51:53
   Duration  3.88s


 PASS  Waiting for file changes...
       press h to show help, press q to quit

마치면서

테스트 코드를 작성하면서 많은 우여곡절이 있었는데 아무래도 처음이다보니 시행착오가 많았던거 같습니다. 그리고 테스트 코드 작성에 대한 노하우 같은게 없어서 더 많은 시간이 걸렸던거 같습니다.

그래도 처음에 제가 테스트하고자 했던 4가지에 대해 테스트 코드를 완성하고 통과 되는걸 보니 한편으로 뿌듯하면서 왜 테스트 코드를 작성하면 좋을지에 대해서도 알게되는 거 같습니다.

다만 나중에 수정사항이 발생해서 수정을 하게 될 때 테스트 코드도 같이 수정해야 되는 단점은 있을 거 같습니다. 하지만 사이드 이펙트를 잡아 낼 수 있으니 좋은거 같기도 하고. 역시 트레이드 오프가 있네요.

profile
배움을 좋아합니다. 새로운 것을 좋아합니다.

3개의 댓글

comment-user-thumbnail
2023년 8월 25일

잘봤습니다 :)

1개의 답글
comment-user-thumbnail
2023년 11월 5일

사랑합니다

답글 달기