icon
15장 : 테스팅과 디버깅

E2E 테스트


이전 절에서 애플리케이션의 개별 단위(함수, 클래스 등)가 제대로 작동하는지 확인하는 단위 테스트(Unit Test) 에 대해 알아보았습니다. 단위 테스트는 코드의 가장 작은 부분을 검증하는 데 효과적이지만, 여러 단위가 결합되어 하나의 기능으로 동작할 때 발생할 수 있는 문제나 사용자 시나리오 전체의 흐름을 검증하기는 어렵습니다. 이때 필요한 것이 바로 E2E (End-to-End) 테스트입니다.

E2E 테스트는 실제 사용자의 관점에서 애플리케이션의 전체 흐름을 시뮬레이션하여, 모든 구성 요소(프론트엔드, 백엔드, 데이터베이스, 네트워크 등)가 통합되어 예상대로 동작하는지 확인하는 테스트입니다. 사용자 인터페이스를 통해 애플리케이션에 접근하고, 상호작용하며, 최종 결과를 검증함으로써 실제 운영 환경에서의 잠재적인 문제를 발견하는 데 도움을 줍니다.

이 절에서는 인기 있는 E2E 테스트 도구인 Cypress를 사용하여 React 및 타입스크립트 애플리케이션에서 E2E 테스트를 작성하는 방법에 대해 자세히 알아보겠습니다.


E2E 테스트의 중요성

E2E 테스트는 다음과 같은 강력한 이점을 제공합니다.

  • 실제 사용자 경험 검증: 사용자가 겪을 수 있는 실제 시나리오를 모방하여, 기능뿐만 아니라 통합적인 사용자 경험까지 검증합니다.
  • 시스템 통합성 확인: 프론트엔드와 백엔드, 데이터베이스, 외부 API 등 시스템의 모든 부분이 원활하게 연동되는지 확인합니다.
  • 배포 전 최종 검증: 프로덕션 환경에 배포하기 전에 애플리케이션이 완벽하게 작동함을 보장하는 마지막 방어선 역할을 합니다.
  • 비즈니스 로직 검증: 복잡한 비즈니스 규칙이 여러 시스템을 거쳐 올바르게 구현되었는지 검증합니다.
  • 회귀(Regression) 방지: 코드 변경이나 새로운 기능 추가로 인해 기존 기능이 오작동하는 회귀 버그를 조기에 발견합니다.

단위 테스트와 E2E 테스트는 상호 보완적입니다. 단위 테스트로 작은 버그를 빠르게 잡고, E2E 테스트로 전체 시스템의 안정성을 확보하는 것이 이상적인 테스트 전략입니다.


Cypress 소개 및 설정

Cypress는 프론트엔드 웹 애플리케이션을 위한 차세대 E2E 테스트 프레임워크입니다. Selenium과 같은 기존 도구와 달리, Cypress는 브라우저 내부에서 직접 실행되므로 더 빠르고 안정적인 테스트를 제공합니다.

Cypress의 주요 특징

  • 빠른 실행: 브라우저와 동일한 실행 루프에서 동작하므로 테스트 실행이 매우 빠릅니다.
  • 실시간 리로딩: 코드 변경 시 테스트가 자동으로 다시 실행되어 개발 피드백이 빠릅니다.
  • 자동 대기: 비동기 요소(네트워크 요청, DOM 업데이트)에 대한 스마트한 자동 대기 기능을 제공하여 테스트의 안정성을 높입니다.
  • 디버깅 용이성: 테스트 실행 중 스냅샷, 동영상 녹화, DOM 스냅샷 등 강력한 디버깅 도구를 제공합니다.
  • 쉬운 설정: 설정이 간편하며, 타입스크립트를 기본적으로 잘 지원합니다.

프로젝트 설정

새로운 React/Node.js 프로젝트에서 Cypress와 타입스크립트를 사용하기 위한 설정 방법은 다음과 같습니다.

Cypress 설치: 프로젝트 루트에서 Cypress를 개발 의존성으로 설치합니다.

npm install --save-dev cypress

Cypress 초기화 및 구성 파일 생성: 다음 명령어를 실행하면 Cypress가 초기화되고, 필요한 파일(cypress.config.ts, cypress/support/e2e.ts, cypress/support/commands.ts 등)이 생성됩니다. 또한 Cypress 테스트 Runner가 열립니다.

