MSW로 모킹 테스트 with testing-library

김동하·2023년 3월 29일
1

jest

목록 보기
3/6
post-thumbnail

들어가며

이번에 과제하면서 MSW 를 처음 써봤다. 신기방기하다... MSW를 실무에서 써본 적이 없어 얼마나 도움이 될지는 모르겠지만 좋은 기술인 것은 확실하니 배워보자. MSW과 테스트 코드의 세계로...

예제

간단한 회원가입 폼이 있다고 가정하자.

function LoginSubmission() {
  const [formData, setFormData] = React.useState(null)
  const {status, responseData, errorMessage} = useFormSubmission({
    endpoint: 'https://auth-provider.example.com/api/login',
    data: formData,
  })

  return (
    <>
      {status === 'resolved' ? (
        <div>
          Welcome <strong>{responseData.username}</strong>
        </div>
      ) : (
        <Login onSubmit={data => setFormData(data)} />
      )}
      <div style={{height: 200}}>
        {status === 'pending' ? <Spinner /> : null}
        {status === 'rejected' ? (
          <div role="alert" style={{color: 'red'}}>
            {errorMessage}
          </div>
        ) : null}
      </div>
    </>
  )
}

로그인 후 status에 따라 다른 UI가 렌더링 되고 api 호출이 정상적으로 되었다면 responseData.username를 화면에 출력하면 된다.

이제 위 컴포넌트에 대한 테스크 코드를 작성해보자.

test(`로그인 시 유저 이름이 화면에 나와야 함`, () => {
  render(<Login />);
});

먼저 테스트할 컴포넌트를 가져온다.

test(`로그인 시 유저 이름이 화면에 나와야 함`, () => {
  render(<Login />);
   const username = "user123"
   const password = "pasword123"
});

이제 username, password에 해당하는 value 를 정해줘야 하는데, 위와 같이 하드코딩으로 넣어버리면 테스트의 의미가 없기 때문에 다른 방식을 사용한다.

test(`로그인 시 유저 이름이 화면에 나와야 함`, () => {
  render(<Login />);
   const { username, password } = buildLoginForm();
});
const buildLoginForm = build({
  fields: {
    username: fake((f) => f.internet.userName()),
    password: fake((f) => f.internet.password()),
  },
});

jackfranklin/test-data-bot라는 라이브러리에서 제공하는 build, fake 함수를 사용하여 mock username, password를 생성한다. buildLoginForm은 매 테스트마다 다른 결과를 return한다.

그리고 이제 event를 테스트할 차례다. fireEvent를 사용하여 유저 이벤트를 발생시킬 수 있다.

test(`로그인 시 유저 이름이 화면에 나와야 함`, () => {
  render(<Login />);
   const { username, password } = buildLoginForm();

  fireEvent.change(screen.getByLabelText(/username/i), { target: { value: username } });
  fireEvent.change(screen.getByLabelText(/password/i), { target: { value: password } });
});

유저 이벤트 관련 테스트를 할 때 fireEvent보다는 userEvent를 사용할 것을 권장한다.

엄밀히 얘기해서 사용자가 <input> 엘리먼트에 데이터를 입력할 때는, change 이벤트 뿐만 아니라 focus, keydown, keyup과 같은 다양한 이벤트가 발생합니다. 따라서 React Testing Library에 내장되어 있는 fireEvent를 사용하면, 실제로 발생해야하는 모든 유저 이벤트를 발생되지 않는다는 단점이 있습니다.

test(`로그인 시 유저 이름이 화면에 나와야 함`, () => {
  render(<Login />);
   const { username, password } = buildLoginForm();

   userEvent.type(screen.getByLabelText(/username/i), username);
   userEvent.type(screen.getByLabelText(/password/i), password);
});

이렇게 userEvent 로 이벤트를 발생시키고

