저번 포스트인 프론트엔드는 무엇을 테스트해야 하는가?에서는 프론트엔드에서의 유닛 테스트와 통합 테스트에 대해 집중적으로 다루었다. 이번 포스트에서는 프론트엔드의 또 다른 테스트 유형인 E2E 테스트에 대해 알아보고, E2E 테스트 도구인 Cypress를 활용하여 필자가 실제 진행 중인 사이드 프로젝트에서 E2E 테스트를 작성해 봄으로써, 이를 통해 E2E 테스트 작성 방법까지 다루어 보려고 한다.

 

 

1. 왜 E2E 테스트를 해야 하는가?

E2E(End-to-End) 테스트는 애플리케이션의 흐름을 처음부터 끝까지 검증하는 것으로, 앞서 언급한 유닛 테스트와 통합 테스트와는 또 다른 의미를 가진다. 유닛 테스트와 통합 테스트가 개발자 관점에서 개발한 모듈이 정상적으로 작동하는지를 검증하는 반면, E2E 테스트는 사용자 관점에서 애플리케이션의 작동을 검증하기 때문이다. 따라서 E2E 테스트는 주로 실제 사용자가 애플리케이션을 사용하는 브라우저 환경에서 진행하며, 대부분의 E2E 테스트 도구가 테스트 과정을 스냅샷이나 동영상으로 볼 수 있는 기능을 제공하고 있다.

 

E2E 테스트가 실제 사용자 환경에서 진행될 수 있다는 점은 장점이지만 동시에 단점이기도 한데, 이 때문에 E2E 테스트는 무거워지며, 애플리케이션 전체를 실제 환경에 올려야 하기 때문에 오버헤드가 매우 크다. 그래서 E2E 테스트는 사용자의 주요 사용 흐름을 검증하거나 모듈 간 통합 검증에만 사용되어야 하며, 각 모듈의 정상 작동 여부는 유닛 테스트나 통합 테스트에서 검증하도록 해야 한다.

 

하지만 프론트엔드에서는 UI와 로직이 결합되어 있어, 많은 테스트가 E2E 테스트로 작성되는 역 피라미드 테스트(아이스크림 콘) 안티 패턴이 자주 발생한다. E2E 테스트는 실행 시간이 길고 관리하기 어렵기 때문에, 반드시 @testing-library와 같은 라이브러리를 사용하여 컴포넌트 단위의 독립적인 테스트를 작성하도록 해야 한다. 독립적인 테스트 작성 방법은 프론트엔드는 무엇을 테스트해야 하는가? 글을 참고하도록 하자

 

단점이 있지만, 개발자가 만든 모듈이 통합되어 애플리케이션 수준에서 사용자가 사용할 때 정상적으로 작동함을 보장할 수 있는 테스트는 E2E 테스트뿐이다. 따라서 E2E 테스트도 매우 중요하다고 할 수 있다.

 

내 생각에 E2E 테스트가 매우 유용한 두 가지 케이스가 있다. 첫 번째는 비즈니스 관점에서 사용자의 주요 경로가 정상 작동하는지 확인하는 것이다. 두 번째는 ATDD(Acceptance Test Driven Development)를 적용하는 경우이다. 일부 모듈이 정상 작동하지 않더라도 사용자의 주요 경로가 정상 작동한다면 비즈니스 관점에서는 크리티컬 한 문제가 아닐 수 있다. 그러나 주요 경로가 정상 작동하지 않는다면 이는 매우 큰 문제가 될 수 있다. 이런 테스트를 작성할 때 E2E 테스트는 매우 적합하다.

 

또한, 필자는 인수 테스트 주도 개발(ATDD)이 유저 스토리 단위로 반복 주기를 가지고 애자일 하게 개발하는 팀에 매우 적합한 방법이라고 생각하고 있는데, 인수 테스트는 사용자 관점에서 작성해야 하므로 E2E 테스트로 작성하는 것이 적합하다. ATDD는 이해관계자들 간에 개발 완성물에 대한 논의를 더 깊게 이끌고, 개발 완료를 명확하게 정의할 수 있어 매우 매력적이고 할 수 있다. 사실, 필자가 이번 사이드 프로젝트에 cypress 기반의 E2E 테스트를 도입한 이유도 다음 프로젝트에서 ATDD를 적용해 보기 위해서 이다.

 

 