npx cypress open

이 명령어를 실행하면 "Welcome to Cypress!" 화면이 나타나고, "E2E Testing" 을 선택하여 설정을 완료합니다. 브라우저 선택 후 계속 진행하면 초기 예제 파일들이 생성됩니다.

cypress.config.ts 설정: Cypress의 설정 파일입니다. 타입스크립트 기반으로 작성됩니다.

cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    setupNodeEvents(on, config) {
      // Node.js 이벤트 리스너를 여기에 추가합니다.
      // 예를 들어, 데이터베이스 초기화나 API Mocking 등을 설정할 수 있습니다.
      return config;
    },
    specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}', // E2E 테스트 파일 패턴
    baseUrl: 'http://localhost:3000', // 애플리케이션이 실행되는 기본 URL
    supportFile: 'cypress/support/e2e.ts', // 커스텀 명령어 등 전역 설정 파일
    video: true, // 테스트 실패 시 비디오 녹화
    screenshotOnRunFailure: true, // 테스트 실패 시 스크린샷 저장
  },
});

tsconfig.json 설정: Cypress 테스트 파일에서도 타입스크립트를 사용하고, Cypress 전역 객체에 대한 타입 지원을 받기 위해 tsconfig.json을 수정합니다.

tsconfig.json
{
  "compilerOptions": {
    // ... (기존 설정 유지)
    "target": "es2018",
    "module": "commonjs", // Cypress가 Node.js 환경에서 돌아가므로 CommonJS 사용 권장
    "lib": ["es2018", "dom"], // DOM 타입도 포함 (브라우저 환경)
    // ...
    "types": ["cypress", "node"] // Jest 대신 Cypress 타입을 포함
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "cypress/**/*.ts", "cypress/**/*.js"], // Cypress 테스트 파일도 포함
  "exclude": ["node_modules"]
}
  • "types": ["cypress", "node"]: Cypress의 전역 변수(cy, Cypress 등)에 대한 타입 정의를 가져옵니다.
  • "lib": ["es2018", "dom"]: 브라우저 환경의 API(DOM, fetch 등) 타입을 포함합니다.

package.json 스크립트 추가: Cypress를 실행하기 위한 스크립트를 추가합니다.

package.json
{
  "name": "my-app",
  // ...
  "scripts": {
    "start": "react-scripts start", // React 앱 실행 스크립트 (또는 백엔드 서버)
    "test:e2e": "cypress open", // Cypress 테스트 러너 GUI 실행
    "test:e2e:headless": "cypress run", // 헤드리스 모드 (CLI)로 테스트 실행
    "test": "jest", // Jest 단위 테스트
  },
  // ...
}

E2E 테스트를 실행하기 전에 애플리케이션이 실행 중이어야 합니다. 예를 들어, React 앱이라면 npm start로 앱을 먼저 실행한 후 npm run test:e2e를 실행합니다.


기본적인 E2E 테스트 작성하기

이제 간단한 사용자 로그인 시나리오를 테스트하는 E2E 테스트를 작성해봅시다.

테스트 대상 React 컴포넌트 (src/components/Login.tsx)

src/components/Login.tsx
import React, { useState } from 'react';

interface LoginProps {
  onLogin: (username: string) => void;
}

const Login: React.FC<LoginProps> = ({ onLogin }) => {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    setError(''); // 이전 에러 메시지 초기화

    if (username === 'testuser' && password === 'password123') {
      onLogin(username);
    } else {
      setError('Invalid username or password.');
    }
  };

  return (
    <div style={{ padding: '20px', border: '1px solid #ccc', maxWidth: '300px', margin: '50px auto' }}>
      <h2>Login</h2>
      <form onSubmit={handleSubmit}>
        <div>
          <label htmlFor="username">Username:</label>
          <input
            id="username"
            type="text"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
          />
        </div>
        <div style={{ marginTop: '10px' }}>
          <label htmlFor="password">Password:</label>
          <input
            id="password"
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
          />
        </div>
        {error && <p style={{ color: 'red', marginTop: '10px' }}>{error}</p>}
        <button type="submit" style={{ marginTop: '20px' }}>Login</button>
      </form>
    </div>
  );
};

export default Login;

테스트 대상 React 앱 (src/App.tsx)

