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 프로젝트 셋업
설치 및 초기 설정
# Cypress 설치npm install --save-dev cypress
# 추가 유용한 패키지들npm install --save-dev @testing-library/cypressnpm install --save-dev cypress-file-uploadnpm 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 설정 파일
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", 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"); }, }); }, },});
🧪 실제 테스트 케이스 구현
사용자 인증 플로우 테스트
describe("사용자 로그인 테스트", () => { beforeEach(() => { // 각 테스트 전에 데이터베이스 초기화 cy.task("clearDb"); cy.task("seedDb"); cy.visit("/login"); });
it("올바른 credentials로 로그인 성공", () => { // 로그인 폼 채우기 cy.get("[data-cy=email-input]")
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=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", "올바른 이메일 형식을 입력해주세요" ); });});
복잡한 사용자 플로우 테스트
describe("게시글 작성 전체 플로우", () => { beforeEach(() => { // 로그인된 상태로 시작 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 응답 모킹과 에러 시나리오 테스트
describe("네트워크 에러 시나리오 테스트", () => { beforeEach(() => { });
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 기능 활용
커스텀 명령어 정의
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 패턴
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.jsimport PostPage from "../support/page-objects/PostPage";
describe("게시글 작성 - Page Object Model", () => { const postPage = new PostPage();
beforeEach(() => { });
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+/); });});
📱 모바일 및 반응형 테스트
다양한 뷰포트 테스트
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)" ); } }); }); });});
터치 이벤트 테스트
describe("터치 인터랙션 테스트", () => { beforeEach(() => { cy.viewport("iphone-12"); cy.visit("/posts"); });
it("스와이프로 게시글 삭제", () => {
// 테스트용 게시글 생성 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 워크플로우
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.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("사용자 생성 테스트", () => { // 이 테스트만의 데이터 생성 // 테스트 로직... });
it("사용자 삭제 테스트", () => { // 별도의 데이터 생성 (다른 테스트와 무관) // 테스트 로직... });});
🎉 마무리
Cypress E2E 테스트는 웹 애플리케이션의 품질을 보장하는 핵심 도구입니다.
💡 핵심 포인트
- 사용자 관점의 테스트: 실제 사용자가 경험하는 시나리오 검증
- 브라우저 호환성: 다양한 환경에서의 동작 보장
- 자동화된 회귀 테스트: 기존 기능 보호
- 팀 문화 개선: 품질에 대한 공동 책임감 형성
- 배포 자신감: 안정적인 릴리즈 프로세스
UMC 12주차에서 Cypress를 도입하면서 테스트의 중요성을 정말 깊이 깨달았어요. 처음엔 “테스트 코드 작성하는 시간에 기능 하나 더 만들 수 있는데?”라고 생각했지만, 결과적으로는 전체 개발 시간을 크게 단축시켜줬습니다! 🚀
여러분의 E2E 테스트 경험은 어떠신가요? Cypress 외에 다른 도구를 사용해보신 분들의 비교 경험도 궁금합니다! 💬
다음 글에서는 성능 테스트와 접근성 테스트 자동화에 대해 다뤄보겠습니다! ⚡
댓글
댓글을 불러오는 중...
댓글을 작성하려면 로그인이 필요합니다.