ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Integration 테스트 코드가 중요한 이유 (React Testing Library, MSW로 작성해보기)
    Frontend 2021. 7. 25. 21:23

     

     

    처음에 회사에서 서비스 개발 프로젝트를 맡았을 때 내가 작성했던 테스트 코드는 util성 함수들에 대한 unit 테스트 코드가 전부였다.

     

    거기에 조금 더 덧붙여서 Redux Saga의 Generator 함수에 대한 간단한 unit 테스트들을 작성하였는데 방법은 매우 간단했다.

    Saga Generator 함수들을 실행했을 때 어떤 이펙트들이 순차적으로 실행되는지 각 단계 별로 mocking하여 테스트하는 방식이다.

     

    그 때 내가 작성했던 테스트 코드 한 대목을 가져와봤다.

     

        describe('1. success scenario', () => {
          let gen: any;
    
          beforeAll(() => {
            gen = fetchAllSaga();
          });
    
          it('yield user and example API calls', () => {
            expect(gen.next().value).toEqual(
              all([
                call(userAPI.get),
                call(exampleAPI.get),
              ]),
            );
          });
    
          it('yield example2 API call', () => {
            const APIResult = [
              { user: {}},
              { data: exampleData },
            ];
    
            expect(gen.next(APIResult).value).toEqual(
              call(
                example2.post,
                { data: [blabla] },
              ),
            );
          });
    
          it('successfully update state', () => {
            const APIResult = { data: [], meta: '' };
    
            expect(gen.next(APIResult).value).toEqual(
              put(exampleAction.fetchAllSuccess({
                user: {} as User,
                exampleData: mockExample,
                exampleData2: mockExample2,
              })),
            );
          });
    
          it('ends generator', () => {
            expect(gen.next().done).toBeTruthy();
          });
        });

     

    특정 Saga Generator 함수의 next()의 결과물이 내가 원하는 순서대로 이루어지는지를 테스트하고 있다.

    이 작업은 redux-saga-test-plan 이란 라이브러리를 사용하면 더욱 간단해진다.

     

    그런데 어느 순간 다음과 같은 의문점이 들기 시작했다.

     

    이렇게 작성한 테스트 코드들이 정말 내가 만든 서비스의 안정성을 100% 보장해주는가?

     

     

     

    테스트 코드의 목적

    위 의문들에 대해서 대답하려면 우리가 테스트 코드를 왜 작성하는지 명확하게 할 필요가 있다. 우리가 테스트 코드를 쓰는 목적은 단순하다. 내가 만든 서비스를 매 번 일일이 실행한 후에 모든 기능을 손수 눌러서 확인하지 않고도 내가 의도한 대로 온전히 작동하는지 빠르게 점검하기 위함이다.

     

    만약 내가 어떤 기능을 최초로 개발했다고 해보자. 대부분의 경우 QA 과정을 거치게 되고, 그 과정에서 모든 경우의 수를 직접 서비스를 사용해보면서 테스트할 것이다. 당연히 많은 시간과 노력이 소요된다. 이 과정에서 만약 오류를 발견했다면? 그렇다면 개발자는 그 오류의 원인을 찾아서 다시 개발물을 수정하고 배포한 후에 다시 한 번 QA 프로세스를 거쳐야 한다.

     

    만약에 QA를 모두 통과했다고 가정해보자. 그런데 새로운 기능을 개발할 때 기존에 개발한 부분과 엮이는 상황이 생길 수 있다. 혹은 기존 코드를 조금 더 개선하기 위해 리팩토링을 하면서 로직을 변경했다고 해보자. 이 때 테스트 코드가 없다면 우리는 다시 한 번 기존 기능에 대한 QA 프로세스를 거쳐야지만 해당 기능의 안정성을 검증할 수 있다.

     

    그렇다면 만약 QA 과정이 이루어지기 전에 서비스의 안정성을 보장할 수 있는 테스트 코드를 미리 작성해 두었고, 그 테스트 코드가 100% 통과되었다면? 당연히 QA 과정에서 오류가 발생할 확률이 현저히 줄어들 것이고, 기존 코드를 자유롭게 리팩토링하거나 새로운 기능을 개발하더라도 기존 기능에 문제가 없음을 손쉽게 증명할 수 있을 것이다. 결국 내가 작성한 테스트 코드들이 제대로 기능을 하고 있는지를 따져보려면 이러한 역할을 잘 수행하고 있는지를 되짚어보면 된다.

     

     

     

    통합(Integration) 테스트 코드가 필요한 이유

    다시 아까 위에서 언급한 Saga Generator 함수에 대한 테스트 코드나 util 함수에 대한 unit 테스트 코드에 대해 얘기해보자. 위에서 예시로 든 Saga Generator 테스트 코드는 크게 2가지 문제점을 가지고 있다.

     

    첫 째, 테스트 코드가 검증해주는 영역이 매우 지엽적이다

    저 테스트 코드로 검증되는 부분은 다음과 같다. call과 같은 Saga 함수를 통해 API 호출이 발생하는 지 여부, 그리고 그 API의 response로 mock data가 반환된다고 가정했을 때 Redux 스토어에 어떤 상태값들이 저장되는 지 여부. 그 외에 API 호출이 어느 시점에 발생이 되고, 상태가 변경되면 화면에서 어떠한 일이 발생하는지에 대해서는 전혀 검증해주지 않는다. 즉, 저 테스트 코드 만으로는 우리의 어플리케이션이 잘 동작한다고 단언할 수 없다.

     

    util 함수에 대한 unit 테스트도 마찬가지이다.

     

    아주 간단한 예시를 들어보자. 

    function hasWarningItem (items) {
      return items.find(item => item.type === 'warning');
    }
    
    // ...
    
    const data = await fetch();
    
    if (hasWarningItem(data)) {
      return <WarningSign />;
    }
    
    return <NormalSign />;

     

    어떤 util 함수를 통해 배열 data의 아이템 중 warning 요소가 있으면 WarningSign 컴포넌트를 렌더링하고 그게 아니면 NormalSign을 렌더링하는 상황이라고 해보자.

     

    우리는 hasWarningItem 함수의 unit 테스트를 통해 인자로 들어온 배열 중에서 warning 타입이 있는 요소를 리턴하는지 안하는지 여부는 확실하게 검사할 수 있다. 그러나 아래와 같이 hasWarningItem(data)를 검사할 때 실수로 앞에 Logical Not을 붙였다면?

    function hasWarningItem (items) {
      return items.find(item => item.type === 'warning');
    }
    
    // ...
    
    const data = await fetch();
    
    // hasWarningItem을 사용하는 곳에서 실수한다면?
    if (!hasWarningItem(data)) {
      return <WarningSign />;
    }
    
    return <NormalSign />;

     

    우리의 어플리케이션은 의도한 대로 동작하지 않을 것이다. 

     

    결국, 각각의 unit들을 뭉쳐서 하나의 어플리케이션을 구성할 때 각 연결 부위를 검사할 수 없다면 전체 어플리케이션의 안정성은 보장할 수 없게 된다.

     

    둘 째, 내부 로직을 변경하면 설사 서비스에 아무 문제가 없더라도 테스트 코드가 깨져버린다.

    Saga 테스트 코드를 다시 살펴보면 정말 단순히 API를 호출 함수의 이름만 변경해도 테스트 코드가 깨진다는 것을 알 수 있다.

     

    import { userAPI, exampleAPI } from '../api'; // 여기서 벌써 테스트가 깨질 것이다.
    
    //...
    
        describe('1. success scenario', () => {
          let gen: any;
    
          beforeAll(() => {
            gen = fetchAllSaga();
          });
    
          it('yield user and example API calls', () => {
            expect(gen.next().value).toEqual(
              all([
                call(userAPI.get),
                call(exampleAPI.get),
              ]),
            );
          });

     

    만약 Saga의 구조 자체를 변경하기라도 한다면 실제 서비스는 아무런 문제가 없는데도 테스트 코드가 깨지며, 일일이 유지 보수를 해주어야만 한다. 이는 사실 큰 문제이다. 테스트 코드를 유지하는데 너무 큰 비용이 들어간다는 문제도 있고, 테스트 코드의 결과에 신뢰를 할 수 없게 된다는 문제도 있다.

     

    따라서 이러한 문제들을 해결하기 위해 우리는 다음과 같은 결론을 내릴 수 있다.

     

    - Integration 테스트 코드를 통해 여러 모듈들이 통합된 하나의 어플리케이션이 제대로 동작하는지를 검사해야 한다.
    - 내부 코드를 어떻게 리팩토링을 하든 유저에게 보여지는 산출물이 같다면 테스트 코드는 깨지지 않아야 한다.

     

     

     

    React Testing Library와 Mock Service Worker

    그럼 Integration 테스트 코드는 어떻게 쓸 수 있을까? 나는 Jest와 React Testing Library, MSW를 사용해서 테스트 코드를 작성하고 있는데 내가 실무에서 주로 사용하는 방법으로 간단한 어플리케이션에 대한 테스트 코드를 작성해보려고 한다.

     

     

    여기 아주 간단한 어플리케이션이 있다.

     

    1 2
    3 4

     

    첫 화면에서 Let's find!라는 버튼을 누르면 모달이 뜨고, 잠깐의 로딩을 거쳐 할 일에 대한 정보를 유저에게 보여준다.

    Close 버튼을 누르면 모달 창이 닫힌다.

     

    자 이제 이 어플리케이션이 잘 작동하는지 검사한다고 해보자.

     

    무엇을 테스트해야할 지 모르겠다면 회사에서 QA 과정에서 직접 기능 테스트를 진행할 때 무엇을 테스트하는지 생각해보면 쉽다. 내가 이 어플리케이션의 기능 QA를 한다고 생각하고 필요한 테스트 케이스를 작성해보자.

     

     

    test("[Let's find you something to do]라는 메시지와 [Let's find!]라는 버튼이 보인다", () => {
      // write here
    });
    
    test("[Let's find!] 버튼을 클릭하면 모달창이 뜬다.", () => {
      // write here
    });
    
    test("모달창에 Loading 메시지가 보이고, 데이터 로딩이 끝나면 할 일, 최소 인원, 필요한 돈 정보가 보인다", () => {
      // write here
    });
    
    test("[Close] 버튼을 클릭하면 모달창이 닫힌다", () => {
      // write here
    });
    
    test("데이터를 불러오다가 에러가 발생하면 Error라는 글자가 보인다", () => {
      // write here
    });

     

    이 정도의 테스트가 통과한다면 어플리케이션 기능이 정상적으로 동작한다고 간주할 수 있을 것이다.

    이제 하나씩 차례대로 코드를 작성해보자.

     

    import "@testing-library/jest-dom";
    // ^ jest에서 dom testing을 쉽게 할 수 있도록 제공해주는 여러 함수들을 import한다.
    // toBeInTheDocument와 같은 메소드들을 사용할 수 있다.
    
    import { render, screen } from "@testing-library/react";
    // ^ 컴포넌트를 render하기 위한 함수
    import App from "../App";
    import React from "react";
    
    
    test("[Let's find you something to do]라는 메시지와 [Let's find!]라는 버튼이 보인다", () => {
      const { getByText } = render(<App />);
    
      expect(getByText("Let's find you something to do")).toBeInTheDocument();
      // ^ render 메소드의 return 객체를 destructuring하여 query문을 사용할 수 있다.
      
      expect(screen.getByRole("button", { name: "Let's find!" })).toBeInTheDocument();
      // 혹은 screen 객체를 import하여 사용하면 document.body 내의 모든 요소를 query한다.
    });

     

    App 컴포넌트를 렌더한 후 내가 의도한대로 텍스트가 출력하는지 쿼리문을 통해 찾는다.

    요소가 존재하지 않는다면 getBy~ 쿼리문은 바로 에러를 throw한다.

     

     

    import { fireEvent, render, screen } from "@testing-library/react";
    
    beforeEach(() => {
      render(<App />);
      // ^ 각 테스트 블록마다 새로 App을 render해야 하므로 beforeEach에 작성하여 수고로움을 덜어낸다
    });
    
    test("[Let's find!] 버튼을 클릭하면 모달창이 뜬다.", () => {
      const button = screen.getByRole("button", { name: "Let's find!" });
      
      fireEvent.click(button);
      
      expect(getByTestId("modalContainer")).toBeInTheDocument();
      // 미리 모달창 element에 data-testid를 심어둔 후 테스트에서 사용한다.
      // ex) <div data-testid="modalContainer">modal</div>
    });

     

    클릭 이벤트는 fireEvent를 사용하여 구현한다.

     

     

    test("모달창에 Loading 메시지가 보이고, 데이터 로딩이 끝나면 할 일, 최소 인원, 필요한 돈 정보가 보인다", () => {
      // write here
    });

     

    자 이제 새로운 문제에 봉착했다. 모달을 띄울 때마다 매 번 API를 새로 호출하여 랜덤한 할 일 정보를 보여준다.

    그런데 테스트할 때마다 실제 API 호출을 하게 되면 API 호출에 대한 결과를 내가 컨트롤할 수 없게 된다.

     

    이 때 필요한 것이 바로 MSW이다.

    MSW는 Mock Service Worker의 약자로 Service Worker API를 사용하여

    실제 API 요청을 가로채 mocking을 해주는 라이브러리이다. 

     

    import { rest } from "msw";
    import { setupServer } from "msw/node";
    
    const mockData = {
      activity: "Go for a walk",
      type: "relaxation",
      participants: 1,
      price: 0,
      link: "",
      key: "4286250",
      accessibility: 0.1
    };
    
    const server = setupServer(
      rest.get("https://www.boredapi.com/api/activity", (req, res, ctx) => (
        res(ctx.json(mockData))
      )),
    );
    
    beforeAll(() => worker.start());
    afterEach(() => worker.resetHandlers());
    afterAll(() => worker.stop());

     

    세팅 방법도 매우 간단하다.

    이제 테스트 코드를 실행하면 www.boredapi.com/api/activity로 API request가 발생할 때마다 MSW에 의해 인터셉트되고 리턴되는 mockData를 통해 항상 일관된 결과를 테스트할 수 있다.

     

    import { fireEvent, render, waitFor, screen } from "@testing-library/react";
    
    beforeEach(() => {
      render(<App />);
    });
    
    // ...
    
    test("모달창에 Loading...이 보이고, 다 로딩되면 할 일, 최소 인원, 필요한 돈 텍스트가 보인다.", async () => {
      // 버튼을 누르고 모달창이 뜨면
      fireEvent.click(screen.getByRole("button", { name: "Let's find!" }));
      
      // 로딩 화면이 보이고
      expect(screen.getByText("Loading...")).toBeInTheDocument();
    
      // 다 로딩되면
      await waitFor(() => screen.getByText(/할 일:/));
      // ^ 또는 waitForElementToBeRemoved(() => screen.getByText("Loading..."));
     
      // 할 일, 최소 인원, 필요한 돈 정보가 보인다
      expect(screen.getByText("할 일: Go for a walk").textContent).toBeInTheDocument();
      expect(screen.getByText("최소 인원: 1").textContent).toBeInTheDocument();
      expect(screen.getByText("필요한 돈: 0$").textContent).toBeInTheDocument();
    });

     

    이렇게 간단하게 하나의 Flow를 쭉 테스트할 수 있다! 🎉

     

    그렇다면 어떤 요소가 없어지는지 여부는 어떻게 알 수 있을까?

     

    // ...
    
    test("[Close] 버튼을 클릭하면 모달창이 닫힌다", () => {
      // 버튼을 누르면 모달창이 보이고
      fireEvent.click(screen.getByRole("button", { name: "Let's find!" }));
      expect(screen.getByTestId("modalContainer")).toBeInTheDocument();
      
      // 모달창의 닫기 버튼을 누르면
      fireEvent.click(screen.getByRole("button", { name: "Close" }));
      
      // 모달창이 화면에서 사라진다
      expect(screen.queryByTestId("modalContainer")).not.toBeInTheDocument();
    });

     

    여기서 중요한 점은 요소가 사라졌는지 검사할 때는 queryBy-문을 사용한다는 것이다.

    왜냐하면 getBy- query문은 요소가 없으면 무조건 에러가 발생시키기 때문이다.

    반면 queryBy-는 요소가 없으면 null을 리턴한다.

     

     

    자 이제 마지막으로 에러 상황에 대한 테스트 코드를 작성해보자.

     

    test("데이터를 불러오다가 에러가 발생하면 Error라는 글자가 보인다", () => {
      server.use(
        rest.get("https://www.boredapi.com/api/activity", (req, res, ctx) => (
          res(ctx.status(500))
        ))
      );
      
      render(<App />);
      
      expect(screen.getByText("Loading...")).toBeInTheDocument();
    
      await waitForElementToBeRemoved(() => screen.getByText("Loading..."));
      
      expect(screen.getByText("Error")).toBeInTheDocument();
    });

     

    특정 테스트 블록에서 API의 mock response를 바꿔주고 싶을 때는 server.use 함수를 사용한다.

    response의 status를 400~500대로 설정하면 에러가 발생한 것처럼 mocking할 수 있다.

    이제 화면에 원하는대로 에러 핸들링이 되고 있는지만 테스트해주면 된다.

     

     


     

     

    자 이제 아까 작성한 Saga Unit 테스트와 이 Integration 테스트를 비교해보자.

     

    Integration 테스트는 어떤 특정한 작은 로직에 대해서만 테스트하는 것이 아니라 앱 전반에 걸쳐 정상적으로 동작하는지 테스트한다.

     

    Integration 테스트에서의 중요한 포인트는 유저에게 보여지는 최종 산출물에 대해서 테스트해야한다는 것이다.

    즉, 내가 내부 로직을 어떤 방식으로 리팩토링하든 유저에게 전달되는 결과물이 같다면 테스트 코드가 깨져서는 안된다.

    이 원칙을 최대한 반영하여 작성한다면 생각보다 테스트 코드를 유지 보수하는데 큰 리소스가 낭비되지 않을 것이다.

     

    Integration 테스트 코드를 앱 전반에 작성해두고 나면 기존 코드를 리팩토링하거나, 새로운 기능을 붙이고 나서 배포를 할 때 더 이상 장애가 발생할까봐 두려움에 떨지 않아도 된다.

     

     

    [읽어보면 좋은 글들]

     

    https://kentcdodds.com/blog/static-vs-unit-vs-integration-vs-e2e-tests

     

    Static vs Unit vs Integration vs E2E Testing for Frontend Apps

    What these mean, why they matter, and why they don't

    kentcdodds.com

     

    https://kentcdodds.com/blog/common-mistakes-with-react-testing-library

     

    Common mistakes with React Testing Library

    Some mistakes I frequently see people making with React Testing Library.

    kentcdodds.com

     

    반응형

    COMMENT