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
// 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 예시

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 테스트를 작성해 보겠습니다.

시나리오

  1. 로그인 페이지로 이동한다.
  2. 사용자 이름과 비밀번호를 입력한다.
  3. 로그인 버튼을 클릭한다.
  4. 로그인 성공 시 대시보드 페이지로 리다이렉트 되는 것을 확인한다.
  5. 대시보드 페이지에 사용자 이름이 표시되는 것을 확인한다.

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

app/login/page.tsx
// 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
// 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
// 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');
  });
});

테스트 실행

  1. Next.js 개발 서버를 실행합니다: npm run dev
  2. Cypress 테스트 러너를 엽니다: npm run cypress:open
  3. 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/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 애플리케이션의 복잡한 사용자 흐름을 효과적으로 테스트할 수 있는 강력하고 직관적인 도구를 제공하여, 개발자가 자신감을 가지고 배포할 수 있도록 돕습니다.