E2E 테스트
Cypress와 TypeScript로 실제 사용자 흐름을 브라우저에서 검증하고 선택자·대기·네트워크 가로채기를 안정적으로 구성합니다.
이전 절에서는 함수나 클래스 같은 개별 단위를 검증하는 단위 테스트(Unit Test)를 다뤘습니다.
단위 테스트는 작은 범위 검증에 강하지만, 여러 단위가 결합된 실제 사용자 흐름을 확인하기에는 한계가 있습니다.
이때 필요한 것이 E2E (End-to-End) 테스트입니다.
E2E 테스트는 실제 사용자 관점에서 애플리케이션 전체 흐름을 시뮬레이션합니다.
프론트엔드, 백엔드, DB, 네트워크까지 통합된 상태에서 예상대로 동작하는지 검증하므로, 운영 환경에서 나타날 수 있는 문제를 조기에 발견하는 데 도움이 됩니다. 다만 이 절의 로그인은 클라이언트 상수 비교이고 API 예제는 응답 대역을 사용하므로 실제 인증 서버나 DB의 통합을 검증하지 않습니다.
이 절에서는 인기 있는 E2E 테스트 도구인 Cypress를 사용하여 React 및 타입스크립트 애플리케이션에서 E2E 테스트를 작성하는 방법에 대해 자세히 알아보겠습니다.
E2E 테스트의 중요성
E2E 테스트는 다음과 같은 강력한 이점을 제공합니다.
- 실제 사용자 경험 검증: 사용자가 겪을 수 있는 실제 시나리오를 모방하여, 기능뿐만 아니라 통합적인 사용자 경험까지 검증합니다.
- 시스템 통합성 확인: 프론트엔드와 백엔드, 데이터베이스, 외부 API 등 시스템의 모든 부분이 원활하게 연동되는지 확인합니다.
- 배포 전 최종 검증: 배포 전에 선정한 사용자 여정의 회귀를 확인합니다. 통과한 시나리오 밖의 완전성을 보장하지는 않습니다.
- 비즈니스 로직 검증: 복잡한 비즈니스 규칙이 여러 시스템을 거쳐 올바르게 구현되었는지 검증합니다.
- 회귀(Regression) 방지: 코드 변경이나 새로운 기능 추가로 인해 기존 기능이 오작동하는 회귀 버그를 조기에 발견합니다.
단위 테스트와 E2E 테스트는 상호 보완적입니다.
단위 테스트로 작은 버그를 빠르게 잡고, E2E 테스트로 전체 시스템의 안정성을 확보하는 것이 이상적인 테스트 전략입니다.
TEST SCOPE PYRAMID
빠른 계약을 넓게, 비싼 사용자 여정을 좁게 둔다
폭은 테스트 수·비용 배분의 정성적 비유이며 고정 비율이 아닙니다. 빠른 단위 계약을 넓게 두고 실제 경계를 통합·계약 테스트로 확인하며, 비싼 E2E는 중요한 사용자 여정을 고릅니다. 대역 응답은 실제 backend·DB 통합을 검증하지 않습니다.
- 기반 · 단위 테스트
작은 공개 동작·경계·오류를 빠르게 넓게 검사합니다.
- 중간 · 통합·계약
실제 연결된 component와 wire 계약의 일치를 확인합니다.
- 상단 · E2E
중요한 사용자 여정을 선택해 전체 연결 범위를 검증합니다.
Cypress 소개 및 설정
Cypress는 프론트엔드 웹 애플리케이션을 위한 차세대 E2E 테스트 프레임워크입니다.
Selenium과 같은 기존 도구와 달리, Cypress는 브라우저 내부에서 직접 실행되므로 더 빠르고 안정적인 테스트를 제공합니다.
Cypress의 주요 특징- 빠른 실행: 브라우저와 동일한 실행 루프에서 동작하므로 테스트 실행이 매우 빠릅니다.
- 실시간 리로딩: 코드 변경 시 테스트가 자동으로 다시 실행되어 개발 피드백이 빠릅니다.
- 자동 대기: 비동기 요소(네트워크 요청, DOM 업데이트)에 대한 스마트한 자동 대기 기능을 제공하여 테스트의 안정성을 높입니다.
- 디버깅 용이성: 테스트 실행 중 스냅샷, 동영상 녹화, DOM 스냅샷 등 강력한 디버깅 도구를 제공합니다.
- 쉬운 설정: 설정이 간편하며, 타입스크립트를 기본적으로 잘 지원합니다.
Cypress + TS E2E 환경 설정
새로운 React/Node.js 프로젝트에서 Cypress와 타입스크립트를 사용하기 위한 설정 방법은 다음과 같습니다.
Cypress 설치: 프로젝트 루트에서 Cypress를 개발 의존성으로 설치합니다.
npm install --save-dev cypressCypress 초기화 및 구성 파일 생성:
다음 명령어를 실행하면 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의 설정 파일입니다.
타입스크립트 기반으로 작성됩니다.
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, // cypress run에서 지원 브라우저의 실행 영상을 녹화
screenshotOnRunFailure: true, // 테스트 실패 시 스크린샷 저장
},
});tsconfig.json 설정:
Cypress 테스트 파일에서도 타입스크립트를 사용하고, Cypress 전역 객체에 대한 타입 지원을 받기 위해 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를 실행하기 위한 스크립트를 추가합니다.
{
"scripts": {
"test:e2e": "cypress open",
"test:e2e:headless": "cypress run"
}
}기존 package.json의 scripts 객체에 위 두 키를 병합하고, 기존 dev, build, preview, test 스크립트는 유지합니다.
test:e2e는 Cypress GUI를 열고, test:e2e:headless는 CLI에서 헤드리스 모드로 실행합니다.
E2E 테스트를 실행하기 전에 애플리케이션이 실행 중이어야 합니다.
예를 들어, Vite 기반 React 앱이라면 npm run dev로 앱을 먼저 실행한 후 npm run test:e2e를 실행합니다.
기본 E2E 테스트 시나리오 작성하기
이제 간단한 사용자 로그인 시나리오를 테스트하는 E2E 테스트를 작성해봅시다.
테스트 대상 React 컴포넌트 (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;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;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');
});
});먼저 프로젝트의 개발 서버를 실행합니다(앞의 Vite 예제는 npm run dev). baseUrl의 3000 포트를 실제 서버 포트에 맞춥니다.
새로운 터미널에서 Cypress 테스트 러너를 실행합니다 (npm run test:e2e).
Cypress GUI에서 login.cy.ts 파일을 클릭하면 브라우저가 열리고 테스트가 실행되는 것을 실시간으로 볼 수 있습니다.
Cypress의 주요 명령어 및 기능
Cypress는 cy 객체를 통해 다양한 명령어를 제공합니다.
cy.visit(url): 특정 URL로 이동합니다.cy.get(selector): CSS 셀렉터를 사용하여 DOM 요소를 선택합니다.cy.get(...).find(selector): 이전에 선택된 요소의 자식 요소를 찾습니다.cy.contains(text): 특정 텍스트를 포함하는 요소를 찾습니다.cy.get(...).type(text): 입력 필드에 텍스트를 입력합니다.cy.get(...).click(): 요소를 클릭합니다.cy.get(...).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하면 테스트의 속도와 안정성을 높일 수 있습니다.
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: '[email protected]' },
{ id: 2, name: 'Mock User 2', email: '[email protected]' },
],
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('[email protected]').should('be.visible');
cy.contains('Mock User 2').should('be.visible');
cy.contains('[email protected]').should('be.visible');
// 로딩 메시지가 사라졌는지 확인
cy.contains('Loading users...').should('not.exist');
});
});cy.intercept()는 등록한 matcher에 맞는 브라우저 HTTP 요청을 관찰하거나 대체합니다. 브라우저 캐시에서 충족된 응답이나 cy.request()까지 모두 가로채는 것은 아닙니다. 위 /users 페이지와 /api/users 호출은 별도 구현이 필요하며 앞의 로그인 앱에는 없습니다.
이를 통해 테스트 중인 애플리케이션의 네트워크 종속성을 제거하고, 특정 시나리오(예: 네트워크 에러, 빈 응답)를 쉽게 시뮬레이션할 수 있습니다.
타입스크립트와 Cypress의 시너지
Cypress는 타입스크립트를 기본적으로 지원하며, 이는 다음과 같은 이점을 제공합니다.
- 강력한 타입 검사:
cy명령어의 인자, 반환값, 커스텀 명령어 등에 대한 타입 검사가 이루어져 잘못된 사용을 방지합니다. - 자동 완성 및 가이드: IDE에서 Cypress 명령어에 대한 자동 완성 및 JSDoc 기반 설명을 제공하여 테스트 코드 작성 속도를 높이고 오류를 줄입니다.
- 코드 가독성 향상: 명시적인 타입 정의는 테스트 코드의 의도를 명확하게 하고 유지보수를 용이하게 합니다.
- 유지보수성: 애플리케이션의 타입 정의가 변경될 때 테스트 코드에서도 관련 타입 오류를 즉시 감지할 수 있어, 회귀 테스트의 효율성을 높입니다.
E2E 테스트 요약
E2E 테스트는 애플리케이션의 사용자 경험과 시스템 통합성을 검증하는 데 필수적인 테스트 유형입니다.
Cypress는 빠르고 안정적이며 디버깅이 쉬운 E2E 테스트 프레임워크로, 현대 웹 애플리케이션 개발에 매우 잘 맞습니다.
타입스크립트와 함께 Cypress를 사용하면 테스트 코드 자체의 품질과 신뢰도를 높이고, 개발 과정에서 발생할 잠재적 문제를 더 빠르게 발견해 견고한 애플리케이션을 배포할 수 있습니다.
단위 테스트, 통합 테스트, E2E 테스트를 적절히 조합하는 것은 소프트웨어 개발 생명주기 전반에 걸쳐 코드의 신뢰성과 안정성을 극대화하는 핵심 전략입니다.
아래 다이어그램은 Cypress E2E 테스트를 안정적으로 만들기 위해 사용자 흐름, 데이터 준비, 네트워크 제어, 실패 증거를 함께 설계하는 기준을 정리한 것입니다.
REPRODUCIBLE E2E
E2E 신뢰도는 환경과 데이터의 반복 가능성에서 나온다
재현 기준입니다. 현재 로그인은 클라이언트 상수 비교이고 users 응답은 intercept 대역입니다. 실제 인증·DB 통합이나 seed/reset·CI는 구현되어 있지 않습니다. 서버 준비·테스트 데이터·요청 범위·실패 증거를 정해 반복합니다.
- Known env
같은 build·설정으로 앱을 먼저 실행하고 readiness를 확인합니다.
- Seed
자신이 소유한 테스트 데이터를 준비합니다. 대역 응답과 실제 DB seed는 구분합니다.
- User journey
핵심 사용자 여정에서 보이는 결과를 검사합니다. matcher를 등록한 뒤 요청을 발생시킵니다.
- Evidence
alias wait·retryable assertion으로 기다리고 지원하는 실패 화면·로그를 남깁니다. 임의 sleep으로 성공을 추정하지 않습니다.
- Diagnosis
최초 불일치를 찾습니다. 캐시 응답과 cy.request는 intercept 대상이 아닙니다.
- Cleanup
정리·reset 후 같은 조건으로 다시 실행합니다. 성공은 선택한 여정과 실제 연결 범위만 검증합니다.
- 핵심 경계
- 진행·선택 경로
그림의 seed·정리·실패 증거 루프는 환경을 반복 가능하게 만드는 설계 기준입니다. 원문의 클라이언트 예제가 실제 DB seed/reset이나 CI를 구현한 것으로 해석하지 않습니다.
로그인 상태와 네트워크 증거를 구분하기
본문의 로그인 앱은 클라이언트 문자열 비교와 React state로만 동작합니다. 성공 콜백이 실행되면 환영 화면이 나오지만 서버 세션이나 저장소에 로그인 정보를 남기지는 않습니다. 따라서 전체 페이지를 새로 읽으면 loggedInUser는 null로 다시 시작합니다. 기존 로그인 spec에 다음 테스트를 추가할 수 있습니다.
it('returns to the login form after a full reload', () => {
cy.visit('/');
cy.get('#username').type('testuser');
cy.get('#password').type('password123');
cy.get('form').submit();
cy.contains('h1', 'Welcome, testuser!').should('be.visible');
cy.reload();
cy.get('#username').should('be.visible');
cy.get('h1').should('not.exist');
});이 기대값은 현재 예제의 메모리 상태 수명에 맞춘 것입니다. 실제 인증을 확장한다면 서버 세션·쿠키·만료·로그아웃 정책에 맞춰 기대값을 다시 정해야 합니다.
API 목록 spec의 /users 화면은 별도로 구현한다는 전제이며 현재 Login/App 코드에 그 화면과 /api/users 호출이 들어 있는 것은 아닙니다. cy.intercept는 요청이 발생하기 전에 등록하고, alias 대기로 요청 완료를 확인한 뒤 화면 결과를 검사합니다. delay: 500은 응답 지연을 줄 뿐 테스트가 반드시 짧은 로딩 화면을 관측한다는 보장은 아닙니다. 로딩 상태를 확실히 검사해야 한다면 응답을 통제해 assertion 이후에 풀어 주는 설계가 필요합니다. Cypress 요청 모킹.
cy.get(...).should(...)의 query와 assertion에는 재시도가 있지만 .then 콜백 자체에는 재시도가 없습니다. 완료 조건에는 화면 assertion이나 요청 alias를 쓰고 고정 시간 대기를 화면 준비 완료 증거로 삼지 않습니다. Cypress 재시도, then 콜백.
E2E 증거 경계 연습 문제
- 올바른 예제 계정으로 로그인한 직후와 전체 새로고침 후의 화면을 예측하세요. 잘못된 비밀번호 제출로 서버 인증 실패까지 검증했다고 말할 수 있나요?
풀이
정상 제출은 onLogin을 호출해 App state가 사용자 이름이 되므로 환영 화면을 표시합니다. 전체 새로고침은 state를 null로 초기화해 로그인 입력을 다시 표시합니다. 잘못된 입력은 Login 안에서 오류 문자열을 설정하고 onLogin을 호출하지 않습니다. 서버 요청·세션 확인이 없는 코드이므로 클라이언트 예제 분기를 확인한 것입니다. 실제 인증 실패와 세션 수명은 서버 통신을 포함한 별도 테스트가 필요합니다.
- 먼저
/users로 이동한 후 intercept를 등록하고cy.wait('@getUsers')를 호출했습니다. 이미 끝난 요청도 기다릴 수 있나요? 정상 순서의 테스트 통과는 무엇까지 확인하나요?
풀이
등록 전에 완료된 요청을 새 intercept가 소급해 잡는다고 기대할 수 없습니다. intercept/alias를 먼저 등록한 다음 visit하여 요청이 발생하게 해야 합니다. 모킹 테스트는 지정한 응답을 받은 화면의 로딩/데이터 표시를 확인합니다. 실제 DB 데이터, 서버 권한 검사나 원격 API 가용성까지 검증하지 않습니다. 브라우저 캐시로 요청이 생기지 않는 경우와 cy.request처럼 intercept 대상이 다른 경우도 구분합니다.
- 로딩 메시지가 이미 사라졌다면 timeout을 늘리는 것만으로 과거 표시 상태를 검증할 수 있나요?
.then과.should도 비교하세요.
풀이
timeout 증가는 아직 충족될 수 있는 조건을 기다릴 시간을 늘릴 뿐 사라진 과거 상태를 복원하지 않습니다. should의 재시도도 미래의 현재 화면을 다시 검사합니다. then 콜백은 한 번 실행되므로 일반적인 조건 대기용 assertion과 구분합니다. 로딩 표시가 계약이면 응답 완료를 테스트가 통제하여 로딩 assertion을 먼저 수행하고, 응답을 풀고 alias/최종 화면을 검사하도록 구성합니다. delay 하나는 이 순서를 강제하지 않습니다.