icon
14장 : 테스팅

E2E 테스트 구현


단위 테스트와 통합 테스트는 애플리케이션의 개별 기능과 모듈 간의 상호작용을 검증하는 데 중요합니다. 하지만 실제 사용자가 브라우저에서 애플리케이션과 상호작용하는 모든 과정을 처음부터 끝까지 시뮬레이션하고 검증하는 것은 어렵습니다. 이때 필요한 것이 E2E (End-to-End) 테스트입니다. E2E 테스트는 애플리케이션이 실제 프로덕션 환경에서 예상대로 작동하는지 최종적으로 확인하는 데 사용됩니다.

이 절에서는 E2E 테스트의 개념과 중요성, Next.js 프로젝트에서 E2E 테스트를 위한 강력한 도구인 Cypress를 설정하고 사용하는 방법, 그리고 실제 사용자 시나리오를 기반으로 한 테스트를 작성하는 방법에 대해 상세히 알아보겠습니다.


E2E 테스트란 무엇이며 왜 중요한가요?

E2E 테스트는 사용자의 관점에서 애플리케이션의 전체 흐름을 테스트하는 방식입니다. 이는 사용자가 웹 브라우저를 열고, 로그인하고, 특정 페이지로 이동하고, 데이터를 입력하고, 버튼을 클릭하는 등의 일련의 상호작용을 자동화된 방식으로 시뮬레이션합니다. E2E 테스트는 백엔드 API, 데이터베이스, 네트워크 등 애플리케이션의 모든 계층을 포함하여 실제 운영 환경과 가장 유사한 조건에서 시스템 전체의 동작을 검증합니다.

E2E 테스트의 중요성

  • 실제 사용자 경험 검증: 사용자 관점에서 핵심 비즈니스 로직과 UI 상호작용이 올바르게 작동하는지 확인합니다.
  • 통합된 시스템 검증: 프론트엔드, 백엔드, 데이터베이스, 네트워크 등 모든 구성 요소가 함께 잘 작동하는지 확인하여 전체 시스템의 견고함을 보장합니다.
  • 배포 전 최종 안전망: 프로덕션 환경에 배포하기 전에 발생할 수 있는 가장 치명적인 문제를 발견하고 수정할 수 있는 마지막 기회를 제공합니다.
  • 신뢰도 향상: 복잡한 사용자 여정을 테스트함으로써, 애플리케이션에 대한 개발 팀과 이해 관계자들의 신뢰도를 높입니다.
  • 회귀(Regression) 방지: 새로운 기능 추가나 코드 변경이 기존 기능에 예상치 못한 부작용을 일으키는 것을 방지합니다.

Cypress란?

Cypress는 현대적인 웹 애플리케이션을 위한 빠르고 안정적인 E2E 테스트 프레임워크입니다. Selenium과 같은 기존 도구와 달리, Cypress는 브라우저 내부에서 직접 실행되므로 더 빠르고 안정적인 테스트 경험을 제공합니다. 개발자 친화적인 API, 실시간 리로드, 디버깅 기능, 비디오 녹화 등 다양한 기능을 내장하고 있습니다.

Cypress의 주요 특징

  • 빠른 실행: 브라우저와 동일한 실행 루프에서 테스트가 실행되어 빠르고 신뢰할 수 있습니다.
  • 쉬운 디버깅: 개발자 도구에 직접 접근할 수 있어 테스트 실행 중 문제를 쉽게 디버깅할 수 있습니다.
  • 자동 재실행: 파일 변경 시 테스트가 자동으로 재실행되어 개발 흐름을 방해하지 않습니다.
  • 스크린샷 및 비디오: 테스트 실패 시 자동으로 스크린샷을 찍고, 전체 테스트 과정을 비디오로 녹화하여 디버깅을 돕습니다.
  • 친숙한 API: jQuery와 유사한 명령어를 사용하여 DOM 조작 및 이벤트 트리거가 직관적입니다.

Next.js 프로젝트에 Cypress 설정하기

Next.js는 Cypress를 위한 통합된 설정을 제공하며, 설치 및 초기 설정이 매우 간단합니다.

Cypress 설치

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

npm install --save-dev cypress
# 또는
yarn add --dev cypress

package.json에 스크립트 추가

package.json 파일의 scripts 섹션에 Cypress 실행 명령어를 추가합니다.

