프론트엔드 테스트 전략 - (2) Integration Test

2022. 9. 4. 08:53Frontend

 

Overview

이번 포스팅에서는 지난 포스팅에서 소개한 대로 프론트엔드에서 통합 테스트(Integration Test)를 구현하기 위한 방법을 설명하고자 합니다. 테스트가 작성되는 프로젝트는 React + Nextjs를 사용한 Server Side Rendering 프로젝트이고, 통합 테스트를 위한 도구로는 가장 유명한 도구 중 하나인 Cypress를 사용하였습니다. 또한 서버사이드 요청을 모킹하기 위해서 MSW(Mock Service Worker)를 사용하였습니다. React + Nextjs로 구성된 프로젝트가 아니더라도(e.g Angularjs, Vuejs) 통합 테스트를 작성하는 핵심 원리와 Server Side Request를 모킹 하는 핵심 원리는 동일하기 때문에 다른 적절한 방법을 찾아 테스트할 수 있을 것으로 기대합니다.

 

What is Integration Test?

지난 포스팅에서 소개한 것처럼 통합 테스트는 단일 컴포넌트를 테스트하는 것이 아닌, 여러 컴포넌트들의 유기적인 조합인 "모듈"안에서 의도한 동작들이 제대로 동작하는지를 테스트하는 것을 의미합니다. 이러한 통합 테스트의 정의를 프론트엔드에 적용하기 위해 조금 더 다듬어 보겠습니다.

Integration testing (sometimes called integration and testing, abbreviated I&T) is the phase in software testing
 in which individual software modules are combined and tested as a group. - Wikipedia

 

프론트엔드 테스트를 위해 모듈을 나누는 단위

프론트엔드에서 "모듈"을 나누는 단위는 사용자에게 보여지는 "화면"을 기준으로 구성하는 것이 좋습니다. 즉 화면을 구성하는 모든 컴포넌트를 하나의 모듈로 간주하고 해당 화면을 기준으로 유저가 수행 가능한 행동들을 기준으로 테스트한다는 의미입니다. 사용자는 화면을 보고 화면에 보이는 여러 요소들과 상호작용하면서 애플리케이션을 사용하기 때문에 화면은 서로 다른 컴포넌트들의 유기적인 관계를 나타내는 하나의 단위가 되며, 따라서 이러한 "유저에게 의미 있는"단위를 기준으로 테스트를 구성하고 수행하는 것은 합리적인 구분으로 생각할 수 있을 것입니다.

 

 

 

통합 테스트 환경

테스트 환경은 웹 서비스의 특성상 브라우저 환경에서 테스트하는 것이 가장 좋습니다. 물론 Test-Renderer와 같은 라이브러리를 사용하면 실제로 브라우저 환경에 렌더링하지 않고 메모리 상에서 렌더링을 처리하여 테스트를 할 수도 있습니다. 하지만 다음의 이유들로 인해서 통합 테스트의 경우 브라우저 환경에서 직접 테스트하는 것을 권장하며 이 포스팅에서는 Cypress라는 테스트 도구를 사용하여 브라우저 환경에서 직접 테스트를 하는 방법에 대해서 소개할 것입니다.

 

  1. 브라우저의 기능을 별도의 모킹 없이 그대로 사용할 수 있다. 
    localStorage, cookie, alert, eventListener등 브라우저에서 기본적으로 제공하는 기능들이 있습니다. 실제로 컴포넌트 개발 시에 이러한 브라우저 API를 사용하여 개발하는 경우는 적지 않으며 따라서 브라우저 환경에서 통합 테스트를 하게 될 경우 위 API를 별도로 모킹 하지 않고도 테스트를 수행할 수 있습니다. 


  2. 사용자가 경험하게 될 환경과 가장 유사한 환경에서 테스트하는 것이 좋다.
    실제로 사용자는 메모리 상에서 동작하는 코드가 아닌 브라우저 환경에 렌더되어 보이는 화면을 기준으로 여러 동작들을 수행합니다. 따라서 실제로 사용자가 경험하게 되는 환경과 가장 유사한 환경을 만들어서 테스트하는 것이 중요합니다.


  3. 테스트할 때도 테스트코드가 동작하는 과정을 눈으로 확인하기 쉬우며 빠른 피드백을 받을 수 있다.
    실제로 테스트코드를테스트 코드를 다 작성하고 나면 CI환경에서 테스트가 자동으로 수행되겠지만, 테스트 코드를 작성하는 과정에는, 특히 TDD방법론을 사용해서 개발하고 있다면 테스트 코드가 어디서 실패하는지, 잘 통과하게 하려면 어느 부분을 수정해야 하는지에 대한 피드백을 빠르고 정확하게 받는 것이 중요합니다. "화면"을 기준으로 "사용자의 입장"에서 테스트를 수행하는 것이기 때문에 피드백도 "화면"으로부터 받을 수 있다면 가장 좋습니다. 브라우저 환경에서 테스트를 하게 된다면 화면으로부터의 실시간 피드백을 받기가 쉽습니다. 

 