2. 왜 Cypress 인가?

Cypress는 인기 있는 E2E 테스트 도구이다. Cypress는 자주 Selenium과 비교되는데, 이 둘의 가장 큰 차이점은 아키텍처에 있다. Selenium은 브라우저 외부에서 실행되고 네트워크를 통해 원격으로 명령을 실행하는 반면, Cypress는 애플리케이션과 같은 실행 루프에서 명령을 실행한다. 따라서 Selenium은 특정 프로그래밍 언어에 구애받지 않고 작성할 수 있지만 속도가 느리며 언어에 맞는 모듈을 추가로 설정해야 한다. 반면에 Cypress는 자바스크립트로만 작성할 수 있지만 더 빠르고 통합적이다. 따라서 자바스크립트 기반의 프레임워크로 애플리케이션을 개발하고 있다면, Cypress를 이용하여 E2E 테스트를 빠르게 적용해 볼 수 있다.

 

2.1 페이지 객체 모델 vs 애플리케이션 작업 모델

앞서 언급한 바와 같이 E2E 테스트는 유지관리가 다른 테스트보다 어려우므로, 이를 쉽게 만들기 위한 기법을 적용하는 경우가 많다. Selenium에서는 이를 위해 '페이지 객체 모델'이라는 기법을 자주 사용한다. 그러나 Cypress 커뮤니티에서는 '애플리케이션 작업 모델'의 사용을 권장하고 있다. 이 둘의 차이점은 무엇일까?

 

페이지 객체 모델(Page Object Model)

페이지 객체 모델은 페이지의 객체를 생성하고, 그 페이지의 요소와 작업을 해당 객체에 정의한다. 즉 페이지 위에 해당 페이지와 관련된 작업을 캡슐화하는 추상화된 계층을 구축하는 것이다. 이러한 방식으로, 변동 사항이 발생했을 때 더 유연하게 대응할 수 있는데, 예컨대 요소의 ID가 변경되었을 때 일반적으로 모든 테스트를 수정해야 하지만 페이지 객체 모델에서는 페이지 클래스에서 요소를 찾아 수정하면 되기 때문이다.

 

cypress로는 아래와 같이 페이지 객체 모델을 만들 수 있다

class SignInPage {
  visit() {
    cy.visit('/signin');
  }

  getEmailError() {
    return cy.get(`[data-testid=SignInEmailError]`);
  }

  getPasswordError() {
    return cy.get(`[data-testid=SignInPasswordError]`);
  }

  fillEmail(value) {
    const field = cy.get(`[data-testid=SignInEmailField]`);
    field.clear();
    field.type(value);

    return this;
  }

  fillPassword(value) {
    const field = cy.get(`[data-testid=SignInPasswordField]`);
    field.clear();
    field.type(value);

    return this;
  }

  submit() {
    const button = cy.get(`[data-testid=SignInSubmitButton]`);
    button.click();
  }
}

export default SignInPage;

 

페이지 객체 모델의 단점

Cypress 커뮤니티에서 페이지 객체 모델의 단점으로 크게 4가지를 주장하고 있다.

 

  1. 관리의 어려움: 페이지 객체는 관리하기 어렵고 실제 애플리케이션 개발로부터 시간을 빼앗습니다. 저는 PageObjects가 테스트를 작성하는데 실제로 도움이 되도록 충분히 문서화된 것을 본 적이 없습니다.
  2. 디버깅 어려움: 페이지 객체는 테스트에 추가적인 상태를 도입하며, 이는 애플리케이션의 내부 상태와는 별개입니다. 이로 인해 테스트와 실패 사유를 이해하는 것이 더 어려워집니다.
  3. 안티패턴: 페이지 객체는 다양한 케이스를 일관된 인터페이스에 맞추려고 하며, 필요에 따라 조건부 로직으로 돌아갑니다 - 이는 우리의 견해에서 큰 안티패턴입니다.
  4. 느린 실행: 페이지 객체는 테스트를 항상 애플리케이션 사용자 인터페이스를 통해 진행하도록 강제하기 때문에 테스트를 느리게 만듭니다.

 