src/App.tsx
import React, { useState } from 'react';
import Login from './components/Login';

const App: React.FC = () => {
  const [loggedInUser, setLoggedInUser] = useState<string | null>(null);

  const handleLogin = (username: string) => {
    setLoggedInUser(username);
  };

  const handleLogout = () => {
    setLoggedInUser(null);
  };

  return (
    <div className="App">
      {loggedInUser ? (
        <div style={{ textAlign: 'center', marginTop: '100px' }}>
          <h1>Welcome, {loggedInUser}!</h1>
          <button onClick={handleLogout}>Logout</button>
        </div>
      ) : (
        <Login onLogin={handleLogin} />
      )}
    </div>
  );
};

export default App;

E2E 테스트 코드 (cypress/e2e/login.cy.ts)

cypress/e2e/login.cy.ts
// describe 블록: 관련 테스트들을 그룹화합니다.
describe('Login Functionality', () => {
  // beforeEach: 각 테스트 케이스 실행 전에 호출됩니다.
  beforeEach(() => {
    // cy.visit(): 지정된 URL로 이동합니다. baseUrl은 cypress.config.ts에 설정되어 있습니다.
    cy.visit('/');
  });

  // it (또는 test): 개별 테스트 케이스를 정의합니다.
  it('should successfully log in with valid credentials', () => {
    // cy.get(): CSS 셀렉터로 DOM 요소를 선택합니다.
    // .type(): 선택된 요소에 텍스트를 입력합니다.
    cy.get('#username').type('testuser');
    cy.get('#password').type('password123');

    // .click(): 선택된 요소를 클릭합니다.
    cy.get('button[type="submit"]').click();

    // cy.contains(): 지정된 텍스트를 포함하는 요소를 찾습니다.
    // .should('be.visible'): 요소가 화면에 보이는지 단언합니다.
    cy.contains('Welcome, testuser!').should('be.visible');

    // 추가 검증: 로그인 폼이 사라졌는지 확인
    cy.get('form').should('not.exist');
  });

  it('should display an error message with invalid credentials', () => {
    cy.get('#username').type('wronguser');
    cy.get('#password').type('wrongpass');
    cy.get('button[type="submit"]').click();

    // 에러 메시지가 표시되는지 확인
    cy.contains('Invalid username or password.').should('be.visible');

    // 로그인 폼이 여전히 보이는지 확인
    cy.get('form').should('exist');
  });

  it('should allow logout after successful login', () => {
    // 로그인 절차 반복
    cy.get('#username').type('testuser');
    cy.get('#password').type('password123');
    cy.get('button[type="submit"]').click();

    cy.contains('Welcome, testuser!').should('be.visible');

    // 로그아웃 버튼 클릭
    cy.get('button').contains('Logout').click();

    // 로그인 폼이 다시 나타났는지 확인
    cy.get('h2').contains('Login').should('be.visible');
    cy.get('form').should('exist');
    cy.contains('Welcome, testuser!').should('not.exist');
  });
});

테스트 실행

먼저 React 개발 서버를 실행합니다 (npm start).

새로운 터미널에서 Cypress 테스트 러너를 실행합니다 (npm run test:e2e).

Cypress GUI에서 login.cy.ts 파일을 클릭하면 브라우저가 열리고 테스트가 실행되는 것을 실시간으로 볼 수 있습니다.


Cypress의 주요 명령어 및 기능

Cypress는 cy 객체를 통해 다양한 명령어를 제공합니다.

  • cy.visit(url): 특정 URL로 이동합니다.
  • cy.get(selector): CSS 셀렉터를 사용하여 DOM 요소를 선택합니다.
  • cy.find(selector): 이전에 선택된 요소의 자식 요소를 찾습니다.
  • cy.contains(text): 특정 텍스트를 포함하는 요소를 찾습니다.
  • cy.type(text): 입력 필드에 텍스트를 입력합니다.
  • cy.click(): 요소를 클릭합니다.
  • cy.should(assertion, value): 특정 단언(assertion)을 사용하여 요소의 상태를 검증합니다. (예: 'be.visible', 'have.text', 'not.exist')
  • cy.intercept(): 네트워크 요청을 가로채고 Mocking하거나 스파이합니다. E2E 테스트에서 백엔드 API를 Mocking하는 데 매우 유용합니다.
  • cy.wait(ms | alias): 일정 시간 대기하거나, 별칭이 지정된 네트워크 요청이 완료될 때까지 대기합니다.
  • cy.screenshot(): 현재 화면의 스크린샷을 찍습니다.
  • cy.exec(command): Node.js 명령어를 실행하여 테스트 환경을 설정하거나 정리할 수 있습니다. (예: DB 초기화 스크립트 실행)