정리하면 개별 컴포넌트를 각각 렌더링하는 것이 아니라 화면을 구성하는 모든 컴포넌트를 한번에 브라우저 환경에 렌더링 한 뒤에 실제로 화면에서 유저가 할 것으로 기대되는 액션들, 이를테면 할일 목록을 추가하기 위해 버튼을 누르거나, 체크박스를 누르는 행동들을 테스트하는 것입니다. 이렇게 "내부 로직"이나 "컴포넌트"가 아닌 "화면"을 기준으로 브라우저 환경에서 통합 테스트를 진행하게 되면 얻을 수 있는 장점은 다음과 같습니다.

 

내부 로직이 변경되어도 테스트코드는 변경될 필요가 없다.

화면을 기준으로 테스트하기 때문에 내부 컴포넌트나 내부 로직이 리팩터링등으로 인해 변경되더라도 테스트 코드가 변경될 필요가 없습니다. 이전 포스팅에서도 예로 들었던 TodoList 애플리케이션을 다시 예로 들어서 설명하면, 실제로 화면을 기준으로 작성된 Integration Test의 테스트 코드는 "유저가 화면에서 할 일 목록 추가 버튼을 클릭했을 때, 입력 창이 뜬다", "입력창에 할 일을 입력하고 전송 버튼을 누르면 입력창이 닫히고 할 일 목록이 추가된다"와 같이 "유저 기준"으로 작성되어 있기 때문에 실제로 유저 입장에서의 큰 기획이 변경되지 않는 한 테스트 코드가 변경될 필요가 없습니다. 컴포넌트가 합쳐지든 나눠지든, 내부 로직이 바뀌든 말든, localStorage를 사용해 데이터를 저장하든 서버에 데이터를 저장하든 유저 입장에서는 여전히 동일하게 할일 목록 추가를 할 수 있는 것입니다. 

 

불필요한 모킹을 최소화할 수 있다.

"브라우저 환경"에서 "실제 애플리케이션 코드를 동작"시켜 테스트하게 되면 불필요한 모킹들을 최소화할 수 있습니다. 실제로 브라우저 환경에서 테스트하기 때문에 localStorage, cookie등의 브라우저 관련 기능들을 모킹 없이 그대로 사용할 수 있으며, 브라우저에서 들어오고 나가는 network 요청들도 쉽게 모킹 할 수 있습니다. 또한 특정 컴포넌트만 따로 떼어내서 테스트하는 방식이 아닌 전체 애플리케이션 서버를 띄워서 특정 화면 단위로 테스트를 수행하기 때문에 Global Store(e.g Redux)나 Hooks와 같은 내부 로직에 대한 모킹도 최소화할 수 있습니다. 실제로 서버와 주고받는 네트워크 request와 response에 대한 모킹만 하면 충분합니다.

 

Using Cypress

이러한 프론트엔드 관점에서의 통합 테스트(어쩌면 격리된 E2E 테스트라고도 부를 수 있을 것 같은)를 수행하기에 적합한 툴로 Cypress를 사용할 수 있습니다. 물론 Playwright와 같이 조금 더 커스텀 가능하고 다른 기능들을 제공하는 툴들도 존재하지만 기본적으로 제공하는 기능이 더 많고, 관련된 Docker 이미지도 제공하며, 오픈소스화 되어 유지보수도 꾸준하게 잘 되어 있고, 많은 플러그인들을 제공하는 등 여러 가지 확실한 장점들이 있기 때문에 통합 테스트를 "도입"하기 위해서는 가장 적절한 도구가 아닐까 하는 생각으로 Cypress를 선택하였습니다. 

 

 

