Cypress E2E 테스트 가이드 | 웹 애플리케이션 테스트 자동화 | 품질보증

Cypress E2E 테스트로 완성하는 견고한 웹 애플리케이션

UMC 12주차 프로젝트 경험으로 배우는 체계적인 테스트 자동화 구축법
테스트 자동화와 품질 보증을 나타내는 코드 검증 과정

Cypress E2E 테스트로 완성하는 견고한 웹 애플리케이션 🧪

안녕하세요! 오늘은 Cypress를 활용한 E2E 테스트에 대해 이야기해보려고 합니다.

UMC 12주차 프로젝트에서 Cypress E2E 테스트를 도입했는데, 이 경험이 정말 값진 배움이었어요! 테스트의 중요성을 몸소 깨달았습니다. 🚀

🤔 왜 E2E 테스트가 필요한가?

테스트 피라미드와 E2E의 역할

🔺 E2E Tests (소수, 느림, 비싸지만 높은 신뢰도)
🔺🔺 Integration Tests (중간)
🔺🔺🔺🔺 Unit Tests (다수, 빠름, 저렴)

실제 프로젝트에서 겪었던 문제들

**E2E 테스트 없을 때 발생한 문제들:**
1. **브라우저별 호환성 이슈**
- Chrome에서는 정상, Safari에서 폼 전송 실패
- 모바일에서 드롭다운 메뉴 작동 안함
2. **사용자 플로우 간 상호작용 오류**
- 로그인 → 데이터 로딩 → 수정 과정에서 상태 꼬임
- 다중 사용자 동시 접속 시 데이터 동기화 문제
3. **배포 후 발견되는 치명적 버그**
- 결제 프로세스 중 세션 만료
- 파일 업로드 후 페이지 깨짐

🔧 Cypress 프로젝트 셋업

설치 및 초기 설정

Terminal window
# Cypress 설치
npm install --save-dev cypress
# 추가 유용한 패키지들
npm install --save-dev @testing-library/cypress
npm install --save-dev cypress-file-upload
npm install --save-dev cypress-real-events
# Cypress 최초 실행 (cypress 폴더 생성)
npx cypress open

프로젝트 구조 설정

cypress/
├── e2e/
│ ├── auth/
│ │ ├── login.cy.js
│ │ └── signup.cy.js
│ ├── user-flows/
│ │ ├── post-creation.cy.js
│ │ └── comment-system.cy.js
│ └── admin/
│ └── dashboard.cy.js
├── fixtures/
│ ├── users.json
│ └── posts.json
├── support/
│ ├── commands.js
│ ├── e2e.js
│ └── page-objects/
│ ├── LoginPage.js
│ └── PostPage.js
└── cypress.config.js

Cypress 설정 파일

cypress.config.js
const { defineConfig } = require("cypress");
module.exports = defineConfig({
e2e: {
baseUrl: "http://localhost:3000",
viewportWidth: 1280,
viewportHeight: 720,
video: true,
screenshotOnRunFailure: true,
// 환경별 설정
env: {
apiUrl: "http://localhost:3001/api",
adminEmail: "[email protected]",
adminPassword: "test123!",
},
// 타임아웃 설정
defaultCommandTimeout: 10000,
requestTimeout: 10000,
responseTimeout: 10000,
// 테스트 파일 패턴
specPattern: "cypress/e2e/**/*.cy.{js,jsx,ts,tsx}",
setupNodeEvents(on, config) {
// 플러그인 설정
on("task", {
// 데이터베이스 시딩
seedDb() {
return require("./cypress/plugins/db-seed");
},
// 테스트 데이터 정리
clearDb() {
return require("./cypress/plugins/db-clear");
},
});
},
},
});

🧪 실제 테스트 케이스 구현

사용자 인증 플로우 테스트

