라이브러리 제작에서 사용한 E2E Test Cypress

라이브러리 배포 주소

fetch 라이브러리의 안정성을 확보하기 위해 사용했던 Cypress에 대해 공유하고자 합니다.

다양한 테스트 방법이 있지만, 인증 관리가 필요한 이 라이브러리 테스트에 있어 Cypress를 활용한 E2E 테스트를 선택한 이유는 다음과 같습니다.

일반적으로 테스트 방법에는 다음과 같은 유형이 있습니다

  • 단위 테스트: 함수나 메서드와 같은 코드의 개별적인 부분을 독립적으로 검증하는 테스트입니다. 빠른 피드백을 제공하며, 비즈니스 로직이 올바르게 동작하는지를 주로 확인합니다.
  • 통합 테스트: 여러 모듈이 서로 상호작용하는 방식을 테스트하여, 시스템의 각 부분이 통합되어 제대로 작동하는지를 확인합니다. 종속성이나 모듈 간의 상호작용을 검증하기에 유용합니다.
  • E2E 테스트: 실제 사용 환경을 재현하여 전체적인 사용자 흐름을 테스트합니다. UI와 API의 상호작용, 인증과 같은 사용자 경험을 통합적으로 검증하기 때문에 최종 사용자에게 가까운 방식으로 테스트할 수 있습니다.

이번 프로젝트에서는 인증 흐름을 포함한 복잡한 사용자 시나리오를 검증해야 했기 때문에, Cypress를 사용하여 E2E 테스트를 수행했습니다. Cypress를 선택한 이유는 다음과 같습니다

  • 인증 흐름 자동화: Cypress는 브라우저 환경을 완벽하게 재현할 수 있어, 로그인, 토큰 발급 및 갱신, 세션 유지 등 인증이 필요한 모든 과정을 사용자처럼 자동화하여 테스트할 수 있었습니다.
  • 상태 관리: Cypress는 쿠키와 로컬 스토리지와 같은 상태를 관리할 수 있어, 인증 상태에 따른 접근 제어와 세션 유지 등의 시나리오를 테스트하기에 적합했습니다.
  • 실제 사용자 경험에 가까운 시나리오 구성: Cypress의 명령 체인을 통해 여러 API 호출과 토큰 갱신 과정을 순차적으로 테스트하여 실제 사용 흐름을 재현할 수 있었습니다.

Cypress 설치

먼저, Cypress를 설치하여 프로젝트에서 사용할 수 있도록 설정합니다.

npm으로 설치

npm install cypress --save-dev

yarn으로 설치

yarn add cypress --dev

pnpm으로 설치

pnpm add cypress --save-dev

Cypress 설정 파일 구성 및 역할 설명

Cypress를 사용하여 E2E 테스트를 진행하면서, 저는 여러 설정 파일을 커스터마이징하여 테스트 효율성을 높였습니다. 각 파일이 어떤 역할을 하는지 설명하겠습니다.

1. command.ts: 커스텀 명령어 정의

command.ts 로그인 관련 작업을 cy.login 명령어로 만들어 테스트 전반에 쉽게 재사용할 수 있도록 했습니다.

/// <reference types="cypress" />
 
const SUPABASE_URL = Cypress.env("SUPABASE_URL");
const SUPABASE_ANON_KEY = Cypress.env("SUPABASE_ANON_KEY");
 
// Add login command
Cypress.Commands.add("login", (email: string, password: string) => {
  cy.request({
    method: "POST",
    url: `${SUPABASE_URL}/auth/v1/token?grant_type=password`,
    body: { email, password },
    headers: {
      apikey: SUPABASE_ANON_KEY,
      "Content-Type": "application/json",
    },
    failOnStatusCode: false,
  }).then((response) => {
    if (response.status === 200) {
      Cypress.env("ACCESS_TOKEN", response.body.access_token);
      Cypress.env("REFRESH_TOKEN", response.body.refresh_token);
    } else {
      throw new Error("Login failed: " + response.body.message);
    }
  });
});

cy.login 명령어를 통해 로그인 요청을 보내고, 성공 시 발급받은 ACCESS_TOKEN과 REFRESH_TOKEN을 환경 변수에 저장하여 이후 테스트에서 인증 상태를 유지할 수 있습니다.

2. index.ts: 타입 정의 및 전역 설정

index.ts 파일에서는 Cypress의 전역 설정을 관리하고, 커스텀 명령어에 대한 타입 정의를 추가했습니다. TypeScript는 Cypress 커스텀 명령어를 기본적으로 인식하지 않기 때문에, cy.login 명령어를 TypeScript에서도 사용할 수 있도록 타입을 선언했습니다.