사실 필자는 1, 2, 3에 대해 솔직히 많이 공감하지 못하고 있다. 페이지 별로 객체를 만들고 관리하는 것이 관리가 어려울 수 있지만, 변경으로 인해 테스트 코드가 깨지는 것보다는 페이지 객체에서 변경사항을 처리하는 이점이 훨씬 크다고 생각한다. 또한, 이런 추상화 기법은 페이지 객체 모델 외에도 흔히 사용되는 패턴이므로, 페이지 객체 모델은 cypress에서도 여전히 유효하다고 본다.

 

4번에 대해서는 단점이 될 수 있다고 본다. 테스트를 작성하다 보면 테스트를 진행을 위한 사전 조건을 설정이 필요할 때가 있다. 예를 들어 todo list를 작성하는 애플리케이션이 있을 때 작업 완료가 정상적으로 작동하는지를 테스트한다고 한다고 해보자. 작업 완료를 테스트하기 위해서는 작업이 미리 생성되어 있어야 한다. 따라서 테스트는 아래와 같이 진행될 것이다

 

4번 항목은 충분히 단점으로 볼 수 있다. 테스트를 작성하다 보면 사전 조건을 설정해야 할 경우가 있다. 예를 들어, todo list를 작성하는 애플리케이션에서 작업 완료 기능이 정상적으로 작동하는지 테스트한다고 가정해 보자. 작업 완료를 테스트하려면, 미리 작업이 생성되어 있어야 합니다. 따라서 테스트는 다음과 같이 진행될 것이다.

출처:  https://www.cypress.io/blog/2019/01/03/stop-using-page-objects-and-start-using-app-actions

페이지 객체 모델은 사용자 인터페이스를 통해 직접 테스트 사전 조건을 설정해야 한다. 이는 테스트 실행 시간이 증가하는 주요 원인이 된다.

 

애플리케이션 작업 모델(Application action model)

애플리케이션 작업 모델(이하 '앱 작업')은 UI를 통한 상호작용이 아닌, 애플리케이션 코드와 직접 상호작용해 테스트 코드에서 애플리케이션 상태를 설정하는 방법이다. Selenium과 달리, Cypress는 애플리케이션과 같은 실행 루프에 존재하는 아키텍처 상 이러한 방법이 가능하다.

 

앱 작업은 듣기만 하면 이해하기 어려울 수 있지만, 코드를 보면 이해하기 쉽다. 앱 작업을 진행하려면 먼저 애플리케이션의 로직을 전역 객체에 참조로 연결하여 Cypress에서 사용할 수 있도록 해야 한다.

useEffect(() => {
    if (window.Cypress) {
      window.addTodos = todos => setTodos(todos)
    }
}, [])

그런 다음 cypress 코드에서 참조로 연결한 함수를 호출하면 된다.

const todos = ['buy some cheese', 'feed the cat', 'book a doctors appointment']

