Home Jest, Cypress로 FE를 테스트해보자
Post
Cancel

Jest, Cypress로 FE를 테스트해보자

Preview Image

1. 시작하게 된 이유

사내에서 앱의 크기가 방대해지며 전체적인 구조를 비즈니스로직과 뷰로 나누는 작업을 진행하였다.

이 과정에서 상수, 반복문, interface등의 타입정의를 나눴고 적지 않은 변화가 발생하며 기존에 잘 작동하던 코드가 실제로 원하는 방식으로 작동하는지 확인해보기 위한 검증이 필요하였다.

그래서 그 기회를 통해 FE테스트를 도입해보자! 라는 패기로운 생각과 함께 여러가지 자료를 찾으며 삽질을 하였고..

정화된 결과를 정리하는 글을 써보자 생각하여 글을작성한다.

2. 개념 (Unit, Integration, E2E)

사실 처음 시작할때는 Jest라이브러리의 존재말고는 친숙하지 않아 개념을 잡는것에 많이 해맸었다. 백엔드의 테스트 같은 경우 req, res로 요청과 응답이 일정한 반면,

프론트의 경우 유저 이벤트 -> 이벤트 발생 -> 화면변경으로 흐름을 생각할 수 있지만 백엔드와는 다르게 UI가 변화하는 것과 동시에 통신응답이 있는경우가 존재하기도 하고

UI의 특별한 변화 없이 통신만을 하는 경우도 있기때문에 명확한 기준 설정이 필요했다.

특히 프론트단에서의 Unit테스트와 Integration Test의 범위를 어떤방식으로 나뉘어야 효과적일지에 대해 고민이 많이 들었고 2019-실용적인프론트엔드 테스트 전략를 통해 각 테스트의 경계부분을 설정하는 것에 많은 도움이 되었다.

현재 재직하고 있는 회사의 Tech Stack은 react, redux-toolkit, redux-saga, styled-component로 프론트가 구성되어있고

각 API, constant, util들은 분리가 되어 있으며 페이지 마다 그 분기를 가져가고 있다.(이렇게 분리하느라 고생을 많이했다 ㅎ…)

2.1 Redux-toolkit Unit Test

가장 간단한 단위로서 현재 회사의 TechStack 중 redux-toolkit에 존재하는 로직 및 복잡한 util 함수에 한해서 매우 복잡도가 높은 경우만 해당 테스트를 진행하기로 했다.

이렇게 정한 이유는 애초부터 test coverage를 높이는 것이 목적이 아닌 경력이 짧은 개발자들로 구성된 팀에서 최소한의 방어장치를 위해 접목한 것이기 때문이다.

쉽게 말하면 아래의 목적을 위해서만 Unit Test를 실행하려 했다.

  1. 실수로 불완전한 복잡한 로직을 프로덕트에 배포하는 불상사를 방지하는 목적
  2. 다른 개발도 진행해야하기때문에 테스트코드 작성에 최소한의 시간 투자에 최대한의 효율을 끌어내는 목적
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
describe("근무유형등록 > 고정형 > Select option 조건에 따라 state변경", () => {
  afterEach(() => {
    reducer(initialState, initialSetting());
  });

  describe("handleFixAttendStartTime", () => {
    test("시간타입 변경 시 퇴근시간은 시작시간 + 9로 변경됨", () => {
      expect(
        reducer(
          initialState,
          handleFixAttendStartTime({ timeType: "hour", value: "10" })
        )
      ).toEqual({ ...initialState, attendTime: "1000", leaveTime: "1900" });
    });

    test("분 타입 변경 시 출근시간과 동일한 분을 가지게 됨(00분 또는 30분)", () => {
      expect(
        reducer(
          { ...initialState },
          handleFixAttendStartTime({ timeType: "minute", value: "30" })
        )
      ).toEqual({ ...initialState, attendTime: "0930", leaveTime: "1830" });
    });
  });
});

위 코드와 같이 해당하는 reducer, action creator를 가져와서 정해놓은 로직에 따라 기대한 state를 반환하는지 테스트 하였다.

afterEach를 사용하여 각 테스트가 진행되기 전 state값을 초기화 하여 상호 테스트 간에 영향이 없도록 만들었다.

util과 같은 간단한 함수도 유사한 방식으로 진행하였고 실제 근무중인 인사, 법령과 관련된 UI,UX에서는 조건에 따라 분기 처리되는 로직이 많아

쓸데없는 자신감을 가지기에 유용한 테스트였다고 생각한다.

2.2 Redux-saga Unit Test

사실 테스트에 대해 거의 모르는 상황에서 테스트를 무작정해봐야겠다고 처음 시도했던 것이 redux-saga쪽이었다.