cypress/e2e/auth/login.cy.js
describe("사용자 로그인 테스트", () => {
beforeEach(() => {
// 각 테스트 전에 데이터베이스 초기화
cy.task("clearDb");
cy.task("seedDb");
cy.visit("/login");
});
it("올바른 credentials로 로그인 성공", () => {
// 로그인 폼 채우기
cy.get("[data-cy=email-input]")
.should("have.value", "[email protected]");
cy.get("[data-cy=password-input]").type("password123");
// 로그인 버튼 클릭
cy.get("[data-cy=login-button]").click();
// 성공 후 리다이렉트 확인
cy.url().should("include", "/dashboard");
cy.get("[data-cy=welcome-message]").should("contain", "환영합니다");
// 로컬 스토리지에 토큰 저장 확인
cy.window()
.its("localStorage")
.invoke("getItem", "authToken")
.should("exist");
});
it("잘못된 credentials로 로그인 실패", () => {
cy.get("[data-cy=email-input]").type("[email protected]");
cy.get("[data-cy=password-input]").type("wrongpassword");
cy.get("[data-cy=login-button]").click();
// 에러 메시지 표시 확인
cy.get("[data-cy=error-message]")
.should("be.visible")
.and("contain", "로그인에 실패했습니다");
// 여전히 로그인 페이지에 있는지 확인
cy.url().should("include", "/login");
});
it("폼 유효성 검사 테스트", () => {
// 이메일 없이 제출 시도
cy.get("[data-cy=password-input]").type("password123");
cy.get("[data-cy=login-button]").click();
cy.get("[data-cy=email-error]").should("contain", "이메일을 입력해주세요");
// 잘못된 이메일 형식
cy.get("[data-cy=email-input]").type("invalid-email");
cy.get("[data-cy=login-button]").click();
cy.get("[data-cy=email-error]").should(
"contain",
"올바른 이메일 형식을 입력해주세요"
);
});
});

복잡한 사용자 플로우 테스트

cypress/e2e/user-flows/post-creation.cy.js
describe("게시글 작성 전체 플로우", () => {
beforeEach(() => {
// 로그인된 상태로 시작
cy.login("[email protected]", "password123");
cy.visit("/posts/new");
});
it("게시글 작성부터 게시까지 전체 플로우", () => {
// Step 1: 게시글 기본 정보 입력
cy.get("[data-cy=title-input]").type("Cypress 테스트 자동화 가이드");
cy.get("[data-cy=content-editor]").type(
"이것은 Cypress로 작성하는 테스트 게시글입니다."
);
// Step 2: 태그 추가
cy.get("[data-cy=tag-input]").type("테스트{enter}");
cy.get("[data-cy=tag-input]").type("자동화{enter}");
// 태그가 추가되었는지 확인
cy.get("[data-cy=tag-list]")
.should("contain", "테스트")
.and("contain", "자동화");
// Step 3: 이미지 업로드
cy.get("[data-cy=image-upload]").selectFile(
"cypress/fixtures/test-image.jpg",
{ force: true }
);
// 이미지 프리뷰 확인
cy.get("[data-cy=image-preview]")
.should("be.visible")
.and("have.attr", "src")
.and("include", "test-image.jpg");
// Step 4: 미리보기 확인
cy.get("[data-cy=preview-button]").click();
cy.get("[data-cy=preview-modal]").should("be.visible");
cy.get("[data-cy=preview-title]").should(
"contain",
"Cypress 테스트 자동화 가이드"
);
cy.get("[data-cy=close-preview]").click();
// Step 5: 게시글 발행
cy.get("[data-cy=publish-button]").click();
// Step 6: 성공 확인
cy.url().should("match", /\/posts\/\d+/);
cy.get("[data-cy=post-title]").should(
"contain",
"Cypress 테스트 자동화 가이드"
);
// 게시글 목록에서도 확인
cy.visit("/posts");
cy.get("[data-cy=post-list]").should(
"contain",
"Cypress 테스트 자동화 가이드"
);
});
it("드래프트 저장 및 불러오기", () => {
// 제목과 내용 입력
cy.get("[data-cy=title-input]").type("임시 저장 테스트");
cy.get("[data-cy=content-editor]").type("임시 저장 내용");
// 드래프트 저장
cy.get("[data-cy=save-draft-button]").click();
cy.get("[data-cy=draft-saved-message]")
.should("be.visible")
.and("contain", "임시 저장되었습니다");
// 페이지 새로고침 후 드래프트 복원 확인
cy.reload();
cy.get("[data-cy=restore-draft-modal]").should("be.visible");
cy.get("[data-cy=restore-draft-button]").click();
cy.get("[data-cy=title-input]").should("have.value", "임시 저장 테스트");
cy.get("[data-cy=content-editor]").should("contain", "임시 저장 내용");
});
});