test(`로그인 시 유저 이름이 화면에 나와야 함`, async () => {
  render(<Login />);
   const { username, password } = buildLoginForm();

  await userEvent.type(screen.getByLabelText(/username/i), username);
  await userEvent.type(screen.getByLabelText(/password/i), password);

  await userEvent.click(screen.getByRole("button", { name: /submit/i }));
});

submit 버튼까지 눌렀을 때 테스트 코드도 작성한다.

잘 동작한다!

MSW

이제 여기에 MSW 를 추가해보자. 플로우를 생각해보면 아이디/비밀번호 입력 후 api를 호출하고 성공적으로 response가 오면 response에 있는 username을 화면에 렌더링하는 것이다.

위 플로우대로 MSW를 사용하여 테스팅해보자.

(MSW setup은 생략함)

const server = setupServer(
  rest.post(
    "https://auth-provider.example.com/api/login")
);

먼저, msw에서 제공하는 restsetupServer를 사용하여 server를 만든다.

const server = setupServer(
  rest.post(
    "https://auth-provider.example.com/api/login"),
    async (req, res, ctx) => {
      return res(ctx.json({ username: req.body.username }));
    }
);

그리고 res에 username을 담아 return해주면 끝!

beforeAll(() => server.listen());
afterAll(() => server.close());

마지막으로 연동만 해주면 된다.

{status === 'pending' ? <Spinner /> : null}

여기서 살짝 궁금했던 게 api 호출 후 pending일 때 로딩 UI가 나오는데 이건 어떻게 처리하나 했는데

waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i));

waitForElementToBeRemoved라는 메서드를 이미 제공하고 있다.. 역시 안되는 건 없다.. 내가 아직 모를 뿐...

test(`로그인 시 유저 이름이 화면에 나와야 함`, async () => {
  render(<Login />);
  const { username, password } = buildLoginForm();

  await userEvent.type(screen.getByLabelText(/username/i), username);
  await userEvent.type(screen.getByLabelText(/password/i), password);

  await userEvent.click(screen.getByRole("button", { name: /submit/i }));

   // 로딩이 지워질 때까지 기다림
  await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i));

  // username이 화면에 나타나는지 
  expect(screen.getByText(username)).toBeInTheDocument();
  screen.debug();
});

이제 테스트를 해보면

usename이 화면에 잘 나오고 있음을 확인할 수 있따!

만약 JSX return 부분에서 usename을 주석처리하게 되면

return (
    <>
      {status === "resolved" ? (
        <div>{/* Welcome <strong>{responseData.username}</strong> */}</div>
      ) : (
        <Login onSubmit={(data) => setFormData(data)} />
      )}
      <div style={{ height: 200 }}>
        {status === "pending" ? <Spinner /> : null}
        {status === "rejected" ? (
          <div role="alert" style={{ color: "red" }}>
            {errorMessage}
          </div>
        ) : null}
      </div>
    </>
  );

테스트에 실패한다!

예외 케이스

만약 에러가 발생했다고 가정하자. 그런 경우도 MSW 에서 핸들링이 가능하다.

const server = setupServer(
  rest.post(
    "https://auth-provider.example.com/api/login",
    async (req, res, ctx) => {
      // 비밀번호가 없을 경우
      if (!req.body.password) {
        return res(ctx.status(400), ctx.json({ message: "password required" }));
      }
      // 아이디가 없을 경우
      if (!req.body.username) {
        return res(
          ctx.status(400),
          ctx.json({ message: "username required" })
        );
      }
      return res(ctx.json({ username: req.body.username }));
    }
  )
);

이렇게 res를 주는 부분에서 체킹을 해준다. 그리고 테스트 코드를 작성해 보자.

** handler로 따로 로직을 분리할 수 있다.


test(`로그인 시 비밀번호를 입력하지 않아 에러가 발생한 경우`, async () => {
  render(<Login />);
  const { username } = buildLoginForm();

  await userEvent.type(screen.getByLabelText(/username/i), username);
  // 비밀번호 userEvent 생략

  await userEvent.click(screen.getByRole("button", { name: /submit/i }));

  await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i));
  screen.debug();
});

