E2E 테스트 구현
Cypress를 설정해 실제 브라우저에서 페이지 이동·입력·응답까지 사용자의 핵심 시나리오를 처음부터 검증합니다.
단위 테스트와 통합 테스트는 기능을 검증하는 데 중요하지만, 사용자가 실제 브라우저에서 겪는 흐름을 처음부터 끝까지 확인하기에는 한계가 있습니다. 이때 필요한 것이 E2E(End-to-End) 테스트입니다. E2E 테스트는 실제 운영 환경에 가까운 조건에서 애플리케이션이 최종적으로 기대한 대로 동작하는지 검증합니다. 이 글에서는 E2E 테스트의 개념과 중요성, Next.js 프로젝트에서 Cypress를 설정하고 쓰는 방법, 실제 사용자 시나리오를 바탕으로 테스트를 작성하는 방법을 다룹니다.
E2E 테스트란 무엇이며 왜 중요한가요?
E2E 테스트는 사용자의 관점에서 애플리케이션의 전체 흐름을 검증하는 방식입니다. 브라우저 실행, 로그인, 페이지 이동, 입력, 클릭 같은 상호작용을 자동화해 흉내 내며, 백엔드 API, DB, 네트워크까지 포함해 운영 환경과 비슷한 조건에서 시스템 전체의 동작을 확인합니다.
E2E 테스트는 사용자의 관점에서 핵심 업무 로직과 UI 상호작용이 올바르게 동작하는지 검증하고, 프론트엔드, 백엔드, 데이터베이스, 네트워크 같은 모든 구성 요소가 함께 잘 동작하는지 확인합니다. 프로덕션에 배포하기 전에 가장 치명적인 문제를 찾아 고칠 마지막 기회가 되며, 복잡한 사용자 여정을 테스트해 개발 팀과 이해 관계자가 애플리케이션을 더 믿을 수 있게 합니다. 새 기능이나 코드 변경이 기존 기능에 예상치 못한 부작용을 일으키는 회귀(regression)도 막아 줍니다.
Cypress란?
Cypress는 현대적인 웹 애플리케이션을 위한 E2E 테스트 프레임워크입니다. 브라우저에서 앱을 제어하고 명령 로그로 실패 지점을 확인할 수 있으며, 실행 속도와 안정성은 시나리오, 환경, 대기 조건에도 영향을 받습니다. 개발자 친화적인 API, 실시간 다시 로드, 디버깅 기능, 비디오 녹화 같은 여러 기능을 내장하고 있습니다.
Cypress는 명령 로그와 재시도할 수 있는 쿼리, 어설션으로 UI 변화를 따라갑니다. 개발자 도구에 직접 접근할 수 있어 테스트 실행 중의 문제를 쉽게 디버깅할 수 있고, 파일이 바뀌면 테스트가 자동으로 다시 실행되어 개발 흐름을 방해하지 않습니다. cypress run에서는 실패했을 때의 스크린숏을 남길 수 있고, video: true로 영상을 기록할 수 있으며 비디오의 기본값은 false입니다(Cypress 설정). jQuery와 비슷한 명령으로 DOM을 다루고 이벤트를 일으킬 수 있어 API도 익숙합니다.
Next.js 프로젝트에 Cypress 설정하기
Next.js 공식 문서에도 Cypress 설정 안내가 있으며, 순서는 Cypress 설치, 실행 스크립트 등록, 첫 실행으로 기본 파일 만들기, 설정 파일 다듬기입니다. Jest와 가장 크게 다른 점은 Cypress가 브라우저를 직접 띄워 앱에 접속한다는 것입니다. 그래서 테스트를 실행할 때 Next.js 서버가 따로 떠 있어야 합니다.
Cypress 설치
Cypress는 테스트할 때만 쓰는 도구이므로 프로젝트 루트에서 개발 의존성으로 설치합니다. 설치할 때 브라우저를 제어하는 Cypress 실행 파일도 함께 내려받으므로, 일반 패키지보다 시간이 조금 더 걸립니다. 지금의 Cypress 16은 Node.js 22, 24, 26 이상에서만 동작하므로, Node.js 20을 쓰고 있다면 먼저 Node.js를 올립니다.
npm install --save-dev cypress
# 또는
yarn add --dev cypresspackage.json에 스크립트 추가
package.json의 scripts에 Cypress 실행 명령 두 개를 추가합니다. cypress:open은 실행할 테스트를 고르고 진행 과정을 화면으로 지켜보는 대화형 앱을 엽니다. cypress:run은 브라우저 창을 띄우지 않고 모든 스펙을 실행해 결과만 터미널에 출력하므로, 사람이 지켜보지 않는 자동 검사에 맞습니다.
{
"name": "your-nextjs-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"test": "jest",
"test:watch": "jest --watch",
"cypress:open": "cypress open",
"cypress:run": "cypress run"
}
}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의 메인 설정 파일입니다.
Jest와 Cypress를 같은 프로젝트에서 쓸 때는 두 도구의 전역 타입을 나눕니다. 루트 tsconfig.json에서는 Cypress 파일을 빼고, Cypress 전용 설정은 별도 파일로 둡니다.
{
"exclude": ["node_modules", "cypress", "cypress.config.ts"]
}루트 설정에서 뺀 Cypress 파일은 Cypress 폴더 안의 전용 tsconfig.json이 검사합니다. types에 cypress를 넣어야 cy 같은 전역 객체의 타입을 인식하고, noEmit은 타입만 검사하고 JavaScript 파일은 만들지 않게 합니다. include에 루트의 cypress.config.ts까지 넣어 설정 파일도 같은 규칙으로 검사합니다.
{
"compilerOptions": {
"types": ["cypress", "node"],
"noEmit": true
},
"include": ["**/*.ts", "../cypress.config.ts"]
}cypress.config.ts의 예시는 다음과 같습니다. e2e 항목은 브라우저로 페이지를 여는 E2E 테스트의 설정이고, component 항목은 다음 글에서 다룰 컴포넌트 테스트의 설정입니다.
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}',
},
});baseUrl을 정해 두면 테스트에서 cy.visit('/login')처럼 경로만 써도 http://localhost:3000/login에 접속합니다. specPattern은 cypress/e2e 아래에서 .cy.ts 같은 이름의 파일만 테스트로 찾게 합니다. setupNodeEvents는 브라우저가 아니라 Cypress를 실행하는 Node 프로세스에서 동작하므로, 파일 읽기나 환경 변수 처리처럼 브라우저에서 할 수 없는 일을 연결하는 곳입니다. 주석에는 setup 파일이라고 적혀 있지만, 각 스펙 파일보다 먼저 실행되는 코드는 앞에서 본 cypress/support/e2e.ts입니다. video: true는 기본값 false를 바꿔 cypress run에서 영상을 남기게 합니다.
Next.js 개발 서버 실행
Cypress는 앱을 직접 띄우지 않고, 이미 실행 중인 서버에 브라우저로 접속해 테스트합니다. 그래서 테스트를 실행하기 전에 baseUrl에 적은 주소에서 Next.js 개발 서버가 떠 있어야 합니다.
npm run dev
# 또는
yarn dev서버를 띄운 터미널은 그대로 두고, 별도의 터미널에서 Cypress 테스트를 실행합니다. 서버가 꺼져 있으면 cy.visit이 접속하지 못해 테스트가 시작하자마자 실패합니다.
더 깊이: E2E 테스트를 프로덕션 빌드에서 돌리는 이유
Next.js의 Cypress 안내는 실제 애플리케이션의 동작에 더 가깝도록 E2E 테스트를 프로덕션 코드에서 돌리라고 권합니다. 즉 npm run build && npm run start로 프로덕션 서버를 띄운 뒤 다른 터미널에서 Cypress를 실행하는 방식입니다. 개발 서버에는 프로덕션과 다르게 동작하는 부분이 있습니다. <Link>의 자동 프리페치는 프로덕션에서만 실행되고, React Strict Mode의 추가 렌더링과 Effect 재실행은 개발 모드에서만 일어나며, 서버 컴포넌트의 fetch 응답은 HMR 사이에 다시 쓰일 수 있고, CSS의 순서도 개발 중에는 달라질 수 있습니다. 그래서 개발 서버에서 통과한 테스트가 배포한 결과의 동작을 그대로 보여 주지는 않습니다.
서버를 띄우고 테스트를 실행하는 두 단계를 하나의 명령으로 묶으려면 start-server-and-test 패키지를 쓸 수 있습니다. 예를 들어 package.json에 "e2e": "start-server-and-test start http://localhost:3000 cypress:run" 스크립트를 더하면, 프로덕션 서버가 그 주소에서 응답할 때까지 기다린 뒤 앞에서 등록한 cypress:run 스크립트로 Cypress를 실행합니다. 마지막 인자는 npm 스크립트 이름이나 실행할 명령이므로, 그런 이름의 스크립트가 없는 cypress 한 단어만 적으면 하위 명령 없는 Cypress CLI가 도움말만 출력하고 끝납니다. 이때 start는 이미 만든 빌드를 실행할 뿐이므로, 코드를 고친 뒤에는 다시 빌드해야 바뀐 내용이 테스트에 반영됩니다. 같은 안내는 Cypress의 컴포넌트 테스트가 async 서버 컴포넌트를 지원하지 않는다는 점도 밝히므로, 그런 화면은 이 글처럼 E2E로 확인합니다.
기본적인 E2E 테스트 작성하기
다음은 문자열 비교와 화면 이동을 연습하는 가상의 로그인 UI입니다. 서버 인증, 쿠키, 세션은 없고 대시보드의 이름도 고정된 문구이므로, 실제 인증의 성공, 만료, 접근 차단에는 그에 맞는 구현과 별도의 테스트가 필요합니다. 이 두 화면만 쓰는 독립 실습으로 실행합니다. 10장 3절에서 Auth.js Proxy로 /dashboard를 보호한 애플리케이션과 나누어야, 가상 로그인 뒤의 이동과 직접 방문을 같은 조건에서 확인할 수 있습니다.
시나리오는 다음과 같습니다. 사용자는 로그인 페이지로 이동해 사용자 이름과 비밀번호를 입력하고 로그인 버튼을 누릅니다. 로그인에 성공하면 대시보드 페이지로 이동하고, 대시보드에 사용자 이름이 표시되어야 합니다.
먼저 가상의 로그인 페이지를 준비합니다. 테스트가 기대는 부분은 세 가지입니다. 입력란의 id(username, password)는 Cypress가 요소를 찾는 손잡이가 되고, testuser와 password123의 조합만 성공으로 처리하며, 결과를 alert로 알린 뒤 성공했을 때만 /dashboard로 이동합니다.
"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>
);
}로그인한 뒤 이동할 가상의 대시보드 페이지도 준비합니다. 이 페이지는 로그인 여부를 실제로 확인하지 않고(useEffect 안의 검사는 주석 처리되어 있습니다), 환영 문구에 고정된 이름인 테스트 사용자를 보여 줍니다. 그래서 테스트는 이 문구가 보이는지로 대시보드에 도착했는지를 판단하고, 로그아웃 버튼은 알림을 띄운 뒤 /login으로 돌아갑니다.
"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를 작성합니다. beforeEach는 테스트마다 먼저 실행되어 window.alert를 기록용 stub(@alerts)으로 바꾸고 로그인 페이지를 새로 엽니다. 그래서 세 테스트는 서로의 입력이나 알림 기록에 영향을 받지 않으며, 각 it 안에서는 입력, 클릭, 확인을 사용자가 하는 순서대로 적습니다.
describe('로그인 기능 E2E 테스트', () => {
beforeEach(() => {
// 각 테스트가 시작되기 전에 로그인 페이지로 이동합니다.
cy.on('window:alert', cy.stub().as('alerts'));
cy.visit('/login');
});
it('올바른 자격 증명으로 로그인에 성공해야 합니다.', () => {
// 1. 사용자 이름 입력 필드를 찾고 값을 입력합니다.
cy.get('#username').type('testuser');
// 2. 비밀번호 입력 필드를 찾고 값을 입력합니다.
cy.get('#password').type('password123');
// 3. 로그인 버튼을 찾고 클릭합니다.
cy.get('button[type="submit"]').click();
// 4. 로그인 성공 알림이 뜨고, 대시보드 페이지로 리다이렉트 되는 것을 확인합니다.
// Cypress가 알림을 자동 수락하고 stub이 메시지를 기록합니다.
cy.get('@alerts').should('have.been.calledOnceWithExactly', '로그인 성공!');
// 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.get('@alerts').should('have.been.calledOnceWithExactly', '로그인 실패: 사용자 이름 또는 비밀번호가 올바르지 않습니다.');
// 여전히 로그인 페이지에 머물러 있는지 확인합니다.
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('@alerts').should('have.been.calledOnceWithExactly', '로그인 성공!');
// 로그아웃 버튼을 클릭합니다.
cy.get('button').contains('로그아웃').click();
// 로그아웃 알림이 뜨고, 로그인 페이지로 리다이렉트 되는 것을 확인합니다.
cy.get('@alerts').should('have.been.calledTwice')
.and('have.been.calledWithExactly', '로그아웃 되었습니다.');
cy.url().should('include', '/login');
cy.contains('로그인').should('be.visible');
});
});cy.on은 핸들러를 곧바로 등록하고, cy.get과 click은 명령 큐에서 차례로 실행됩니다. 그래서 로그아웃 테스트의 끝에 cy.on('window:alert', ...)로 로그아웃 알림만 확인하는 핸들러를 따로 적으면, 그 핸들러는 큐의 명령보다 먼저 등록되어 앞선 로그인 알림까지 받게 됩니다. 위 코드는 이런 혼동을 피하려고 beforeEach에서 모든 알림을 하나의 stub으로 기록한 뒤 단계마다 횟수와 메시지를 확인합니다. 자세한 동작은 Cypress 이벤트와 stub 문서에서 확인할 수 있습니다.
NEXT · 14.3
세 로그인 시나리오는 문자열 비교와 화면 이동을 검증합니다
본문 가상 UI는 입력 문자열을 비교해 alert와 router.push를 실행합니다. 대시보드 이름은 고정 문구이며 서버 인증·쿠키·세션을 구현한 실습이 아닙니다.
| 시나리오 | 예제 코드에서 확인할 범위 |
|---|---|
| 성공 | testuser / password123 → 성공 알림 → /dashboard와 고정 환영 문구 |
| 실패 | 잘못된 입력 → 실패 알림 → /login 유지 |
| 로그아웃 | 로그인 후 버튼 클릭 → 두 번째 알림이 로그아웃 메시지인지 확인 → /login |
- 성공
- 예제 코드에서 확인할 범위: testuser / password123 → 성공 알림 → /dashboard와 고정 환영 문구
- 실패
- 예제 코드에서 확인할 범위: 잘못된 입력 → 실패 알림 → /login 유지
- 로그아웃
- 예제 코드에서 확인할 범위: 로그인 후 버튼 클릭 → 두 번째 알림이 로그아웃 메시지인지 확인 → /login
각 시나리오는 /login에서 새로 시작합니다. 성공 알림은 첫 호출, 로그인 후 로그아웃 알림은 두 번째 호출로 확인합니다. 아래의 cy.intercept 예시는 POST /api/login을 실제 호출하는 별도 구현용이며 이 가상 UI의 요청은 아닙니다.
위 표는 가상 로그인 UI의 세 시나리오가 무엇을 검증하는지 정리합니다. 성공 시나리오는 testuser와 password123을 입력해 성공 알림이 뜨고 /dashboard로 이동해 고정된 환영 문구가 보이는지, 실패 시나리오는 잘못된 입력에 실패 알림이 뜨고 /login에 머무는지 확인합니다. 로그아웃 시나리오는 로그인한 뒤 버튼을 눌러 두 번째 알림이 로그아웃 메시지인지, /login으로 돌아오는지 확인합니다. 표의 아래 설명처럼 각 시나리오는 /login에서 새로 시작하며, 성공 알림은 첫 호출로, 로그인 뒤의 로그아웃 알림은 두 번째 호출로 확인합니다. 이 화면은 입력 문자열을 비교해 alert와 router.push를 실행할 뿐, 서버 인증이나 쿠키, 세션을 구현한 실습이 아닙니다.
테스트를 실행하려면 먼저 npm run dev로 Next.js 개발 서버를 실행하고, npm run cypress:open으로 Cypress 테스트 러너를 엽니다. 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()로 백엔드 API 요청을 가로채 모의 응답을 돌려줄 수도 있습니다. 다음은 실제로 POST /api/login을 호출하는 로그인 구현을 위한 별도 예시입니다. 앞의 가상 로그인 페이지에는 그런 요청이 없으므로 이 intercept를 쓰지 않습니다.
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에 사용자 정의 명령으로 정의해 다시 쓸 수 있습니다. 아래 코드에서 declare global 부분은 TypeScript가 cy.login을 알 수 있도록 Chainable 타입에 새 명령을 더하는 선언이고, Cypress.Commands.add가 실제 동작을 등록합니다.
declare global {
namespace Cypress {
interface Chainable {
login(username: string, password: string): Chainable<void>;
}
}
}
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');
export {};등록한 뒤에는 테스트에서 cy.login('testuser', 'password123') 한 줄로 로그인 페이지 방문부터 대시보드 도착 확인까지 실행할 수 있습니다. 이 파일은 cypress/support/e2e.ts가 불러와야 모든 스펙에 적용되며, 맨 끝의 export {}는 파일을 모듈로 만들어 declare global 선언이 동작하게 합니다.
cypress run 명령을 쓰면 Cypress 테스트를 헤드리스(headless) 모드로 실행할 수 있어, CI/CD 파이프라인에 넣어 코드를 푸시하거나 배포하기 전에 E2E 테스트를 자동으로 실행하는 데 씁니다. 각 테스트는 서로 독립적으로 실행되어야 하므로, beforeEach 훅에서 로그인 상태나 로컬 저장소 같은 테스트 환경을 초기화하거나 데이터베이스를 정리하는 작업을 하는 것이 좋습니다.
로컬에서는 cypress open으로 명령과 UI를 살펴보고, CI에서는 앱이 준비되었는지 확인한 뒤 cypress run으로 실행합니다. 계정과 저장 상태를 격리하고, 실패한 스크린숏과 로그를 보존합니다. 대역을 썼다면 실제로 연결하지 않은 서버나 인증의 범위도 결과에 남깁니다.
정리
- E2E 테스트는 실제 브라우저에서 사용자의 흐름 전체를 자동으로 따라가며, 운영 환경에 가까운 조건에서 시스템 전체의 동작을 검증합니다.
- Cypress는
cypress open으로 대화형 실행을,cypress run으로 헤드리스 실행을 하며, 테스트 전에 Next.js 개발 서버가 실행 중이어야 합니다. - Jest와 함께 쓸 때는 루트
tsconfig.json에서 Cypress 파일을 빼고 Cypress 전용tsconfig.json을 두어 전역 타입을 나눕니다. - 브라우저 알림은
cy.on('window:alert', cy.stub())으로 기록해 횟수와 메시지를 단계별로 검증합니다. cy.intercept로 준비한 응답을 쓰는 검사는 실제 서버의 인증과 인가 검사와 구별해 결과에 적습니다.
연습 문제
1. 검증 범위를 바꿔 보기
이 가상 UI에서 로그인 절차 없이 /dashboard를 직접 열면 어떤 이름이 보일까요? testuser에 잘못된 비밀번호를 넣으면 알림은 몇 번 뜨고 화면은 이동할까요? 세 Cypress 테스트가 통과하면 서버의 관리 API 접근 권한까지 검증되었다고 할 수 있을까요?
해설 보기
대시보드의 초기 이름은 고정된 "테스트 사용자"이고, Effect의 접근 차단 코드는 주석입니다. 따라서 직접 방문하면 그대로 "환영합니다, 테스트 사용자님!"이 보이며 이를 막는 구현은 없습니다. 로그인 함수는 두 문자열이 모두 맞아야 성공하므로, 잘못된 비밀번호에는 실패 알림이 한 번만 뜨고 router.push는 호출되지 않습니다.
세 시나리오는 이 화면의 입력, 알림, 이동만 확인합니다. 서버 세션이나 관리 API를 검사하려면 실제 인증 구현을 연결하고, 로그인하지 않은 요청, 만료된 세션, 권한 없는 사용자에 대한 기대값을 따로 세워야 합니다. cy.intercept로 준비한 응답을 쓰는 검사와 실제 서버의 인가 검사도 구별하세요.
2. 알림 stub을 테스트마다 새로 만드는 이유
cy.on('window:alert', cy.stub().as('alerts'))를 beforeEach가 아니라 파일 맨 위에서 한 번만 등록하면, 실패 시나리오의 calledOnceWithExactly 검사는 어떻게 될까요?
해설 보기
Cypress는 각 테스트가 끝나면 cy.on으로 등록한 이벤트 리스너와 별칭을 정리하므로, 파일 맨 위에서 한 번만 등록하면 두 번째 테스트부터는 알림을 기록하는 stub과 @alerts 별칭이 없어 검사가 실패합니다. beforeEach에서 테스트마다 새 stub을 등록하면 각 테스트가 자신의 알림만 세므로, 앞 테스트의 호출이 섞이지 않습니다.
3. 글자로 버튼 찾기의 함정
실패 시나리오의 마지막 줄 cy.contains('로그인').should('be.visible')은 무엇을 확인하나요? 이 검사만으로 로그인 페이지에 머물렀다고 판단하기에는 어떤 약점이 있을까요?
해설 보기
화면 어딘가에 "로그인"이라는 글자가 보이는지를 확인합니다. 그런데 로그인 페이지에는 제목과 버튼에 같은 글자가 있고, 다른 페이지에도 "로그인"이라는 글자가 있을 수 있으므로 이 검사만으로는 어느 요소를 찾았는지 분명하지 않습니다. 앞의 cy.url().should('include', '/login')과 함께 확인하거나, cy.contains('h1', '로그인')처럼 요소를 좁히거나, data-testid를 붙여 찾으면 의도가 분명해집니다.
Cypress를 Next.js에 설정하는 공식 절차는 Next.js의 Cypress 설정 안내에서 확인할 수 있습니다.
다음 글 컴포넌트 테스트에서는 컴포넌트 하나를 실제 브라우저에서 따로 렌더링해 검사하는 컴포넌트 테스트를 알아봅니다.