API 응답 모킹과 에러 시나리오 테스트

cypress/e2e/error-scenarios/network-errors.cy.js
describe("네트워크 에러 시나리오 테스트", () => {
beforeEach(() => {
cy.login("[email protected]", "password123");
});
it("API 서버 다운 시 에러 핸들링", () => {
// API 요청을 실패로 모킹
cy.intercept("GET", "/api/posts", {
statusCode: 500,
body: { error: "Internal Server Error" },
}).as("getPostsError");
cy.visit("/posts");
cy.wait("@getPostsError");
// 에러 메시지 표시 확인
cy.get("[data-cy=error-message]")
.should("be.visible")
.and("contain", "서버에 문제가 발생했습니다");
// 재시도 버튼 확인
cy.get("[data-cy=retry-button]").should("be.visible");
});
it("네트워크 타임아웃 시나리오", () => {
// 지연된 응답 모킹
cy.intercept("POST", "/api/posts", {
delay: 30000, // 30초 지연
statusCode: 200,
body: { success: true },
}).as("createPostTimeout");
cy.visit("/posts/new");
cy.get("[data-cy=title-input]").type("타임아웃 테스트");
cy.get("[data-cy=content-editor]").type("내용");
cy.get("[data-cy=publish-button]").click();
// 로딩 스피너 표시 확인
cy.get("[data-cy=loading-spinner]").should("be.visible");
// 타임아웃 에러 메시지 확인
cy.get("[data-cy=timeout-error]", { timeout: 35000 })
.should("be.visible")
.and("contain", "요청 시간이 초과되었습니다");
});
it("부분적 데이터 로딩 실패", () => {
// 메인 데이터는 성공, 부가 데이터는 실패
cy.intercept("GET", "/api/posts", { fixture: "posts.json" }).as("getPosts");
cy.intercept("GET", "/api/posts/*/comments", {
statusCode: 404,
}).as("getCommentsError");
cy.visit("/posts/1");
cy.wait("@getPosts");
cy.wait("@getCommentsError");
// 메인 콘텐츠는 표시되지만 댓글 섹션에 에러 메시지
cy.get("[data-cy=post-content]").should("be.visible");
cy.get("[data-cy=comments-error]").should(
"contain",
"댓글을 불러올 수 없습니다"
);
});
});

🛠 고급 Cypress 기능 활용

커스텀 명령어 정의

cypress/support/commands.js
import "@testing-library/cypress/add-commands";
// 로그인 커스텀 명령어
Cypress.Commands.add("login", (email, password) => {
cy.session([email, password], () => {
cy.visit("/login");
cy.get("[data-cy=email-input]").type(email);
cy.get("[data-cy=password-input]").type(password);
cy.get("[data-cy=login-button]").click();
cy.url().should("include", "/dashboard");
});
});
// API를 통한 직접 로그인 (더 빠름)
Cypress.Commands.add("loginApi", (email, password) => {
cy.request({
method: "POST",
url: `${Cypress.env("apiUrl")}/auth/login`,
body: { email, password },
}).then((response) => {
window.localStorage.setItem("authToken", response.body.token);
});
});
// 테스트 데이터 생성
Cypress.Commands.add("createPost", (postData) => {
cy.request({
method: "POST",
url: `${Cypress.env("apiUrl")}/posts`,
headers: {
Authorization: `Bearer ${window.localStorage.getItem("authToken")}`,
},
body: postData,
});
});
// 파일 업로드 커스텀 명령어
Cypress.Commands.add("uploadFile", (selector, filePath) => {
cy.get(selector).selectFile(filePath, { force: true });
cy.get("[data-cy=upload-progress]").should("not.exist");
});
// 무한 스크롤 테스트
Cypress.Commands.add("scrollToBottom", () => {
cy.window().then((win) => {
win.scrollTo(0, win.document.body.scrollHeight);
});
});