describe('Todo application', () => {
  beforeEach(() => {
    cy.visit('/')
 
    cy.window().invoke(
      'addTodos',
      todos.map(todo => ({ title: todo, done: false }))
    )
  })
  
  // ...
}

앱 작업으로 작성한 테스트는 아래와 같이 동작한다.

출처: https://www.cypress.io/blog/2019/01/03/stop-using-page-objects-and-start-using-app-actions

 

페이지 객체 모델에 비해 확연히 빨라진 모습을 볼 수 있다

 

둘 다 사용할 수는 없는가?

사실 저는 페이지 객체 모델과 앱 작업을 처음 접했을 때, 이 두 패턴이 서로 대체하는 역할이 아니라, 함께 사용할 수 있지 않나?라는 생각이 들었다. 왜냐하면 페이지 객체 모델은 추상화 계층을 만들어 관리하는 기법이고, 앱 작업은 애플리케이션의 상태를 변경하는 기법이기 때문에 둘은 서로 다른 측면을 가지고 있다고 생각했기 때문이다.

 

이에 대해 더 찾아보고 고민해 본 결과, Cypress 측 의견은 애플리케이션 상태를 직접 조작하면 테스트 조건 설정을 위한 반복 로직을 줄일 수 있고, 테스트해야 하는 로직만 남기게 된다. 이로 인해 중복 로직을 크게 줄일 수 있으며, 반복 로직이 생겨도 Cypress는 command 함수를 커스텀하여 정의할 수 있으므로, 페이지 수준의 객체를 관리할 필요가 없다는 것이다.

 

어느 정도 맞는 말이라고 생각하지만, 큰 규모의 애플리케이션의 경우, 앱 작업의 사용 여부에 관계없이 페이지 객체 모델을 유지하는 것이 관리 측면에서 도움이 될 것 같다는 생각이 들었다. 물론 규모가 크지 않는 경우, 페이지 객체 모델을 관리하면서 오히려 오버헤드가 발생할 수 있으니, 앱 작업과 command 함수의 조합으로 충분히 커버할 수 있을 것 같다.

 

앱 작업의 단점

마치 페이지 객체 모델이 구식이고 앱 작업이 진리인 것처럼 보일 수 있어서 앱 작업의 단점을 정리해 봤다. 어느 기술, 패턴도 은 총알이 아니니 상황과 목표에 따라 적절하게 활용하는 게 가장 좋을 듯하다.

 

  1. 강한 결합: 앱 작업은 애플리케이션 코드와 Cypress의 테스트 코드가 밀접하게 연결되어 있다. 이는 애플리케이션 코드의 변경이 테스트 코드에 영향을 줄 수 있다는 의미로, 이는 예상치 못한 동작일 수 있다. 따라서 앱 작업을 위해 애플리케이션 코드의 추상화된 인터페이스가 우선적으로 필요하다
  2. 현실적인 한계: E2E 테스트는 개발자뿐만 아니라 QA 팀에서도 작성하는 경우가 많다. 그러나 QA 팀이 애플리케이션 코드에 대한 접근 권한이 없을 수 있고, 최신 프론트엔드 기술에 대한 이해가 필요하므로 현실적으로 운영하기 어려울 수 있다.

 

3. 프로젝트에 Cypress와 앱 작업으로 E2E 테스트 작성하기

본격적으로 Cypress와 앱 작업을 적용하여 E2E 테스트를 작성해 보자. 필자는 사이드 프로젝트로 필자 모교의 졸업 요건을 검사할 수 있는 사이트를 운영하고 있는데, 해당 프로젝트의 사용자 주요 경로를 E2E 테스트로 만들어 관리하려고자 한다.

 

참고로 Cypress 공식 문서는 테스트 코드를 처음 작성하는 사람들이 참고할만한 자료가 많은데 특히 Best practices 파트에는 테스트 코드를 작성할 때 지켜야 할 좋은 관행들을 잘 정리해두고 있어 참고하면 좋다. 자세한 내용은 다음을 참고하자

 

3.1 사용자 정의 커맨드로 유틸 함수 정의하기

먼저, Cypress의 사용자 정의 커맨드를 작성하여 테스트 코드 작성에 필요한 유틸 함수를 정의해 보자. E2E 테스트는 성격 상 로그인과 결합된 로직을 테스트하는 경우가 많다. 따라서 매번 테스트마다 UI를 통해 로그인을 시도하면 테스트 실행 시간이 매우 길어진다. 이러한 문제를 해결하기 위해, Cypress는 브라우저 콘텍스트를 캐시 할 수 있는 cy.session() 커맨드 함수를 제공하고 있다. 이를 사용하면 한 번 로그인하면 다른 테스트에서도 재사용할 수 있다.

 

또한 테스트를 작성하기 위해 요소를 선택할 필요가 있는데, id나 class, 텍스트와 결합하면 변경에 쉽게 깨질 수 있다. 따라서 data-cy 속성을 요소에 추가하고 이를 selector로 활용하고자 한다.

// login 사용자 정의 커맨드
Cypress.Commands.add('login', (id, password) => {
  cy.session(
    id,
    () => {
      cy.visit('/sign-in');

      cy.get('input[name=authId]').type(id);

      // {enter} 는 submit을 동작시킨다.
      cy.get('input[name=password]').type(`${password}{enter}`, { log: false });

      // redirect를 예상한다.
      cy.url().should('include', '/my');
    },
    {
      // 동작이 제대로 일어 났는지를 검증한다.
      validate: () => {
        cy.getCookie('accessToken').should('exist');
      },
    },
  );
});

// data-cy 속성을 통해 요소에 접근한다.
Cypress.Commands.add('dataCy', (value, options) => {
  return cy.get(`[data-cy=${value}]`, options);
});

커스텀한 사용자 정의 커맨드는 아래와 같이 사용할 수 있다.

describe('Critical Path', () => {
  beforeEach(() => {
    cy.login('admin', 'admin');
    
    // ...
    
  });

  it('visit result page', () => {
    cy.visit('/my');

    cy.dataCy('result-page-link').click();

    cy.dataCy('remain-credit').should('contain', 82);
  });

  // ...
});

 

3.2 애플리케이션 작업 모델 적용하여 테스트 속도 개선하기

다음으로 앱 작업을 실제로 적용해 보고, 이로 인해 개발자의 경험과 테스트 속도가 어떻게 개선되는지 확인해 보자. 앱 작업을 적용해 볼 테스트 케이스는 '과목 삭제하기'이다. 사용자는 졸업 요건을 검사하기 위해 과목을 추가하거나 삭제할 수 있는데, 과목을 삭제하려면 반드시 먼저 과목을 추가해야 한다. 먼저 앱 작업을 적용하지 않고 테스트 코드를 작성해 보자

it('delete lecture', () => {
    const testLectureIds = [1, 3, 4];
    
    cy.visit('/my');

    cy.dataCy('open-lecture-search-dialog-button').click();

    createLecture('영어1');

    createLecture('영어무역이론');

    createLecture('영어회화3');

    cy.dataCy('drawer-overlay').click({ force: true });

    cy.contains('영어회화3').should('exist');

    deleteLecture(testLectureIds[0]);

    deleteLecture(testLectureIds[1]);

    deleteLecture(testLectureIds[2]);

    function createLecture(lectureName: string) {
      cy.dataCy('search-lecture-input').type(lectureName);

      cy.dataCy(`lecture-${lectureName}`, { timeout: 10000 }).should('exist');

      cy.dataCy(`add-lecture-button-${lectureName}`).click();

      cy.dataCy(`add-lecture-button-${lectureName}`).should('be.disabled');
    }

    function deleteLecture(lectureId: number) {
      cy.dataCy(`taken-lecture-delete-model-trigger-${lectureId}`).click();

      cy.dataCy('confirm-button').click();

      cy.dataCy(`taken-lecture-delete-model-trigger-${lectureId}`).should('not.exist');
    }
  });

코드에서 볼 수 있듯이, 과목 삭제를 테스트하려면 먼저 과목이 추가되어 있어야 한다. 일반적으로 앱 작업을 사용하지 않는 경우, 테스트의 사전 조건을 설정하기 위해 실제 사용자 인터페이스를 통해 과목을 미리 추가해 두어야 한다. 즉 테스트는 아래와 같이 진행된다.

app action 적용 전

테스트는 총 24초가 걸린다. 하지만 테스트 케이스에서 확인하고자 하는 것은 '과목 삭제하기'이지만, 실제로는 사전 조건을 설정하기 위한 '과목 추가하기'에 대부분의 시간이 소요되고 있다. '과목 추가하기'는 다른 테스트 케이스에서 이미 검증되었으므로, 이를 다시 실행하는 것은 낭비인 것이다.

 

다음으로 앱 작업을 적용하여, ‘과목 추가하기’를 실제 사용자 인터페이스를 통해 수행하지 않고, 앱 작업으로 수행해 보자. 앱 작업을 적용하기 위해서는 먼저 브라우저 전역 객체에 작업에 대한 참조를 연결해야 한다. React를 사용하는 경우 아래와 같이 등록할 수 있다.

'use client';

import { addTakenLecture } from '@/app/business/services/lecture/taken-lecture.command';

export function CypressProvider({ children }: React.PropsWithChildren) {
  useEffect(() => {
    if (window.Cypress) {
      window.addTakenLecture = async (lectureId: number[]) => {
        await Promise.all(lectureId.map((id) => addTakenLecture(id)));
      };
    }
  }, []);

  return <>{children}</>;
}

앱 작업을 사용하여 테스트 코드를 작성해 보자.

it('delete lecture', () => {
    const testLectureIds = [1, 3, 4];
    
    cy.visit('/my');
    
    cy.window().invoke(
      // 메서드 이름
      'addTakenLecture',
      // 메서드 인자
      testLectureIds,
    );

    deleteLecture(testLectureIds[0]);

    deleteLecture(testLectureIds[1]);

    deleteLecture(testLectureIds[2]);

    function deleteLecture(lectureId: number) {
      cy.dataCy(`taken-lecture-delete-model-trigger-${lectureId}`).click();

      cy.dataCy('confirm-button').click();

      cy.dataCy(`taken-lecture-delete-model-trigger-${lectureId}`).should('not.exist');
    }
  })

앱의 작업을 적용하고 수행한 테스트는 다음과 같이 실행된다

app action 적용 후, 시간이 단축되었다

전체 테스트 소요시간이 24초에서 14초로 줄어, 약 40%의 시간이 단축되었다. 예상보다 유의미한 시간 단축이 발생하였다. 더불어 테스트 코드에서는 테스트하고자 하는 코드만 남아, 무엇을 테스트하는지 더 명확하게 파악할 수 있게 되었다.

 

실제로 앱 작업을 적용하기 전에는 사실 긴가민가 했지만, 직접 사용해 본 결과 개발자 경험과 테스트 소요 시간이 크게 개선되었다는 것을 느낄 수 있었다. 여전히 애플리케이션 코드와 테스트 코드가 강하게 결합되는 문제는 단점이 될 수 있지만, 장점이 뚜렷한 강력한 패턴인 것은 분명해 보인다. 상황에 따라 적절하게 사용하도록 해야겠다.

 

 

4. Cypress로 E2E 테스트를 작성해 보고 느낀 점

서비스의 품질 향상을 위해 E2E 테스트를 적용해보고자 했고, 이번 기회에 Cypress를 활용하여 E2E 테스트를 작성할 수 있어서 즐겁게 작업했 던 것 같다. 실제로 E2E 테스트를 작성하면서 느낀 점은 E2E 테스트가 유닛 테스트나 통합 테스트와는 다른 영역을 커버한다는 것이다. 개발한 모듈이 정상적으로 동작하는지 검증하는 것과, 모듈이 통합되어 애플리케이션을 구성하고 사용자에게 적절한 가치를 전달하는지 검증하는 것은 완전히 다른 작업이라는 것을 깨달을 수 있었다.

 

또한, E2E 테스트가 왜 무거운 비용이 드는 테스트인지 이해할 수 있었다. 개발 환경이 매우 무거워 보였고, 실제로 테스트 소요 시간도 예상보다 더 길었다. 특히, FE는 제품을 이루는 다른 모듈들과 연동되어 동작하기 때문에 독립적인 테스트 작성이 더 어렵게 느껴졌다. 따라서, E2E 테스트는 전략적으로 필요한 요소에 적용하며, 유닛 테스트와 통합 테스트와의 적절한 비율을 유지하면서 관리할 필요성을 다시 한번 느꼈다.

 

Cypress는 실제로 사용해 보니 많은 장점을 가진 테스트 도구라는 생각이 들었다. 모든 E2E 작성 패키지가 포함되어 있어 설치와 실행이 간편하며, GUI로 제공되는 부가 기능들이 편의성이 좋게 느껴졌다. 또한 앱 작업을 실제로 사용해보니 다른 E2E 테스트 도구와 확실히 차별화될 수 있는 요소라는 생각이 들었다. 부가적으로 Cypress는 자동 대기를 지원하여, 비동기 요소를 처리하는 코드를 따로 작성할 필요가 없어 이 부분도 매우 편리했다.

 

Cypress의 단점이라고 한다면... 사실 엄밀히 말하면 Cypress의 단점은 아니지만, Cypress는 테스트 러너와 assertion을 위해 Mocha와 Chai를 사용하고 있다는 점이다. 주로 React로 개발하고 Jest를 사용하는 나에게는 이 문법이 다소 생소하게 느껴졌다. SPA 프레임워크로 개발을 시작한 사람이라면, 이 문제가 진입 장벽으로 느껴질 수 있다. 물론 Cypress를 사용하면 프레임워크와 무관하게 모든 애플리케이션을 테스트할 수 있어야 하므로, 이런 결정은 어쩔 수 없지 않았나 생각하고 있다.

 

참고 자료

https://www.cypress.io/blog/2019/01/03/stop-using-page-objects-and-start-using-app-actions

https://www.lambdatest.com/blog/cypress-app-actions/

https://medium.com/@iamsanjeevkumar/page-object-and-app-action-with-cypress-my-thoughts-a69b3f18707f