React

react에서 api 테스트 코드 작성하는 방 (feat. react testing library, mock api)

B_Tae 2023. 8. 31. 00:52

들어가기 앞서 mock API는 꼭 테스트를 위해서만 사용하는 건 아닙니다. API 명세서가 작성되어 있다면 서버가 완성되기 이전 프론트 단에서 개발을 진행할 때 유용하게 사용할 수 있습니다. 이점은 인지하고 넘어가도록 하겠습니다.

mock API를 사용하여 리액트 테스트코드 작성하기

이번에는 mock API를 이용하여 테스트하는 방법에 대해 정리해보도록 하겠습니다.

사실 실제 서비스에 연결된 API를 사용하여 테스트 할 수 있습니다. 그렇게 된다면 API 통신까지 테스트를 할 수 있으니 안정성은 더 올라갈 것으로 생각되지만, API 통신 즉 서버가 정상 동작하는건 서버단에서 테스트를 해야할 사항이지 프론트단에서 꼭 테스트할 필요는 없다고 생각됩니다. 그리고 mock API를 사용할 경우 여러 장점이 있습니다.

mock API를 사용하는 이유

  • 실제 API와 격리: mock api를 사용할 경우 서버와 분리할 수 있습니다. 이는 서버의 상태에 의존하지 않고 구성 요소에 동작에만 초점을 맞출 수 있습니다. 또한 서버에 속도, 비용과 관계없이 테스트가 가능하며 실제 api변경으로 인해 테스트에서 예기치 않은 문제가 발생하는 것을 방지하는데 도움이 됩니다. 또한 서버 개발이 완료되지 않은 상태에서 테스트 코드 작성이 가능합니다.
  • 빠른 속도: mock API는 일반적으로 더 빠른 테스트 실행으로 이어집니다. 실제 API 호출에는 네트워크 요청이 포함되므로 잠재적인 네트워크 변동으로 인해 테스트 속도가 느려지고 안정성이 떨어질 수 있습니다.
  • 예측 가능성: mock API를 사용하면 테스트에서 수신하는 데이터를 제어할 수 있습니다. 이를 통해 다양한 시나리오와 에지 케이스를 더 쉽게 시뮬레이션하여 포괄적인 테스트 커버리지를 보장할 수 있습니다. 다시 풀어 얘기하자면 오류를 범하기 쉬운 값(보통 경계값)을 사용하여 모든 케이스에 테스트가 가능해집니다.

저는 예측 가능성을 위해서라도 mock API를 사용하는 것이 바람직하다는 입장입니다. 또한 코드가 복잡할게 없습니다. 물론 mock API에 response를 내려주는 코드는 추가되겠지만 data를 요청하는 API코드는 변경할 필요가 없다는 것입니다. 아래 예시 코드를 보면 이해가 되실겁니다.

mock API 선정


후보로는 axios-mock-adapter, msw, nock총 3개에 라이브러리를 찾았습니다. 위 이미지는 최근 5년간 다운로드 수를 그래프로 표시한 것입니다.

 

대부분에 라이브러리 선택에서 자신에 프로젝트나 성향에 맞게 선택하는 것이 중요합니다. 저는 연습 목적으로 사용하는 것이기 때문에 다운로드 상승폭이 가장 큰 그리고 RTL공식 문서에서 사용하고 있는 msw를 선택했습니다. 또한 Rest API 뿐 아니라 GraphQL도 지원하기 때문에 더 끌렸던 것 같습니다.

msw, nock 라이브러리에 차이점은 링크에 정리가 잘 되어있습니다. 참고하여 자신에 맞는 라이브러리를 선정해봅시다.

React에서 msw라이브러리 환경설정


테스트 코드에 적용하기 전 react 프로젝트에서는 어떻게 적용하는지 확인해보겠습니다. 자세한 내용은 공식문서를 참고하시면 도움이 될 것 같습니다.

라이브러리 설치