package.json
{
  "name": "your-nextjs-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "test": "jest",
    "test:watch": "jest --watch",
    "cypress:open": "cypress open", // Cypress UI를 열어 테스트를 실행하고 디버깅
    "cypress:run": "cypress run"    // 헤드리스 모드로 모든 테스트 실행 (CI/CD용)
  },
  // ... (dependencies, devDependencies)
}

Cypress 초기 설정 및 구조

npm run cypress:open 또는 yarn cypress:open 명령어를 처음 실행하면, Cypress가 필요한 파일과 디렉토리를 자동으로 생성합니다.

  • cypress/ 디렉토리 생성: 모든 Cypress 관련 파일이 이 디렉토리 내에 생성됩니다.
    • cypress/e2e/: E2E 테스트 스펙 파일이 위치합니다.
    • cypress/support/e2e.ts (또는 e2e.js): 모든 테스트 파일에 앞서 실행되는 전역 설정 파일입니다. 커스텀 명령어나 공통적으로 필요한 설정을 여기에 추가합니다.
    • cypress/support/commands.ts: 커스텀 Cypress 명령어를 정의합니다.
    • cypress/fixtures/: 테스트에 필요한 정적 데이터를 저장합니다.
    • cypress.config.ts (또는 cypress.config.js): Cypress의 메인 설정 파일입니다.

cypress.config.ts 예시

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

export default defineConfig({
  e2e: {
    // 테스트 대상이 되는 애플리케이션의 기본 URL (Next.js 개발 서버 URL)
    baseUrl: 'http://localhost:3000',
    // 테스트 스펙 파일의 경로 패턴
    specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
    // 테스트 시작 전 실행될 setup 파일
    setupNodeEvents(on, config) {
      // Node.js 이벤트 리스너를 설정하여 Cypress가 Node 환경과 상호작용하도록 합니다.
      // 예: 파일 시스템 접근, 환경 변수 로드 등
    },
    // 비디오 녹화 활성화
    video: true,
    // 스크린샷 활성화 (테스트 실패 시 자동으로 촬영)
    screenshotOnRunFailure: true,
  },
  // 컴포넌트 테스트 설정 (선택 사항)
  component: {
    devServer: {
      framework: 'next',
      bundler: 'webpack',
    },
    specPattern: 'components/**/*.cy.{js,jsx,ts,tsx}',
  },
});

Next.js 개발 서버 실행

Cypress는 실제 브라우저에서 동작하므로, 테스트를 실행하기 전에 Next.js 개발 서버가 실행 중이어야 합니다.

npm run dev
# 또는
yarn dev

별도의 터미널에서 이 명령어를 실행한 후 Cypress 테스트를 실행합니다.


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

사용자 로그인 시나리오를 예시로 E2E 테스트를 작성해 보겠습니다.

시나리오

로그인 페이지로 이동한다.

사용자 이름과 비밀번호를 입력한다.

로그인 버튼을 클릭한다.

로그인 성공 시 대시보드 페이지로 리다이렉트 되는 것을 확인한다.

대시보드 페이지에 사용자 이름이 표시되는 것을 확인한다.

사전 준비 (가상의 로그인 페이지 및 대시보드 페이지)

app/login/page.tsx
"use client";
import { useState } from 'react';
import { useRouter } from 'next/navigation';