실제로 공식 문서에서 누구나 쉽게 설치하고 테스트 코드를 작성할 수 있도록 많은 예제들과 튜토리얼들을 제공하고 있으며, 플러그인들도 쉽게 확인하고 사용할 수 있도록 안내하고 있습니다. 실제로 Cypress에서 동작하는 테스트 코드는 아래와 같은 형식을 가지고 있으며 실제로 테스트 코드를 실행하면 아래와 같이 실제 브라우저 환경에서 테스트 코드가 동작하면서 시각적으로 피드백을 남겨주는 것을 확인할 수 있습니다. CI 환경에서 테스트 코드를 동작시킬 때는 headless 모드로 GUI 없이 동작시킬 수 있으므로 시각적 피드백 없이 빠르게 동작시킬 수도 있습니다. (물론 여전히 브라우저 환경이므로 테스트 실패시에 스냅샷을 남겨주는 기능도 내장하고 있습니다.)

 

 

실제로 공식문서에 환경 설정 및 테스트 코드 예제까지 자세하게 문서가 제공되기 때문에 이 포스팅에서 Cypress 테스트 코드를 작성하는 법을 자세하게 안내하지는 않으며 공식문서에 나오지 않는, 즉 브라우저에서 모킹 할 수 없는 서버사이드 네트워크 요청을 모킹 하는 방법을 위주로 다루려고 합니다.

 

 

How Cypress Works

Cypress를 사용한 테스트는 다음과 같이 동작합니다.

  1. 테스트 수행 명령어가 입력되면 Cypress는 코드상에 있는 모든 모듈을 webpack을 사용해서 하나의 JS파일로 번들링합니다. 즉 브라우저에서 실행될 수 있도록 애플리케이션을 번들링 하는 것입니다. 

  2. 준비가 완료되면 Chrome을 실행하고 빈 페이지에 테스트 코드를 삽입하고 실행합니다. 

  3. 이 테스트 코드는 가장 먼저 iframe을 사용해서 애플리케이션의 페이지 (e.g localhost:3000/mypage)로 접속합니다. (cy.visit이나 cy.navigate에 해당합니다) Cypress는 iframe이 해당 애플리케이션의 페이지로 접속하게 만들며 테스트 코드는 이미 브라우저에서 실행되고 있기 때문에 iframe내부에 있는 DOM에 접근할 수 있습니다. 

  4. DOM API를 사용해서 element를 찾고 event를 트리거하면서 테스트를 수행합니다.

 

 

How to Mock Server Side Request

 

Cypress Provides "Almost" Everything 

React로만 구성된 CSR(Client Side Rendering)방식의 웹 서비스는 Cypress에서 Network를 모킹 하는 데에 있어서 큰 어려움이 없습니다. 위에서 언급했듯이 Cypress는 테스트를 수행하기 위해 자체적으로 브라우저를 띄워 거기에 애플리케이션을 렌더링 해서 테스트를 수행하기 때문에 브라우저에서 발생하는 네트워크 요청도 Cypress 자체에서 받아서 모킹 할 수 있습니다. 실제로 cy.intercept를 사용하면 클라이언트 사이드에서 발생하는 네트워크 요청을 손쉽게 모킹 할 수 있습니다.

 

 

실제로 다음과 같이 테스트 코드를 작성하면 `/user/nickname/period`의 path로 GET 요청을 보냈을 때 실제 서버로 요청을 보내지 않고, 코드에 명시된 serverError를 리턴하게 됩니다. 이를 통해 서버에서 응답 가능한 케이스들을 간편하게 테스트할 수 있습니다.

 

  it('이름 변경시에 14일이 경과하지 않은 경우 서버 에러를 보여준다.', () => {
    const serverError = 'some error';
    cy.intercept('GET', `*/user/nickname/period`, {
      statusCode: 200,
      body: { text: serverError },
    });

    cy.get('[data-testid=changeName]').click();
    cy.contains(serverError);
  });