yarn add msw --dev

service worker 등록 코드 초기화

npx msw init public/ --save

위 명령어를 실행하면 public폴더 내에 mockServiceWorker.js라는 파일이 만들어집니다.

 

환경설정을 간단하게 끝내봤습니다. 이제 목업 데이터를 만들어보도록 하겠습니다.

파일 생성

mkdir src/mocks
touch src/mocks/handlers.ts
touch src/mocks/browsers.ts
touch src/mocks/todoList.json

CLI에 익숙하지 않다면 직접 폴더 및 파일을 만들어주시면 됩니다. 결론적으로는 src/mocks/handlers.js구조를 갖추면 됩니다.

 

TodoList에서 사용할 수 있는 코드를 만들도록 하겠습니다.

// src/mocks/todoList.json
[
  {
    "id": 1,
    "text": "hello world",
    "done": true
  },
  {
    "id": 2,
    "text": "bye world",
    "done": false
  }
]

 

우선 데이터를 불러올 수 있도록 기본값을 넣어둔 json파일을 작성해줍니다. 가상에 DB에 역할을 하는셈이죠.

 

// src/mocks/handlers.ts

import { rest } from "msw";
import todoList from "./todoList.json";

export const handlers = [
  rest.get("/todoList", async (req, res, ctx) => {
    return res(ctx.delay(1000), ctx.status(200), ctx.json(todoList));
  }),
  rest.post("/todoList", async (req, res, ctx) => {
    const { text } = await req.json();
    const newData = {
      id: new Date().getTime(),
      text: text,
      done: false,
    };
    todoList.push(newData);

    return res(ctx.delay(1000), ctx.status(201), ctx.json(newData));
  }),
];

2개에 mock API를 만들었습니다. get은 json을 불러오는 api이며, post는 todoList에 값을 추가할 수 있습니다. return에는 실제 서버와 비슷하게 동작하기 위해 1초에 delay를 추가하여 전달합니다.

 

코드는 완성했으니, 프로젝트 실행 시 적용되도록 worker을 실행시키도록 하겠습니다.

우선 앞서 작성한 handlers을 worker에 등록해줍니다.

// src/mock/browsers.ts

import { setupWorker } from "msw";
import { handlers } from "./handlers";

export const worker = setupWorker(...handlers);

 

그 다음은 App.tsx에서 worker을 실행시켜줍니다.

//App.tsx
function App(){
...
    if (process.env.ENV_TYPE === "DEV") {
    worker.start();
  }
...
}

배포할 때 마다 코드를 수정하기 싫은게 당연하죠? .env파일을 이용하여 개발환경에서만 worker가 실행되도록 만들어봤습니다. 연습용이라면 굳이 .env설정까지 하기 귀찮으니 조건문을 지우고 실행해도 상관없습니다.

 

프로젝트를 실행하고 console창에 위와 같이 문구가 뜬다면 실행준비는 다 끝났습니다.

 

이제 mock API를 이용하여 todoList를 완성해보겠습니다. 코드는 기존에 만들어뒀던 todoList를 수정하여 작성하겠습니다. 또한 전체 코드가 아닌 일부 코드만 작성하도록 할께요 !  혹시 TodoList 전체 코드가 궁금하다면 이전 포스트를 참고해주시면 감사하겠습니다.

todoList에 mock API 적용하기


todoList에서 get API 받기

// Todo.tsx
...
    useEffect(() => {
    getTodoList();
  }, []);

  const getTodoList = async () => {
    const { data } = await axios({
      method: "get",
      url: "/todoList",
    });
    setTodos(data);
    console.log(data);
  };
...

요청하는 방법은 너무 익숙한 방법입니다. 앞서 mock API를 설명하면서 코드가 복잡해지지 않는다고 말했습니다. mock API에동작방식을 보게되면 네트워크 환경에서 API 요청을 가로채 mock API로 연결하는 것입니다. 따라서 mock API에 endPoint 세팅을 실제 서버주소와 동일하게 설정했다면 하나의 코드로 서버와 mock API 방식 모두 사용가능합니다.

 