declare namespace Cypress {
  interface Chainable<Subject = any> {
    /**
     * Custom command to log in to Supabase
     * @example cy.login('email', 'password')
     */
    login(email: string, password: string): Chainable<void>;
  }
}

3. e2e.ts: API 인스턴스 생성

e2e.ts 파일은 Cypress가 매 E2E 테스트 실행 전에 자동으로 로드하는 설정 파일입니다. 이 파일은 커스텀 명령어 등록, 글로벌 설정 및 예외 처리 등을 통해 테스트의 안정성과 편의성을 높여줍니다.

테스트 실행 전 설정

before(() => {
  // Example: Log in test user or perform other initial setup
  cy.login("test1234@test.com", "123456");
});

before 훅을 통한 전역 설정: 모든 테스트가 시작되기 전에 실행될 설정을 정의합니다. 예제에서는 cy.login을 호출하여 특정 계정(test1234@test.com)으로 로그인한 상태에서 테스트가 시작되도록 설정했습니다. 이를 통해 매 테스트마다 로그인을 반복할 필요 없이, 필요한 초기 설정을 한 번만 적용할 수 있습니다.

전역 예외 처리

전역 예외 처리: Cypress는 기본적으로 uncaught:exception 이벤트를 발생시키면 테스트를 중단하지만, 여기서는 모든 예외를 무시하고 테스트가 중단되지 않도록 설정했습니다

Cypress.on("uncaught:exception", (err, runnable) => {
  // Ignore the error and continue the tests
  return false;
});

4.test.cy.ts

Cypress 테스트 구조

describe

describe는 테스트를 그룹화하는 데 사용됩니다. 테스트 모음에 대해 설명을 제공하며, 이를 통해 테스트를 논리적 단위로 구성할 수 있습니다.

it

it은 단일 테스트 케이스를 정의하는 데 사용됩니다. describe 내부에 여러 개의 it을 포함하여 다양한 테스트를 실행할 수 있습니다.

expect

expect는 테스트의 예상 결과를 정의하는 Assertion입니다. 이를 통해 테스트 결과가 예상한 대로 동작하는지 확인할 수 있습니다.

먼저 Api 클래스의 인스턴스를 생성합니다. 이 인스턴스는 baseUrl, getToken, onRefreshToken과 같은 속성을 설정하여 Supabase와의 API 통신을 처리합니다. getToken은 요청 시 토큰을 가져오는 함수이고, onRefreshToken은 토큰 만료 시 새로운 토큰을 발급받는 함수입니다.

인스턴스 생성

const api = new Api({
  baseUrl: SUPABASE_URL, // API 기본 URL
  getToken: () => Cypress.env("ACCESS_TOKEN"), // API 요청에 필요한 토큰
  onRefreshToken: async () => {
    // 토큰 갱신 처리
    const { data, error } = await supabase.auth.refreshSession({
      refresh_token: Cypress.env("REFRESH_TOKEN"),
    });
 
    if (error) {
      throw new Error("Token refresh failed: " + error.message);
    }
 
    if (data?.session?.access_token) {
      Cypress.env("ACCESS_TOKEN", data.session.access_token);
      Cypress.env("REFRESH_TOKEN", data.session.refresh_token);
      cy.log("New ACCESS_TOKEN:", data.session.access_token);
      cy.log("New REFRESH_TOKEN:", data.session.refresh_token);
    } else {
      throw new Error("Failed to get a new access token");
    }
  },
});

요청 보내기

이 Api 인스턴스를 사용하여 Supabase API에 GET 요청을 보냅니다. 요청에 필요한 ACCESS_TOKEN과 apikey를 beforeRequest에서 설정하고, 성공 시 응답 데이터를 검증합니다.

api.get({
  url: `${API_ENDPOINT}/test`,
  query: { select: "*" },
  beforeRequest: (url, options) => {
    options.headers = {
      ...options.headers,
      apikey: SUPABASE_ANON_KEY,
    };
    cy.log("Using ACCESS_TOKEN:", Cypress.env("ACCESS_TOKEN"));
  },
  onSuccess: (data) => {
    const responseData = data as any[];
    expect(responseData).to.be.an("array");
    if (responseData.length > 0) {
      expect(responseData[0]).to.have.property("key");
    }
  },
  onError: (error) => {
    throw new Error("GET request failed: " + error.message);
  },
});

E2E 결과 화면