How do We Mock Server Side Request (Internal Request)?

Cypress는 브라우저에서 할 수 있는 모든 것들을 테스트할 수 있습니다. 하지만 Nextjs와 같이 서버사이드 렌더링을 사용하는 프로젝트의 경우 난관이 존재합니다. "브라우저에서 할 수 없는 것"들도 존재하기 때문입니다. 실제로 Nextjs서버에 페이지 생성을 요청했을 때, Nextjs가 HTML을 그리기 위해 Server Side에서 Internal Request를 통해 Customized 된 데이터를 받아오고 이를 통해 뷰를 그리는 경우를 생각해 보겠습니다.

 

아래 그림에서 확인할 수 있듯, 실제로 서버사이드에서 Internal Request가 발생하는 것은 Cypress에서 관리할 수 있는 것이 아닙니다. 애플리케이션이 로드 된 뒤에 브라우저에서 요청이 발생하는 것이 아니기 때문입니다. 따라서 Cypress의 메서드를 사용해서 서버사이드에서 이루어지는 네트워크 요청들을 모킹 할 수 있는 방법은 없습니다. 즉 Cypress 바깥에서 이를 해결할 수 있는 방법을 찾아야 한다는 의미입니다

 

Using Service Worker

아래는 구글에서 소개하고 있는 서비스 워커의 정의입니다.

서비스 워커는 브라우저가 백그라운드에서 실행하는 스크립트로, 웹페이지와는 별개로 작동하며, 웹페이지 또는 사용자 상호작용이 필요하지 않은 기능에 대해 문호를 개방합니다.

- 서비스 워커는 자바스크립트 Worker이므로, DOM에 직접 액세스할 수 없습니다. 대신에 서비스 워커는 postMessage 인터페이스를 통해 전달된 메시지에 응답하는 방식으로 제어 대상 페이지와 통신할 수 있으며, 해당 페이지는 필요한 경우 DOM을 조작할 수 있습니다.

- 서비스 워커는 프로그래밍 가능한 네트워크 프록시이며, 페이지의 네트워크 요청 처리 방법을 제어할 수 있습니다. 서비스 워커는 사용하지 않을 때는 종료되고 다음에 필요할 때 다시 시작되므로 서비스 워커의 onfetch 및 onmessage 핸들러의 전역 상태에 의존할 수 없습니다. 보관했다가 다시 시작할 때 재사용해야 하는 정보가 있는 경우 서비스 워커가 IndexedDB API에 대한 액세스 권한을 가집니다.

 

 

 

갑자기 서버사이드 요청을 모킹하는 법을 이야기하다가 서비스 워커 이야기를 꺼낸 것은 다음 내용과 관련이 있습니다.

 

서비스 워커는 프로그래밍 가능한 네트워크 프록시이며,
페이지의 네트워크 요청 처리 방법을 제어할 수 있습니다.

 

 

즉 서비스 워커는 네트워크 프락시로 사용할 수 있으며, 이를 사용하면 서버사이드에서 일어나는 네트워크 요청을 가로채서 원하는 값을 주는, 즉 "모킹(Mocking)"의 기능을 하는 주체로써 사용할 수 있다는 것입니다. 이를 사용하면 다음과 같은 일련의 과정을 통해 서버사이드에서 발생하는 네트워크 요청들을 Cypress 테스트 환경에서 모킹 할 수 있습니다.

 

  1. Cypress 브라우저에서 페이지 생성을 요청할 때 서버사이드 요청시 원하는 응답을 설정하기 위한 정보들을 Cookie에 담아서 요청합니다. (정책이나 필요에 따라, Cross Domain 쿠키 설정을 추가로 구현해야 할 수도 있습니다.)

  2. Nextjs 서버는 해당 쿠키를 받은 뒤에 HTML 생성을 위한 정보를 받기 위해 REST API 서버로 요청을 보냅니다. 그리고 이 요청은 실제로 API 서버에 도달하기 전에 Service Worker에 의해 Intercept 됩니다.

  3. Service Worker에 사전에 설치된 Handler가 요청에 담겨 전달된 쿠키를 보고 원하는 값(mock data)을 리턴해줍니다.

  4. Nextjs 서버는 리턴된 mock data를 기반으로 페이지를 생성하여 Cypress 브라우저에게 리턴합니다.

  5. 원하는 데이터로 생성된 페이지이므로 Cypress는 의도에 맞게 이후 테스트를 수행할 수 있습니다.

 

 

