Cypress E2E 테스트 운영: 안정적인 셀렉터, 로그인 세션 재사용, 아키텍처와 커버리지
이 글의 핵심
깨지지 않는 셀렉터 고르기, API 모킹, 로그인 상태를 테스트마다 반복하지 않는 방법, CI 통합, Cypress가 브라우저 안에서 동작하는 구조와 커버리지 수집을 다룹니다.
이 글의 핵심
Cypress로 E2E 테스트를 구축하는 글입니다. 설치부터 테스트 작성, 셀렉터, API 모킹, 로그인 세션 재사용, CI/CD 통합까지 예제로 정리하고, 테스트가 간헐적으로 실패하는(flaky) 원인을 곳곳에서 짚었습니다.
실무에서 마주치는 문제들
배포 전 수동 회귀 확인이 점점 길어져요
기능이 늘수록 “로그인 → 주문 → 결제” 같은 핵심 흐름을 손으로 다시 눌러 보는 시간이 늘고, 바쁠 때는 건너뛰게 됩니다. 자동화한 E2E 테스트는 매 PR마다 같은 흐름을 같은 방식으로 확인해 주므로 “이번엔 확인 안 했다”가 사라집니다.
여러 브라우저에서 확인해야 해요
Cypress는 Chrome 계열(Chrome, Edge, Electron)과 Firefox를 지원하고, Safari 엔진(WebKit)은 실험적 기능으로만 지원합니다. Safari 호환성이 중요한 서비스라면 WebKit을 정식 지원하는 Playwright를 함께 검토해야 합니다.
테스트가 가끔만 실패해요
E2E 테스트가 신뢰를 잃는 가장 큰 이유는 느려서가 아니라 코드와 무관하게 가끔 실패하기 때문입니다. 이 글의 셀렉터·대기·세션 처리 방식은 대부분 이 문제를 줄이기 위한 것입니다.
Cypress란?
핵심 특징
Cypress는 현대적인 E2E 테스팅 프레임워크입니다. 주요 장점:
- 자동 대기와 재시도:
cy.get()과 이어지는.should()는 기본 4초(defaultCommandTimeout) 동안 조건이 맞을 때까지 반복 확인합니다 - Time Travel: 테스트 러너에서 명령마다 당시 DOM 스냅샷을 되돌려 볼 수 있습니다
- 실시간 리로드: 스펙 파일을 저장하면 즉시 다시 실행됩니다
- 스크린샷/비디오: 실패 시 스크린샷이 자동 저장되고, 비디오는 설정으로 켭니다
Selenium 같은 WebDriver 도구는 테스트 코드가 별도 프로세스에서 HTTP 프로토콜로 브라우저에 명령을 보냅니다. Cypress는 테스트 코드 자체를 앱과 같은 브라우저 안에서 실행하므로 DOM에 직접 접근하고, 네트워크 요청도 Cypress가 띄운 프록시를 거치게 해서 가로챌 수 있습니다. 이 구조가 자동 대기와 cy.intercept를 가능하게 하는 반면, 여러 탭이나 여러 브라우저 창을 동시에 다루는 시나리오는 지원하지 않는다는 제약도 만듭니다.
설치
npm install -D cypress
// package.json
{
"scripts": {
"cy:open": "cypress open",
"cy:run": "cypress run"
}
}
# GUI 모드
npm run cy:open
# Headless 모드
npm run cy:run
처음 cypress open을 실행하면 cypress.config.ts가 생성됩니다. 테스트마다 전체 URL을 쓰지 않도록 baseUrl을 먼저 잡아 두면 cy.visit('/login')처럼 경로만 적을 수 있고, CI에서 스테이징 주소로 바꿀 때도 CYPRESS_BASE_URL 환경 변수 하나만 넘기면 됩니다. 모바일 화면은 실제 기기가 아니라 뷰포트 크기로 흉내 내는 방식이라, 반응형 레이아웃 확인에는 충분하지만 터치 제스처나 모바일 브라우저 고유 동작까지 검증하지는 못한다는 점을 알고 쓰면 됩니다.
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: { baseUrl: 'http://localhost:3000' },
viewportWidth: 1280,
viewportHeight: 720,
screenshotOnRunFailure: true, // 실패 시 스크린샷 (CI 디버깅용)
video: false, // 필요할 때만 켜기: 녹화는 실행 시간을 늘림
});
// 테스트 안에서 모바일 크기로: cy.viewport('iphone-x');
첫 번째 테스트
// cypress/e2e/login.cy.ts
describe('Login', () => {
beforeEach(() => {
cy.visit('/login'); // baseUrl 기준 상대 경로
});
it('should login successfully', () => {
cy.get('[data-testid="email"]').type('[email protected]');
cy.get('[data-testid="password"]').type('password123');
cy.get('[data-testid="submit"]').click();
cy.url().should('include', '/dashboard');
cy.contains('Welcome back').should('be.visible');
});
it('should show error for invalid credentials', () => {
cy.get('[data-testid="email"]').type('[email protected]');
cy.get('[data-testid="password"]').type('wrongpassword');
cy.get('[data-testid="submit"]').click();
cy.contains('Invalid credentials').should('be.visible');
});
});
Cypress 명령은 호출되는 즉시 실행되지 않습니다. it 블록이 실행되면 명령들이 큐에 쌓이고, 테스트 함수가 끝난 뒤 Cypress가 하나씩 꺼내 실행합니다. 그래서 const el = cy.get(...)로 값을 받아 쓸 수 없고, async/await도 쓸 수 없습니다. 값이 필요하면 .then()으로 받거나 .as()로 별칭을 붙여야 합니다. 이 모델을 모르고 일반 Promise를 섞으면, Cypress 명령이 실제로 실행되기 전에 Promise 쪽 코드가 먼저 돌아서 순서가 뒤엉킨 테스트가 됩니다.
cy.url().should('include', '/dashboard')에 cy.wait()가 없어도 되는 것은 should가 조건을 만족할 때까지 재시도하기 때문입니다. 다만 재시도되는 것은 바로 앞의 조회 명령(cy.get, cy.url, cy.contains 등)이고, .click()이나 .type() 같은 동작 명령은 재시도되지 않습니다.
셀렉터
Best Practices
// ❌ 나쁜 예 (변경에 취약)
cy.get('.btn-primary')
cy.get('#submit-button')
cy.get('button:nth-child(2)')
// ✅ 좋은 예 (안정적)
cy.get('[data-testid="submit"]')
cy.get('[data-cy="submit"]')
cy.contains('Submit')
.btn-primary는 디자인이 바뀌면, #submit-button은 개발자가 id를 정리하면, nth-child는 버튼 순서만 바뀌어도 깨집니다. 모두 테스트가 검증하려는 동작과 무관한 이유로 실패하는 셀렉터입니다. data-testid/data-cy는 “테스트용”이라는 의도가 드러나서 다른 개발자가 함부로 지우지 않고, 스타일과도 분리됩니다. cy.contains('Submit')는 사용자가 실제로 보는 텍스트로 찾으므로 의미상 가장 자연스럽지만, 문구가 바뀌거나 다국어 전환 시 깨지므로 텍스트 자체가 검증 대상일 때 쓰는 편이 좋습니다. CSS-in-JS가 만든 css-1x2y3z 같은 해시 클래스는 빌드마다 바뀔 수 있으므로 절대 셀렉터로 쓰면 안 됩니다.
커스텀 명령어
// cypress/support/commands.ts
Cypress.Commands.add('login', (email: string, password: string) => {
cy.visit('/login');
cy.get('[data-testid="email"]').type(email);
cy.get('[data-testid="password"]').type(password);
cy.get('[data-testid="submit"]').click();
});
// 사용
cy.login('[email protected]', 'password123');
TypeScript 프로젝트에서는 커스텀 명령을 추가하기만 하면 Property 'login' does not exist on type 'cy & CyEventEmitter' 에러가 납니다. declare global { namespace Cypress { interface Chainable { login(email: string, password: string): Chainable<void> } } }로 타입을 선언해 줘야 합니다. 또 이 login 명령은 매 테스트마다 로그인 화면을 거치므로 느립니다. 로그인 화면 자체를 검증하는 테스트는 한두 개면 충분하고, 나머지는 아래 “인증” 절의 API 로그인 + cy.session으로 바꾸는 것이 좋습니다.
API 모킹
cy.intercept
describe('Posts', () => {
beforeEach(() => {
cy.intercept('GET', '/api/posts', {
statusCode: 200,
body: [
{ id: 1, title: 'Post 1', content: 'Content 1' },
{ id: 2, title: 'Post 2', content: 'Content 2' },
],
}).as('getPosts');
cy.visit('/posts');
});
it('should display posts', () => {
cy.wait('@getPosts');
cy.contains('Post 1').should('be.visible');
cy.contains('Post 2').should('be.visible');
});
});
cy.intercept는 반드시 요청이 나가기 전에 등록해야 합니다. 위 예제가 cy.visit()보다 먼저 intercept를 호출하는 이유입니다. 순서를 바꾸면 페이지 로드 중에 나간 요청은 가로채지 못하고 실서버로 가며, cy.wait('@getPosts')는 Timed out retrying after 5000ms: cy.wait() timed out waiting 5000ms for the 1st request to the route: getPosts. No request ever occurred. 에러로 실패합니다. 이 에러가 보이면 등록 순서와 URL 패턴(쿼리 스트링 포함 여부)을 먼저 확인하면 됩니다.
모킹의 트레이드오프도 분명합니다. 응답을 고정하면 백엔드 상태와 무관하게 빠르고 안정적이지만, 실제 API 계약이 바뀌어도 테스트는 계속 통과합니다. 그래서 대부분의 화면 테스트는 모킹하고, 로그인·결제 같은 핵심 흐름 몇 개만 실제 백엔드(스테이징)에 붙여 계약을 확인하는 식으로 섞어 씁니다.
Fixture
// cypress/fixtures/users.json
[
{ "id": 1, "name": "John", "email": "[email protected]" },
{ "id": 2, "name": "Jane", "email": "[email protected]" }
]
cy.intercept('GET', '/api/users', { fixture: 'users.json' }).as('getUsers');
인증
API로 로그인하고 세션 재사용하기
// cypress/support/commands.ts
Cypress.Commands.add('loginByApi', (email: string, password: string) => {
cy.session([email], () => {
cy.request('POST', 'http://localhost:8000/api/login', {
email,
password,
}).then((response) => {
window.localStorage.setItem('token', response.body.token);
});
}, {
validate() {
// 캐시된 세션이 아직 유효한지 확인 (토큰 만료 시 다시 로그인)
cy.request({ url: 'http://localhost:8000/api/me', failOnStatusCode: false,
headers: { Authorization: `Bearer ${window.localStorage.getItem('token')}` } })
.its('status').should('eq', 200);
},
});
});
// 사용
beforeEach(() => {
cy.loginByApi('[email protected]', 'password123');
cy.visit('/dashboard');
});
UI로 로그인하면 폼 입력과 리다이렉트를 매번 거쳐 느리고, 로그인 화면이 바뀌면 관련 없는 테스트까지 모두 깨집니다. cy.request는 브라우저 UI를 거치지 않고 HTTP 요청을 직접 보내므로 훨씬 빠릅니다. cy.session은 여기서 한 단계 더 나아가, 첫 실행 때 만든 쿠키·localStorage·sessionStorage를 캐시해 두었다가 같은 id([email])로 호출되면 로그인 과정을 건너뛰고 복원합니다. Cypress는 테스트마다 브라우저 저장소를 비우는 테스트 격리(testIsolation)가 기본이라, 세션을 캐시하지 않으면 모든 테스트가 로그인부터 다시 합니다.
처음 cy.session을 쓸 때 흔히 겪는 문제는 세션 복원 후 페이지가 비어 있는 것입니다. cy.session은 복원 후 빈 페이지(about:blank)로 이동하므로 반드시 뒤에 cy.visit()을 호출해야 합니다. 또 토큰을 쿠키가 아닌 메모리(React 상태 등)에만 들고 있는 앱은 저장소를 복원해도 로그인 상태가 되지 않으므로, 이런 경우 앱이 새로고침 시 토큰을 어디서 복원하는지부터 확인해야 합니다.
CI/CD 통합
GitHub Actions
# .github/workflows/cypress.yml
name: Cypress Tests
on: [push, pull_request]
jobs:
cypress:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Run Cypress tests
uses: cypress-io/github-action@v6
with:
build: npm run build
start: npm start # 백그라운드로 서버 실행
wait-on: 'http://localhost:3000'
browser: chrome
# record: true # Cypress Cloud 사용 시에만 (CYPRESS_RECORD_KEY 필요)
- name: Upload screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: cypress-screenshots
path: cypress/screenshots
cypress-io/github-action은 의존성 설치(npm ci)와 Cypress 바이너리 캐싱을 직접 처리하고, start와 wait-on 옵션으로 서버를 띄운 뒤 응답할 때까지 기다려 줍니다. 서버를 별도 스텝에서 npm start &로 띄우는 방식도 동작은 하지만, 서버가 시작 중 죽어도 로그가 잘 보이지 않고 대기 로직을 직접 관리해야 합니다. record: true는 결과를 Cypress Cloud(유료 플랜 기반 서비스)에 올리는 옵션이라 계정과 레코드 키가 없으면 실패하므로, 쓰지 않는다면 빼 두는 것이 맞습니다.
CI에서만 실패하는 테스트는 대부분 속도 차이에서 옵니다. CI 머신은 로컬보다 느려서 기본 4초 안에 요소가 나타나지 않거나, 애니메이션이 끝나기 전에 클릭을 시도합니다. 이럴 때 cy.wait(3000)을 넣고 싶어지지만, 그것은 느린 환경에서는 여전히 부족하고 빠른 환경에서는 시간만 낭비합니다. 네트워크 응답은 cy.wait('@alias')로, 화면 상태는 로딩 표시가 사라지는 것을 should('not.exist')로 기다리는 식으로 “무엇을 기다리는지”를 명시해야 합니다. 실패 스크린샷을 아티팩트로 올려 두는 설정은 이런 문제를 원격에서 진단하는 데 거의 필수입니다.
테스트끼리 상태를 공유하지 않게 만들기
// ❌ 나쁜 예 (의존성 있음)
it('should create user', () => {
cy.get('[data-testid="name"]').type('John');
cy.get('[data-testid="submit"]').click();
});
it('should edit user', () => {
// 이전 테스트에 의존
cy.contains('John').click();
});
// ✅ 좋은 예 (독립적)
it('should edit user', () => {
cy.loginByApi('[email protected]', 'password123');
cy.visit('/users/1');
cy.contains('Edit').click();
});
테스트 순서에 기대면 하나만 골라 실행(it.only)하거나 순서를 바꿨을 때 실패하고, 앞 테스트가 실패하면 뒤 테스트가 줄줄이 실패해서 진짜 원인을 가립니다. 독립적인 테스트를 만들려면 각 테스트가 필요한 데이터를 직접 준비해야 합니다. 위의 /users/1처럼 고정 데이터에 기대는 대신, cy.request로 테스트용 사용자를 API로 만들거나 cy.task로 Node 쪽에서 DB 시드를 넣는 방식이 더 확실합니다.
컴포넌트 테스트
E2E 테스트는 로그인부터 결제까지 흐름 전체를 확인하기엔 좋지만, 버튼 하나의 상태별 렌더링을 확인하려고 매번 앱을 띄우고 화면까지 이동하는 것은 느립니다. Cypress의 컴포넌트 테스트는 앱 서버 없이 컴포넌트 하나를 실제 브라우저에 cy.mount()로 올리고, E2E와 같은 명령(cy.contains, cy.get)으로 검증합니다. cypress open에서 Component Testing을 고르면 React·Vue 등 프레임워크와 번들러(Vite, webpack) 설정을 잡아 줍니다. jsdom 기반 단위 테스트와 달리 실제 브라우저 레이아웃과 CSS가 적용된 상태로 확인할 수 있다는 점이 장점이고, 그만큼 실행은 조금 더 무겁습니다.
// src/components/Button.cy.tsx
import Button from './Button';
describe('Button', () => {
it('클릭 시 핸들러를 한 번 호출한다', () => {
const onClick = cy.spy().as('onClick');
cy.mount(<Button label="저장" onClick={onClick} />);
cy.contains('저장').click();
cy.get('@onClick').should('have.been.calledOnce');
});
});
심화: Cypress 아키텍처·intercept·커버리지·프로덕션
인-브라우저 실행 모델
Cypress를 실행하면 Node 프로세스(서버)와 브라우저가 함께 뜹니다. 브라우저 안에는 테스트 러너 화면과 앱을 담은 iframe이 있고, 스펙 코드는 앱과 같은 브라우저에서 실행됩니다. Node 프로세스는 파일 시스템 접근(cy.task, cy.readFile)과 네트워크 프록시를 담당합니다. 테스트 코드가 브라우저 안에 있기 때문에 DOM을 직접 읽을 수 있지만, 브라우저의 동일 출처 정책도 그대로 적용됩니다. 테스트 도중 OAuth 로그인처럼 다른 도메인으로 이동하는 흐름은 cy.origin()으로 감싸야 하고, 그렇지 않으면 cross-origin 에러로 실패합니다.
명령 큐와 자동 재시도 덕분에 단언이 안정적으로 붙는 경우가 많지만, 순수 Promise(fetch().then, async 함수)와 Cypress 명령을 섞으면 실행 순서가 코드 순서와 달라져 플레이크의 원인이 됩니다.
cy.intercept와 네트워크 스텁
cy.intercept는 XHR/fetch 경로를 가로채 고정 응답·지연·에러를 시뮬레이션합니다. as('alias')와 cy.wait('@alias')로 네트워크 동기화를 하면 cy.wait(5000) 같은 고정 대기보다 안정적입니다.
커버리지
Cypress로 프런트 커버리지를 모으려면 앱 코드를 Istanbul(babel-plugin-istanbul 등)로 계측해 빌드하고 @cypress/code-coverage 플러그인으로 수집합니다. 계측 빌드를 따로 만들어야 하고 실행도 느려지므로, 커버리지 임계값은 Vitest/Jest 쪽에서 관리하고 E2E는 핵심 사용자 여정 검증에 집중하는 편이 비용 대비 효과가 좋습니다. E2E 커버리지는 “어떤 화면 코드가 한 번도 실행되지 않는가”를 찾는 참고 자료로 쓰는 정도가 적당합니다.
프로덕션
E2E 스위트는 스테이징·프리뷰가 기본이며, 프로덕션에는 합성 헬스 체크와 관측(RUM·APM) 으로 보완합니다. 영문 심화 요약은 Cypress E2E Testing Guide (English)를 참고하십시오.
같이 보면 좋은 글
자주 묻는 질문 (FAQ)
Q. Cypress vs Playwright, 어떤 게 나은가요?
A. Cypress는 대화형 테스트 러너와 Time Travel 디버깅이 강점이고, Playwright는 WebKit(Safari 엔진) 정식 지원, 여러 탭·브라우저 컨텍스트, 기본 병렬 실행이 강점입니다. Safari 검증이나 멀티 탭 시나리오가 필요하면 Playwright, 이미 Cypress 기반 자산이 있거나 개발 중 디버깅 경험을 중시하면 Cypress가 무난합니다.
Q. 유닛 테스트도 Cypress로 하나요?
A. 순수 함수·로직 단위 테스트는 Vitest나 Jest가 훨씬 빠릅니다. 다만 위의 컴포넌트 테스트처럼 실제 브라우저 렌더링이 필요한 UI 컴포넌트 검증에는 Cypress Component Testing을 쓸 수 있습니다.
Q. 실제 API를 호출해야 하나요?
A. 대부분의 화면 테스트는 cy.intercept로 모킹하는 편이 빠르고 안정적입니다. 다만 모킹만 하면 API 계약 변경을 놓치므로, 핵심 흐름 몇 개는 스테이징 백엔드에 실제로 붙여서 확인하는 것이 좋습니다.
Q. 운영(프로덕션) 환경을 대상으로 돌려도 되나요?
A. 데이터를 만들거나 바꾸는 테스트는 운영 데이터를 오염시키므로 스테이징·프리뷰 환경에서 돌립니다. 운영에는 읽기 전용 스모크 테스트나 합성 모니터링 정도만 두는 것이 일반적입니다.