네트워크 요청 Mocking

E2E 테스트는 실제 백엔드와 연동하는 것이 이상적이지만, 테스트 환경에서 백엔드를 항상 준비하는 것은 번거롭고 느릴 수 있습니다. 이때 cy.intercept()를 사용하여 네트워크 요청을 Mocking하면 테스트의 속도와 안정성을 높일 수 있습니다.

cypress/e2e/api_mocking.cy.ts
describe('User List with API Mocking', () => {
  beforeEach(() => {
    // GET /api/users 요청을 Mocking
    cy.intercept('GET', '/api/users', {
      statusCode: 200,
      body: [
        { id: 1, name: 'Mock User 1', email: 'mock1@example.com' },
        { id: 2, name: 'Mock User 2', email: 'mock2@example.com' },
      ],
      delay: 500, // 0.5초 지연 추가 (로딩 상태 테스트용)
    }).as('getUsers'); // 이 인터셉트를 'getUsers'라는 별칭으로 저장

    cy.visit('/users'); // 사용자 목록 페이지로 이동 (가정)
  });

  it('should display a loading state then user data from mocked API', () => {
    // 로딩 메시지가 보이는지 확인
    cy.contains('Loading users...').should('be.visible');

    // @getUsers 인터셉트가 완료될 때까지 기다립니다.
    cy.wait('@getUsers');

    // Mocking된 데이터가 화면에 표시되는지 확인
    cy.contains('Mock User 1').should('be.visible');
    cy.contains('mock1@example.com').should('be.visible');
    cy.contains('Mock User 2').should('be.visible');
    cy.contains('mock2@example.com').should('be.visible');

    // 로딩 메시지가 사라졌는지 확인
    cy.contains('Loading users...').should('not.exist');
  });
});

cy.intercept()fetchXMLHttpRequest를 사용하여 발생하는 모든 HTTP 요청을 가로챕니다. 이를 통해 테스트 중인 애플리케이션의 네트워크 종속성을 제거하고, 특정 시나리오(예: 네트워크 에러, 빈 응답)를 쉽게 시뮬레이션할 수 있습니다.


타입스크립트와 Cypress의 시너지

Cypress는 타입스크립트를 기본적으로 지원하며, 이는 다음과 같은 이점을 제공합니다.

  • 강력한 타입 검사: cy 명령어의 인자, 반환 값, 커스텀 명령어 등에 대한 타입 검사가 이루어져 잘못된 사용을 방지합니다.
  • 자동 완성 및 가이드: IDE에서 Cypress 명령어에 대한 자동 완성 및 JSDoc 기반 설명을 제공하여 테스트 코드 작성 속도를 높이고 오류를 줄입니다.
  • 코드 가독성 향상: 명시적인 타입 정의는 테스트 코드의 의도를 명확하게 하고 유지보수를 용이하게 합니다.
  • 유지보수성: 애플리케이션의 타입 정의가 변경될 때 테스트 코드에서도 관련 타입 오류를 즉시 감지할 수 있어, 회귀 테스트의 효율성을 높입니다.

결론

E2E 테스트는 애플리케이션의 사용자 경험과 시스템 통합성을 검증하는 데 필수적인 테스트 유형입니다. Cypress는 빠르고 안정적이며 디버깅이 용이한 E2E 테스트 프레임워크로서, 현대 웹 애플리케이션 개발에 매우 적합합니다. 타입스크립트와 함께 Cypress를 사용하면, 테스트 코드 자체의 품질과 신뢰도를 높이고, 개발 과정에서 발생할 수 있는 잠재적인 문제를 효과적으로 발견하여 더욱 견고한 애플리케이션을 배포할 수 있습니다.

단위 테스트, 통합 테스트, E2E 테스트를 적절히 조합하는 것은 소프트웨어 개발 생명주기 전반에 걸쳐 코드의 신뢰성과 안정성을 극대화하는 핵심 전략입니다.