MSW(Mock Service Worker)

MSW는 위에서 설명한 Service Worker의 역할을 간편하게 수행할 수 있도록 도와주는 라이브러리입니다. 실제로 서비스 워커를 설치해서 아래의 절차를 거쳐서 mock data를 리턴하며, 네트워크 레벨에서 이를 처리하므로 React를 사용하든, Angular Js를 사용하든 라이브러리에 구애받지 않고 원하는 리턴 값을 전달해 줄 수 있습니다.

 

 

 

설정 방법 및 사용방법은 공식 문서에 잘 소개되어 있으므로 이를 하나하나 소개하지는 않겠습니다. 다음과 같이 쿠키를 통해 조건에 맞는  mock data를 리턴할 수 있도록 Handler를 정의하고 이를 기반으로 MSW Service Worker를 실행합니다.

// set up server
export const server = setupServer(...handlers);

// handlers
export const handlers = [
  rest.get(`${endpoint}/user/profile`, (req, res, ctx) => {
    const { TEST_USER_TYPE } = req.cookies;

    switch (TEST_USER_TYPE) {
      case 'parent':
        return res(ctx.json(mockParentProfileResponse));
      case 'student':
        return res(ctx.json(mockStudentProfileResponse));
      default:
        return res(ctx.json({}));
    }
  }),
];

 

그리고 _app.tsx에서 애플리케이션이 처음 시작될 때, 이 서비스 워커를 설치하여 Nextjs 서버로부터 들어오는 네트워크 요청들을 모킹 할 수 있게 되며 아래와 같이 test 환경에서만 트리거 되도록 설정하면 테스트 환경에서만 ServerSideRequest를 모킹 할 수 있게 됩니다.

// _app.tsx

/**
 * MSW Configurations
 * Only mock Server Side Requests
 * (use cy.intercept to mock Client Side Requests)
 */
if (isServer && process.env.NODE_ENV === 'test') {
  import('@/tests/mocks/server').then(({ server }) => {
    server.listen({ onUnhandledRequest: 'bypass' });
  });
}

function MyApp({})....

 

 

Conclusion

이번 포스팅에서는 프론트엔드에서의 통합 테스트(Integration Test)를 정의하고 이를 수행하기 위한 방법을 구체적으로 살펴보았습니다. 특히 Nextjs를 사용해서 Internal Server Side Request를 통해 페이지를 서버사이드에서 렌더링 하는 경우 서버 사이드에서 발생하는 요청들은 Cypress에서 제공하는 기능만으로는 테스트하기가 어렵기 때문에 이를 테스트하기 위해 Service Worker(using MSW)와 Cookie를 사용하여 서버사이드에서 발생하는 네트워크 요청들을 Cypress에서 컨트롤할 수 있는 방법을 알아보았습니다.

 

오늘 소개한 방법을 사용하면 나중에 전사적으로 E2E테스트를 시작할 준비가 되었을 때 기존 테스트 코드를 그대로 재사용할 수 있습니다. (서버사이드에서 발생하는 요청들을 모킹하는 것이 아니라 그냥 실제 서버에서 내려주는 값을 사용하는 것이기 때문에 네트워크 모킹 하는 부분들만 지워주면 됩니다.)

 

애플리케이션에서 사용되는 프론트엔드 프로젝트의 규모가 갈수록 커짐에 따라 테스트 코드를 "잘" 작성하는 것만큼이나 "쉽고 빠르게" 작성하는 것이 중요해졌습니다. "테스트 코드를 작성하고 있다"에서 더 나아가 "테스트 코드를 잘 작성"하기 위해 이런저런 고민들을 계속하게 되는 것 같습니다.

 

 

반응형