Page Object Model 패턴

cypress/support/page-objects/PostPage.js
class PostPage {
elements = {
titleInput: () => cy.get("[data-cy=title-input]"),
contentEditor: () => cy.get("[data-cy=content-editor]"),
tagInput: () => cy.get("[data-cy=tag-input]"),
tagList: () => cy.get("[data-cy=tag-list]"),
imageUpload: () => cy.get("[data-cy=image-upload]"),
publishButton: () => cy.get("[data-cy=publish-button]"),
saveDraftButton: () => cy.get("[data-cy=save-draft-button]"),
previewButton: () => cy.get("[data-cy=preview-button]"),
characterCount: () => cy.get("[data-cy=character-count]"),
};
visit() {
cy.visit("/posts/new");
return this;
}
fillTitle(title) {
this.elements.titleInput().clear().type(title);
return this;
}
fillContent(content) {
this.elements.contentEditor().clear().type(content);
return this;
}
addTags(tags) {
tags.forEach((tag) => {
this.elements.tagInput().type(`${tag}{enter}`);
});
return this;
}
uploadImage(imagePath) {
this.elements.imageUpload().selectFile(imagePath, { force: true });
return this;
}
publish() {
this.elements.publishButton().click();
return this;
}
saveDraft() {
this.elements.saveDraftButton().click();
return this;
}
preview() {
this.elements.previewButton().click();
return this;
}
verifyCharacterCount(expectedCount) {
this.elements.characterCount().should("contain", expectedCount);
return this;
}
}
export default PostPage;
// 사용 예시
// cypress/e2e/user-flows/post-creation-pom.cy.js
import PostPage from "../support/page-objects/PostPage";
describe("게시글 작성 - Page Object Model", () => {
const postPage = new PostPage();
beforeEach(() => {
cy.loginApi("[email protected]", "password123");
});
it("게시글 작성 플로우 - POM 패턴", () => {
postPage
.visit()
.fillTitle("POM 패턴으로 작성한 게시글")
.fillContent("Page Object Model을 사용하면 테스트 코드가 깔끔해집니다.")
.addTags(["테스트", "POM", "패턴"])
.uploadImage("cypress/fixtures/test-image.jpg")
.preview();
// 미리보기 확인 후 게시
cy.get("[data-cy=preview-modal]").should("be.visible");
cy.get("[data-cy=close-preview]").click();
postPage.publish();
cy.url().should("match", /\/posts\/\d+/);
});
});

📱 모바일 및 반응형 테스트

다양한 뷰포트 테스트

cypress/e2e/responsive/mobile-tests.cy.js
describe("모바일 반응형 테스트", () => {
const viewports = [
{ device: "iPhone 12", width: 390, height: 844 },
{ device: "iPad", width: 768, height: 1024 },
{ device: "Desktop", width: 1920, height: 1080 },
];
viewports.forEach(({ device, width, height }) => {
describe(`${device} (${width}x${height})`, () => {
beforeEach(() => {
cy.viewport(width, height);
cy.visit("/");
});
it("네비게이션 메뉴 테스트", () => {
if (width < 768) {
// 모바일: 햄버거 메뉴
cy.get("[data-cy=mobile-menu-button]").should("be.visible").click();
cy.get("[data-cy=mobile-menu]").should("be.visible");
} else {
// 데스크톱: 일반 메뉴
cy.get("[data-cy=desktop-menu]").should("be.visible");
cy.get("[data-cy=mobile-menu-button]").should("not.be.visible");
}
});
it("게시글 목록 레이아웃 테스트", () => {
cy.visit("/posts");
if (width < 768) {
// 모바일: 세로 단일 컬럼
cy.get("[data-cy=post-grid]").should(
"have.css",
"grid-template-columns",
"repeat(1, 1fr)"
);
} else if (width < 1024) {
// 태블릿: 2 컬럼
cy.get("[data-cy=post-grid]").should(
"have.css",
"grid-template-columns",
"repeat(2, 1fr)"
);
} else {
// 데스크톱: 3 컬럼
cy.get("[data-cy=post-grid]").should(
"have.css",
"grid-template-columns",
"repeat(3, 1fr)"
);
}
});
});
});
});