비밀번호를 발생시키는 userEvent를 생략하고 테스트코드를 작성하고 테스트를 해보면

위와 같은 에러메시지가 잘 나오고 있음을 확인할 수 있다. 그럼 이제 테스트코드에 에러메시지가 잘 나오는지를 검증하는 코드를 작성하면 된다.

expect(screen.getByRole("alert")).toHaveTextContent("password required");

에러가 나오는 경우도 통과!

further

살짝 불편한 게 있었는데 error message를 하드코딩으로 넣어놨다는 것이다. 이렇게 되면 error message가 바뀌면 여러 번 일을 해야 한다.

이런 경우를 방지 하기 위해서 toMatchInlineSnapshot() 메서드를 사용한다.

expect(screen.getByRole("alert")).toMatchInlineSnapshot();

이렇게 호출만하고 테스트를 실행하면

expect(screen.getByRole("alert")).toMatchInlineSnapshot(`
    <div
      role="alert"
      style="color: red;"
    >
      password required
    </div>
  `);

해당 JSX를 가져오게 되고 테스트도 통과다.

하지만 JSX를 다 가져오는 것보다 text content만 가져오는 것이 훨씬 보기 좋으니(?) text만 가져오도록 수정한다.

expect(screen.getByRole("alert").textContent).toMatchInlineSnapshot();

이렇게 수정해주면

expect(screen.getByRole("alert").textContent).toMatchInlineSnapshot(
    `"password required"`
  );

UI에 나온 에러메시지를 잘 가져오는 것을 확인할 수 있다.

만약 서버 측에서 에러메시지를 바꿨다고 가정했을 때

  if (!req.body.password) {
        return res(
          ctx.status(400),
          ctx.json({ message: "password is strongly required" })
        );
      }

'u'를 눌러서 테스트 업데이트를 해주면

expect(screen.getByRole("alert").textContent).toMatchInlineSnapshot(
    `"password is strongly required"`
  );

변경된 에러메시지가 잘 나온다.

서버 에러

이번엔 서버에서 알 수 없는 500 에러가 발생한 경우가 있다고 가정하자.

test(`알 수 없는 서버 에러 발생한 경우`, async () => {
  server.use(
    rest.post(
      "https://auth-provider.example.com/api/login",
      async (req, res, ctx) => {
        return res(ctx.status(500), ctx.json({ message: "unknown error" }));
      }
    )
  );

  render(<Login />);
  
  {...코드 동일함 }

  expect(screen.getByRole("alert").textContent).toMatchInlineSnapshot();
});

test 케이스에 500 에러를 발생시킨 케이스를 추가하고 테스트를 실행하면 아래와 같이 제대로 테스트가 되는 것을 확인할 수 있다.

하지만 해당 테스트를 가장 상위에서 실행하게 되면

다른 에러 메시지 테스트 케이스와 충돌하게 된다. 이를 해결하기 위해서는

server.resetHandlers();

를 추가 해주면 된다.

혹은

afterEach(()=> server.resetHandlers())

를 통해 매 테스트 마다 MSW를 reset해주면 된다!

test(`알 수 없는 서버 에러 발생한 경우`, async () => {
  const testErrorMessage = "unknown error";
  server.use(
    rest.post(
      "https://auth-provider.example.com/api/login",
      async (req, res, ctx) => {
        return res(ctx.status(500), ctx.json({ message: testErrorMessage }));
      }
    )
  );

  render(<Login />);

  await userEvent.click(screen.getByRole("button", { name: /submit/i }));
  await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i));

  expect(screen.getByRole("alert")).toHaveTextContent(testErrorMessage);
});

이렇게 에러메시지를 따로 관리하면서 최종적으로 서버 에러에 관한 테스트코드를 완료하였다.

참고

profile
프론트엔드 개발

0개의 댓글