위 코드를 실행하여 아래와 같은 console이 찍힌다면 성공입니다.

 

todoList 항목 추가

데이터를 받았으니 이번에는 항목을 추가하는 것 까지 작성해보도록 하겠습니다.

// Todo.tsx
...
    const addItem = async (text: string) => {
    const { data } = await axios.post("/todoList", { text });
    setTodos((item) => [...item, data]);
    console.log(data);
  };
...

이전 addItem함수를 위와 같이 변경합니다. 동작 방식은 동일합니다. state에 저장하던 방식에서 mock api를 이용하는 부분만 추가되었습니다.

테스트에서 mock API(feat. msw)


테스트 코드를 작성하기 전 문제가 있습니다. 이유는 @testing-library/react에서 msw를 사용하기 위해서는 추가 세팅이 필요합니다. 어떤 문제가 있는지 한번 yarn test를 실행해봅시다.

 

<TodoList />에서 axios를 import하는데 이를 읽어 드릴 수 없는 것 같습니다. 읽을 수 있도록 package.json을 수정해봅시다. 그리고 재실행해주세요.

//package.json
{
...
"jest": {
    "transformIgnorePatterns": [
      "node_modules/(?!axios)/"
    ]
  }
}

 

이제 axios는 해결했지만 msw에서 에러가 발생하고 있습니다. 발생하는 이유는 mock API를 실행하기 위해 App.ts에서 workder.start()를 실행했습니다. 하지만 <Todo />를 렌더하는 과정에서는 mock API가 실행되지 않아서 문제가 발생합니다. 코드를 수정하겠습니다.