운이좋게도 가장 직관적인 테스트방법이었던 것 같아 참 다행이라고 생각된다. 아래에서 한번 더 언급하겠지만, 해당테스트에서는 API가 제대로 호출되는지 call 메서드를 통해 확인이 가능하므로 액션이 순서에 맞춰 호출이 되는지 + Saga가 API함수를 적절하게 호출하는지에 대한 개념으로 테스트를 커버하였다.

1
2
3
4
5
6
7
8
9
it("로딩시작 ,모든 구매 이용권 데이터를 받아오는 API 호출, 데이터 voucher slice에 저장,로딩 끝", () => {
  expect(iterator.next().value).toEqual(put(setIsLoading(true)));
  expect(iterator.next().value).toEqual(call(getSubscriptonData));
  expect(iterator.next(mockedResponseData).value).toEqual(
    put(saveSubscriptionData(mockedResponseData.data))
  );
  expect(iterator.next().value).toEqual(put(setIsLoading(false)));
  expect(iterator.next().done).toBeTruthy();
});

generator의 특징이 활용되어 next의 value를 알 수 있게 되어 해당방식을 통해 액션의 값 변화를 살펴볼 수 있게 된다. 다만 처음에 next안에 들어가는 값의 용도를 몰라 헤매었던 기억이 있는데 다음 iterator 즉 yield가 선언되어있는 곳에서 받을 값을 미리 대신한다고 보면 된다.

3 Integration Test

Integration Test는 초기에는 testing-library와 jest의 조합만 활용하여 DOM snapshot의 클래스명을 통해 이를 확인해볼까 생각을했다.(많은 기존의 영상들이 이러한 방식을 보여줬다.)

하지만 styled-component로 만들어지는 스타일 컴포넌트가 대부분이므로 Hashing된 클래스명으로 이를 판단하기에는 무리가 있었기 때문에 다른 방식이 필요했고

무엇보다 실제화면이 아닌 DOM만으로 이것이 정확하게 작동하고 있는지 판단하기에는 무리가 있다고 판단하였다.

그래서 여러가지 방식을 찾아보고 강의도 들어본 뒤, Cypress를 통해 실제 UI의 변경사항을 보면서 테스팅을 가능한 방식으로 진행하였다.

API 호출의 경우 cypress에서 기본적으로 fixture를 활용하여 mocking-response를 반환해주는 메서드가 존재하나,

실제 서버에 데이터를 전송하고 받아온 데이터를 모킹하는 방식이므로 비효율적이라고 판단하였고

동시에 Integration Test보다는 E2E테스트와 유사하다고 보이기 때문에 실제 api request, response 모두를 모킹할 수 있는 라이브러리인 msw를 선택하여 사용하였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
  rest.get(`${VACATION_PREVIEW_URL}`, (req, res, ctx) => {
    const monthType = req.url.searchParams.get('monthType');
    const yearType = req.url.searchParams.get('yearType');
    const enterDate = req.url.searchParams.get('enterDate');

    let newdateData = [...dateData];
    const len = newdateData.length;
    if (monthType === 'BEFORE') {
      newdateData.splice(len - 1, 1, {
        ...dateData[len - 1],
        yearRemark: monthType === 'BEFORE' ? '1번째 회계일' : null,
        monthRemark: monthType === 'BEFORE' ? null : '4개월차',
      });
    }

    if (yearType) {
      newdateData.splice(0, 1, {
        ...dateData[0],
        yearRemark: yearType === 'ACCOUNT_FULL' ? '회계일' : '입사일',
      });
    }

    if (enterDate) {
      newdateData = newdateData.map((el, index) => ({
        ...el,
        date: `2021-${
          Number(newdateData[0].date.split('-')[1]) + index
        }-${String(enterDate).slice(-2)}`,
      }));
    }

    return res(ctx.json([...newdateData]));
  }),

handler.ts

위 코드와 같이 조건등에 따라 마음대로 msw를 설정할 수 있고 실제로 요청을 하지 않으므로 완벽히 프론트 단에서 원하는 상황에 대한 테스트를 할 수 있게 된다.

다만 굉장히 공수가 많이 들어갔고 실제로 많은 부분을 이런방식으로 테스트하기위해서는 실제 개발과 유사할 정도로 노력이 필요한 것 같다는 판단을 했다.(아직 익숙치 않아서 그런것 같기도하다..)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
describe(() => {
  it("모달에 내용 변경 뒤 나가기 버튼 클릭 후 다시 열었을때 내용 초기화", () => {
    user.findAllByText("설정").click();

    user.findAllByText("입사자 월차").siblings().click();
    user.get("ul").find("li").contains("입사일에 11개 선 지급").click();
    user.findAllByText("입사일에 11개 선 지급").should("exist");

    user.findAllByText("나가기").click();

    user.findAllByText("설정").click();
    user.findAllByText("1달만근 시 1일 지급").should("exist");
  });
});