export default function LoginPage() {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const router = useRouter();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    // 가상 로그인 API 호출 (실제로는 백엔드 API를 호출)
    if (username === 'testuser' && password === 'password123') {
      alert('로그인 성공!');
      router.push('/dashboard');
    } else {
      alert('로그인 실패: 사용자 이름 또는 비밀번호가 올바르지 않습니다.');
    }
  };

  return (
    <div style={{ maxWidth: '400px', margin: '50px auto', padding: '30px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
      <h1 style={{ textAlign: 'center', marginBottom: '30px', color: '#333' }}>로그인</h1>
      <form onSubmit={handleSubmit}>
        <div style={{ marginBottom: '20px' }}>
          <label htmlFor="username" style={{ display: 'block', marginBottom: '8px', fontWeight: 'bold' }}>사용자 이름:</label>
          <input
            id="username"
            type="text"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            style={{ width: '100%', padding: '10px', borderRadius: '4px', border: '1px solid #ccc' }}
          />
        </div>
        <div style={{ marginBottom: '30px' }}>
          <label htmlFor="password" style={{ display: 'block', marginBottom: '8px', fontWeight: 'bold' }}>비밀번호:</label>
          <input
            id="password"
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            style={{ width: '100%', padding: '10px', borderRadius: '4px', border: '1px solid #ccc' }}
          />
        </div>
        <button
          type="submit"
          style={{ width: '100%', padding: '12px', backgroundColor: '#007bff', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer', fontSize: '1.1em' }}
        >
          로그인
        </button>
      </form>
    </div>
  );
}
app/dashboard/page.tsx
"use client";
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';

export default function DashboardPage() {
  const router = useRouter();
  const [user, setUser] = useState('테스트 사용자'); // 실제로는 로그인 상태 관리 시스템에서 가져옴

  // 간단한 로그인 여부 확인 로직 (실제 앱에서는 더 복잡)
  useEffect(() => {
    // 실제로는 토큰 확인 등의 로직
    // if (!isLoggedIn) {
    //   router.push('/login');
    // }
  }, [router]);

  const handleLogout = () => {
    alert('로그아웃 되었습니다.');
    router.push('/login');
  };

  return (
    <div style={{ maxWidth: '800px', margin: '50px auto', padding: '30px', border: '1px solid #28a745', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
      <h1 style={{ textAlign: 'center', marginBottom: '30px', color: '#28a745' }}>대시보드</h1>
      <p style={{ fontSize: '1.2em', textAlign: 'center', marginBottom: '40px' }}>
        환영합니다, <span style={{ fontWeight: 'bold', color: '#007bff' }}>{user}</span>님!
      </p>
      <div style={{ display: 'flex', justifyContent: 'center', gap: '20px' }}>
        <button
          onClick={() => alert('프로필 보기 클릭')}
          style={{ padding: '12px 25px', backgroundColor: '#6c757d', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer', fontSize: '1.1em' }}
        >
          프로필 보기
        </button>
        <button
          onClick={handleLogout}
          style={{ padding: '12px 25px', backgroundColor: '#dc3545', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer', fontSize: '1.1em' }}
        >
          로그아웃
        </button>
      </div>
    </div>
  );
}

Cypress 테스트 파일 (cypress/e2e/login.cy.ts)

cypress/e2e/login.cy.ts
describe('로그인 기능 E2E 테스트', () => {
  beforeEach(() => {
    // 각 테스트가 시작되기 전에 로그인 페이지로 이동합니다.
    cy.visit('/login');
  });

  it('올바른 자격 증명으로 로그인에 성공해야 합니다.', () => {
    // 1. 사용자 이름 입력 필드를 찾고 값을 입력합니다.
    cy.get('#username').type('testuser');

    // 2. 비밀번호 입력 필드를 찾고 값을 입력합니다.
    cy.get('#password').type('password123');

    // 3. 로그인 버튼을 찾고 클릭합니다.
    cy.get('button[type="submit"]').click();

    // 4. 로그인 성공 알림이 뜨고, 대시보드 페이지로 리다이렉트 되는 것을 확인합니다.
    // window.alert를 모킹하여 Cypress가 알림창을 처리하도록 합니다.
    cy.on('window:alert', (str) => {
      expect(str).to.equal('로그인 성공!');
    });

    // URL이 '/dashboard'로 변경되었는지 확인합니다.
    cy.url().should('include', '/dashboard');

    // 5. 대시보드 페이지에 "환영합니다, 테스트 사용자님!" 텍스트가 표시되는 것을 확인합니다.
    cy.contains('환영합니다, 테스트 사용자님!').should('be.visible');
  });

  it('잘못된 자격 증명으로 로그인에 실패해야 합니다.', () => {
    // 잘못된 사용자 이름과 비밀번호 입력
    cy.get('#username').type('wronguser');
    cy.get('#password').type('wrongpassword');

    // 로그인 버튼 클릭
    cy.get('button[type="submit"]').click();

    // 로그인 실패 알림이 뜨는 것을 확인합니다.
    cy.on('window:alert', (str) => {
      expect(str).to.equal('로그인 실패: 사용자 이름 또는 비밀번호가 올바르지 않습니다.');
    });

    // 여전히 로그인 페이지에 머물러 있는지 확인합니다.
    cy.url().should('include', '/login');
    cy.contains('로그인').should('be.visible');
  });

  it('로그인 후 로그아웃 기능이 작동해야 합니다.', () => {
    // 먼저 로그인에 성공합니다.
    cy.get('#username').type('testuser');
    cy.get('#password').type('password123');
    cy.get('button[type="submit"]').click();
    cy.url().should('include', '/dashboard');
    cy.contains('환영합니다, 테스트 사용자님!').should('be.visible');

    // 로그아웃 버튼을 클릭합니다.
    cy.get('button').contains('로그아웃').click();

    // 로그아웃 알림이 뜨고, 로그인 페이지로 리다이렉트 되는 것을 확인합니다.
    cy.on('window:alert', (str) => {
      expect(str).to.equal('로그아웃 되었습니다.');
    });
    cy.url().should('include', '/login');
    cy.contains('로그인').should('be.visible');
  });
});

테스트 실행

Next.js 개발 서버를 실행합니다. npm run dev

Cypress 테스트 러너를 엽니다. npm run cypress:open

Cypress UI에서 e2e 테스트를 선택하고, 방금 생성한 login.cy.ts 파일을 클릭하여 테스트를 실행합니다.

Cypress가 브라우저를 열고, 정의된 시나리오에 따라 자동으로 상호작용하며 테스트 결과를 보여줄 것입니다.


Cypress 활용 팁 및 고급 기능

  • 셀렉터 사용
    • Cypress는 cy.get()을 사용하여 DOM 요소를 선택합니다. data-testid 속성과 같이 테스트 전용 속성을 사용하는 것이 가장 안정적입니다.
    • cy.get('[data-testid="login-button"]').click();
    • 텍스트로 찾기: cy.contains('로그인')
  • 비동기 처리: Cypress 명령어는 기본적으로 비동기적으로 작동하며, 자동으로 재시도 로직을 포함합니다. cy.get().should('be.visible') 와 같이 어설션 체이닝을 통해 요소가 나타날 때까지 기다릴 수 있습니다.
  • 네트워크 요청 모킹 (cy.intercept) cy.intercept()를 사용하여 백엔드 API 요청을 가로채고 Mock 응답을 반환할 수 있습니다. 이는 테스트의 속도를 높이고, 외부 API의 영향을 받지 않는 안정적인 테스트를 가능하게 합니다.
    cy.intercept('POST', '/api/login', {
      statusCode: 200,
      body: { message: '로그인 성공', user: { name: 'testuser' } },
    }).as('loginRequest'); // 요청에 별칭 부여
    
    // ... 로그인 시도 ...
    
    cy.wait('@loginRequest').its('response.statusCode').should('eq', 200); // 요청 대기 및 검증
  • 커스텀 명령어: 자주 사용되는 일련의 동작(예: 로그인)을 cypress/support/commands.ts에 커스텀 명령어로 정의하여 재사용성을 높일 수 있습니다.
    cypress/support/commands.ts
    Cypress.Commands.add('login', (username, password) => {
      cy.visit('/login');
      cy.get('#username').type(username);
      cy.get('#password').type(password);
      cy.get('button[type="submit"]').click();
      cy.url().should('include', '/dashboard');
    });
    
    // 사용: cy.login('testuser', 'password123');
  • CI/CD 통합: cypress run 명령어를 사용하여 헤드리스 모드로 Cypress 테스트를 실행할 수 있습니다. 이는 CI/CD 파이프라인에 통합하여 코드 푸시 또는 배포 전에 자동으로 E2E 테스트를 실행하는 데 사용됩니다.
  • 리셋 상태: 각 테스트는 독립적으로 실행되어야 합니다. beforeEach 훅을 사용하여 테스트 환경(예: 로그인 상태, 로컬 스토리지)을 초기화하거나, 데이터베이스를 정리하는 등의 작업을 수행하는 것이 좋습니다.

E2E 테스트는 애플리케이션의 최종 품질을 보장하는 데 매우 중요합니다. Cypress는 Next.js 애플리케이션의 복잡한 사용자 흐름을 효과적으로 테스트할 수 있는 강력하고 직관적인 도구를 제공하여, 개발자가 자신감을 가지고 배포할 수 있도록 돕습니다.