// Todo.test.tsx
describe("<Todo />", () => {
  const server = setupServer(
    // Describe network behavior with request handlers.
    // Tip: move the handlers into their own module and
    // import it across your browser and Node.js setups!
    rest.get("/todoList", async (req, res, ctx) => {
      return res(
        ctx.delay(1000),
        ctx.status(200),
        ctx.json([
          {
            id: 1,
            text: "hello world",
            done: true,
          },
          {
            id: 2,
            text: "bye world",
            done: false,
          },
        ])
      );
    }),

    rest.post("/todoList", async (req, res, ctx) => {
      const { text } = await req.json();
      const newData = {
        id: new Date().getTime(),
        text: text,
        done: false,
      };

      return res(ctx.delay(1000), ctx.status(201), ctx.json(newData));
    })
  );

  // Enable request interception.
  beforeAll(() => server.listen());

  // Reset handlers so that each test could alter them
  // without affecting other, unrelated tests.
  afterEach(() => server.resetHandlers());

  // Don't forget to clean up afterwards.
  afterAll(() => server.close());

... //test 코드
}

테스트하는 코드 상단에 코드를 추가해주면 됩니다. 차이점은 setupServer입니다. 이는 node 환경에서 사용하기 위함입니다. 저는 하나의 컴포넌트에서만 사용했기 때문에 따로 파일을 분리하지 않고 상단에 작성했지만 프로젝트에서 사용하려면 파일을 따로 관리하는 방법도 좋을 것 같습니다.

 

이제 마지막 단계입니다. 기존 테스트 코드는 비동기 동작을 고려하지 않았기 때문에 비동기를 고려한 코드로 수정할 필요가 있습니다. 수정하도록 하겠습니다.

// Todo.test.tsx
import { fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import { worker } from "../mocks/browsers";
import { setupServer } from "msw/node";
import Todo from "../View/Todo";
import { rest } from "msw";

describe("<Todo />", () => {
  const server = setupServer(
    // Describe network behavior with request handlers.
    // Tip: move the handlers into their own module and
    // import it across your browser and Node.js setups!
    rest.get("/todoList", async (req, res, ctx) => {
      return res(
        ctx.delay(1000),
        ctx.status(200),
        ctx.json([
          {
            id: 1,
            text: "hello world",
            done: true,
          },
          {
            id: 2,
            text: "bye world",
            done: false,
          },
        ])
      );
    }),

    rest.post("/todoList", async (req, res, ctx) => {
      const { text } = await req.json();
      const newData = {
        id: new Date().getTime(),
        text: text,
        done: true,
      };
      // todoList.push(newData);

      return res(ctx.delay(1000), ctx.status(201), ctx.json(newData));
    })
  );

  // Enable request interception.
  beforeAll(() => server.listen());

  // Reset handlers so that each test could alter them
  // without affecting other, unrelated tests.
  afterEach(() => server.resetHandlers());

  // Don't forget to clean up afterwards.
  afterAll(() => server.close());
  it("renders TodoForm and TodoList", () => {
    render(<Todo />);
    screen.getByText("저장"); // TodoForm 존재유무 확인
    screen.getByTestId("TodoList"); // TodoList 존재유무 확인
  });

  it("renders two defaults todos", async () => {
    render(<Todo />);
    await screen.findByText("hello world", {}, { timeout: 2000 });
    await screen.findByText("bye world", {}, { timeout: 2000 });
  });

  it("creates new todo", async () => {
    render(<Todo />);
    // 이벤트를 발생시켜서 새 항목을 추가하면
    const input = await screen.findByPlaceholderText("할 일을 입력하세요");
    fireEvent.change(input, {
      target: {
        value: "새 항목 추가하기",
      },
    });
    fireEvent.click(screen.getByText("저장"));
    // 해당 항목이 보여져야합니다.
    await screen.findByText("새 항목 추가하기", {}, { timeout: 2000 });
  });

  it("toggles todo", async () => {
    render(<Todo />);
    // hello world 항목에 클릭 이벤트를 발생시키고 text-decoration 속성이 설정되는지 확인
    const todoText = await screen.findByText(
      "hello world",
      {},
      { timeout: 2000 }
    );
    expect(todoText).toHaveStyle("text-decoration: line-through;");
    fireEvent.click(todoText);
    expect(todoText).not.toHaveStyle("text-decoration: line-through;");
    fireEvent.click(todoText);
    expect(todoText).toHaveStyle("text-decoration: line-through;");
  });

  it("removes todo", async () => {
    render(<Todo />);
    const todoText = await screen.findByText(
      "hello world",
      {},
      { timeout: 2000 }
    );
    const removeButton = todoText.nextSibling;
    fireEvent.click(removeButton);
    expect(todoText).not.toBeInTheDocument(); // 페이지에서 사라졌음을 의미함
  });
});

혹시 비동기 코드를 테스트하는 방법이 어색하다면 이전 포스트를 한번 읽어보시는 것을 추천드립니다.

 

수정된 코드는  이전 포스트에서 비동기 작업을 고려한 테스트 코드에서 작성한 코드와 유사하니 생략하도록 하겠습니다. 핵심은 비동기 처리되는 시간동안 테스트 코드 또한 일정 시간을 기다려 테스트하는 방식입니다.

 

마무리


테스트 코드는 안정적인 서비스를 만들기 위해 중요한 요소입니다. 특히 작은 스타트업에서는 요구 사항이 바뀌고 빠르게 기능이 추가되기 때문에 이전 코드에 안정성을 테스트하는데 많은 시간을 소요하게됩니다. ( 제가 그랬어요......) 그래서 요즘 TDD방식에 개발 방법이 유행하는 것 같습니다. 

그러나, 테스트 코드가 필수는 아닙니다. 테스트 코드 작성 비용이 생각보다 많이 든다고 얘기를 많이 들었습니다. 빠른 개발 속도를 위해서 포기하는 경우도 있을 것 같아요. 하지만 많은 개발자들에 테스트 코드에 필요성을 많이 느낄 것 같습니다.