sth.integration.ts

cypress의 경우 위에 작성된 방식처럼 특수하게 중복될 일 없는 텍스트를 통해서 테스트를 위한 dom에 접근하여 click이벤트 및 존재여부 확인등을 쉽게 판단할 수 있게 된다.

다만, 공식문서에는 그리 추천하지 않고 data-id 등을 통해서 테스트를 위한 아이디값을 미리 설정하고 확인하는것을 추천한다고 되어있어

추후에 테스트코드를 한번 리팩토링 할 때, 다른방식을 시도해볼 생각이다.

4. E2E Test

사실 integration test와 e2e테스트에서 차이점을 준 것은 백엔드와의 연동이 되느냐 마느냐의 문제로 나뉘었다.

프론트엔드 특성 하나의 유저 이벤트를 통해 다양한 API호출이 발생하고 화면이 변경되고 하는 일이 많아 페이지별로 integration test를 진행하고

모든 작동이 정상적으로 된다면 이벤트를 통해 API를 호출하는 등의 행동에 대한 안정성이 커버된다고 판단했기 때문이다.

또한, Timing-difference로 인해 클릭이벤트로 인한 호출 순서에 대한 확인이 필요하거나 호출 횟수가 의도한대로 발생하는지에 대해 확인하기 위해서는 별도로

toHaveBeenCalled() 와 같은 메서드를 통해 확인해주거나, 실제 테스트가 진행되는 동안 모든 로그가 찍히기때문에 직접 확인해보는 방법이 있어 이를 활용하였다.

여기서 변수는 위에서 언급한대로 테크스택이 리덕스 툴킷 + 리덕스사가로 이루어져있어 api의 대부분의 호출은 redux saga를 통해 진행되는데

이때 cypress에해서 해당 api가 호출되었는지 확인이 되지 않아 굉장히 애를 먹었고 보장하는 범위를 생각하여 다시 테스트를 구성하였을 때는 합리적인 구성이 나왔다.

  1. Integration Test에서는 redux toolkit+ redux saga의 해당 action이 실행되는지 확인한다.
  2. Unit Test에서 Redux saga를 테스트하여 해당 API를 호출하는지 확인한다.

위와 같은 방식으로 구분을 지어놓고 난 뒤 테스트를 더욱 간편하게 짤 수 있게 되었다.

1
2
3
4
5
6
7
8
9
10
11
12
describe("companyId와 userName이 있고 토큰이 없는 경우", () => {
  it("로그인 정상작동 및 해당 경로로 정상이동", () => {
    const to = "vouchers";
    user.login({ to });
    user.window().its("store").invoke("dispatch", doLogin);

    user.location("href");
    user.location().should((location) => {
      expect(location.href).to.eq(`http://localhost:3000/${to}`);
    });
  });
});

redirect.e2e.ts

위의 reidrect.e2e.ts에서처럼 window에 미리 Cypress가 존재하는 경우에만 store객체를 임의로 배정준 뒤

그것을 통해서 store의 변동사항 및 액션이 dispatch되는지 확인할 수 있게 된다.

cypress-pipe라는 별도 모듈을 설치하면 payload와 state의 변동사항도 체크할 수 있다.

5. 결론

입사 후 주니어 프론트개발자 분밖에 계시지 않아 (물론 나도 주니어이다.) props drilling을 통해 급하게 jsp에서 포팅된 코드들로 이루어진 리액트 프로젝트였다.

그 과정에서 유틸,상수,api분리를 위한 redux-saga도입을 통해 관심사를 최대한 분리하려 하였고 유지보수가 용이하게 만들기 위해 노력했다.

그리고 마지막으로 조금이라도 마음이 편해져보고자(+ 알아야만 하는) FE Test에 대한 개념을 공부하며 회사에 도입하며 혼자 애를 굉장히 먹으며 진행했다.

하지만 여러가지 개념을 배우게 되었고(테스트 개발의 개념 등..), 설계를 어떤방식으로 하는게 유리할지에 대해 다양한 시각에서 생각을 해보게 되어 굉장히 값어치 있는 시간이었다고 생각된다.

아래 참고에는 무수히 참고한 많은 포스팅중 나름 필요하다고 생각된 것들을 별도로 노션에 모아놓았던 것인데 누군가에게는 도움이 될지 모르겠지라는 마음으로 다 공유해본다.

아마도 이글을 포스팅하고 나서도 추가적으로 수정을 몇번씩 할 것 같다,

끝!

참고

Concept

Library Usage

For Testing API with action dispatched with redux toolkit

Mocking API with JEST

msw Docs

Tips

Jest Setting for MonoRepo

This post is licensed under CC BY 4.0 by the author.

Next JS를 공부해보자[1편]

Next JS를 공부해보자[2편-Auth]