터치 이벤트 테스트

cypress/e2e/mobile/touch-interactions.cy.js
describe("터치 인터랙션 테스트", () => {
beforeEach(() => {
cy.viewport("iphone-12");
cy.visit("/posts");
});
it("스와이프로 게시글 삭제", () => {
cy.loginApi("[email protected]", "password123");
// 테스트용 게시글 생성
cy.createPost({
title: "스와이프 테스트 게시글",
content: "삭제될 예정인 게시글입니다.",
});
cy.reload();
// 첫 번째 게시글을 왼쪽으로 스와이프
cy.get("[data-cy=post-item]")
.first()
.trigger("touchstart", { touches: [{ clientX: 300, clientY: 100 }] })
.trigger("touchmove", { touches: [{ clientX: 100, clientY: 100 }] })
.trigger("touchend");
// 삭제 버튼이 나타나는지 확인
cy.get("[data-cy=delete-button]").should("be.visible").click();
// 확인 다이얼로그
cy.get("[data-cy=confirm-delete]").click();
// 게시글이 목록에서 사라졌는지 확인
cy.get("[data-cy=post-list]").should(
"not.contain",
"스와이프 테스트 게시글"
);
});
it("Pull-to-refresh 테스트", () => {
// 페이지 상단에서 아래로 당기기
cy.get("body")
.trigger("touchstart", { touches: [{ clientX: 200, clientY: 50 }] })
.trigger("touchmove", { touches: [{ clientX: 200, clientY: 200 }] })
.trigger("touchend");
// 새로고침 인디케이터 확인
cy.get("[data-cy=refresh-indicator]").should("be.visible");
// 데이터가 새로고침되는지 확인
cy.get("[data-cy=last-updated]").should("contain", new Date().getDate());
});
});

🚀 CI/CD 파이프라인 통합

GitHub Actions 워크플로우

.github/workflows/cypress.yml
name: Cypress Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
cypress-run:
runs-on: ubuntu-latest
strategy:
matrix:
browser: [chrome, firefox, edge]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build application
run: npm run build
- name: Start application
run: npm start &
- name: Wait for server
run: npx wait-on http://localhost:3000
- name: Run Cypress tests
uses: cypress-io/github-action@v6
with:
browser: ${{ matrix.browser }}
record: true
parallel: true
group: ${{ matrix.browser }}
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload screenshots
if: failure()
uses: actions/upload-artifact@v3
with:
name: cypress-screenshots-${{ matrix.browser }}
path: cypress/screenshots
- name: Upload videos
if: always()
uses: actions/upload-artifact@v3
with:
name: cypress-videos-${{ matrix.browser }}
path: cypress/videos

테스트 결과 리포팅

// cypress.config.js에 리포터 설정 추가
module.exports = defineConfig({
e2e: {
// 다중 리포터 설정
reporter: 'cypress-multi-reporters',
reporterOptions: {
configFile: 'reporter-config.json',
},
setupNodeEvents(on, config) {
// 테스트 결과를 Slack에 전송
on('after:run', (results) => {
if (results.failures > 0) {
return require('./cypress/plugins/slack-notification')(results)
}
})
},
},
})
// reporter-config.json
{
"reporterEnabled": "mochawesome, cypress-terminal-report",
"mochawesomeReporterOptions": {
"reportDir": "cypress/reports",
"overwrite": false,
"html": true,
"json": true,
"charts": true
}
}

📊 실제 프로젝트 성과 분석

UMC 12주차 프로젝트 결과

📈 Cypress 도입 전후 비교:
🐛 **버그 발견율**
- 도입 전: 배포 후 발견되는 버그 12건/주
- 도입 후: 배포 후 발견되는 버그 3건/주 (75% 감소)
⏱️ **개발 효율성**
- 수동 테스트 시간: 4시간/배포 → 30분/배포
- 회귀 테스트: 수동 8시간 → 자동 20분
- 전체 QA 프로세스: 50% 시간 단축
🎯 **신뢰성 향상**
- 사용자 신고 버그: 월 15건 → 월 4건
- 핫픽스 배포 횟수: 주 3회 → 주 0.5회
- 사용자 만족도: 3.2/5 → 4.6/5

팀 내 문화 변화

🔄 **개발 워크플로우 변화**
Before Cypress:
1. 개발 완료
2. 수동 테스트 (개발자)
3. QA 팀 수동 테스트
4. 버그 발견 시 1번부터 반복
After Cypress:
1. 개발 + 테스트 코드 작성
2. 자동 테스트 실행
3. QA 팀 엣지 케이스 검증
4. 자신감 있는 배포

🎯 모범 사례와 팁

효과적인 테스트 작성 원칙

// ✅ 좋은 테스트 예시
describe("게시글 좋아요 기능", () => {
it("로그인한 사용자가 게시글에 좋아요를 누를 수 있다", () => {
// Given: 로그인한 사용자와 게시글이 있을 때
cy.loginApi("[email protected]", "password123");
cy.createPost({ title: "테스트 게시글", content: "내용" });
cy.visit("/posts/1");
// When: 좋아요 버튼을 클릭하면
cy.get("[data-cy=like-button]").click();
// Then: 좋아요 수가 증가하고 버튼 상태가 변경된다
cy.get("[data-cy=like-count]").should("contain", "1");
cy.get("[data-cy=like-button]").should("have.class", "liked");
});
});
// ❌ 피해야 할 테스트 예시
describe("전체 애플리케이션 테스트", () => {
it("모든 기능이 작동한다", () => {
// 너무 광범위하고 구체적이지 않음
cy.visit("/");
cy.get("button").click();
cy.url().should("include", "something");
// ... 100줄의 테스트 코드
});
});

데이터 테스트 속성 활용

// HTML에 data-cy 속성 추가
// ✅ 추천
<button data-cy="submit-button" className="btn btn-primary">
제출하기
</button>
// ❌ 비추천 (CSS 클래스나 ID 의존)
<button id="submit-btn" className="btn btn-primary">
제출하기
</button>
// Cypress 테스트에서
cy.get('[data-cy=submit-button]') // ✅ 안정적
cy.get('#submit-btn') // ❌ 변경 위험
cy.get('.btn-primary') // ❌ 다른 버튼과 충돌 가능

테스트 격리와 독립성

// ✅ 각 테스트가 독립적
describe("사용자 관리", () => {
beforeEach(() => {
cy.task("clearDb"); // 데이터 초기화
cy.task("seedTestData"); // 필요한 데이터만 생성
});
it("사용자 생성 테스트", () => {
// 이 테스트만의 데이터 생성
cy.createUser({ email: "[email protected]" });
// 테스트 로직...
});
it("사용자 삭제 테스트", () => {
// 별도의 데이터 생성 (다른 테스트와 무관)
cy.createUser({ email: "[email protected]" });
// 테스트 로직...
});
});

🎉 마무리

Cypress E2E 테스트는 웹 애플리케이션의 품질을 보장하는 핵심 도구입니다.

💡 핵심 포인트

  • 사용자 관점의 테스트: 실제 사용자가 경험하는 시나리오 검증
  • 브라우저 호환성: 다양한 환경에서의 동작 보장
  • 자동화된 회귀 테스트: 기존 기능 보호
  • 팀 문화 개선: 품질에 대한 공동 책임감 형성
  • 배포 자신감: 안정적인 릴리즈 프로세스

UMC 12주차에서 Cypress를 도입하면서 테스트의 중요성을 정말 깊이 깨달았어요. 처음엔 “테스트 코드 작성하는 시간에 기능 하나 더 만들 수 있는데?”라고 생각했지만, 결과적으로는 전체 개발 시간을 크게 단축시켜줬습니다! 🚀


여러분의 E2E 테스트 경험은 어떠신가요? Cypress 외에 다른 도구를 사용해보신 분들의 비교 경험도 궁금합니다! 💬

다음 글에서는 성능 테스트와 접근성 테스트 자동화에 대해 다뤄보겠습니다!

댓글

댓글을 불러오는 중...