Cypress 是为现代 Web 构建的强大端到端测试框架。与在浏览器外运行的传统测试工具不同,Cypress 直接在浏览器内与应用一起执行,原生访问 DOM、网络请求和所有 JavaScript 对象。这种架构消除了测试运行器和浏览器驱动之间网络延迟导致的不稳定性。本指南涵盖从初始设置到 CI 部署所需的一切。
Cypress 在浏览器中运行,实现快速、无抖动的 E2E 测试。使用 npm install cypress 安装,使用 cy.visit/cy.get/cy.contains 编写测试,使用 cy.intercept 模拟 API 响应,使用 fixtures 管理测试数据,通过自定义命令封装可复用逻辑,利用组件测试进行隔离 UI 验证,并通过 cypress run 集成到 CI 中。
- Cypress 直接在浏览器中运行,消除了 WebDriver 层,提供原生 DOM 访问和自动等待。
- 使用 data-cy 属性作为选择器,而非 CSS 类或 ID,以创建对 UI 变更有弹性的测试。
- cy.intercept() 允许存根、监听和修改网络请求,无需运行后端即可测试边界情况。
- Fixtures(cypress/fixtures/)将测试数据外部化为 JSON 文件,保持测试整洁且数据可跨规格复用。
- 自定义命令(Cypress.Commands.add)封装常见工作流如登录,减少测试文件间的重复。
- Cypress 组件测试在真实浏览器中隔离验证 UI 组件,补充 E2E 测试。
为什么选择 Cypress 进行 E2E 测试?
Cypress 从零开始为 Web 应用的端到端测试而设计。它在与应用相同的运行循环中运行,可以同步访问浏览器中发生的一切。没有测试运行器和浏览器之间的网络跳转,没有 WebDriver 协议开销,也没有外部进程编排命令。
这种架构提供了多项优势:自动等待 DOM 元素、网络请求和动画;时间旅行调试可查看每个命令的应用状态;保存文件后实时重载;以及一致的确定性测试执行。
Cypress vs Playwright vs Selenium
了解 Cypress 与其他流行测试框架的比较,帮助你做出明智的选择。
| 特性 | Cypress | Playwright | Selenium |
|---|---|---|---|
| 架构 | 浏览器内运行 | CDP/WebSocket | WebDriver 协议 |
| 浏览器 | Chrome, Firefox, Edge, WebKit | Chromium, Firefox, WebKit | 所有主流浏览器 |
| 语言 | JavaScript/TypeScript | JS/TS, Python, Java, C# | 多种语言 |
| 自动等待 | 内置(所有命令) | 内置(所有操作) | 需要手动等待 |
| 网络模拟 | cy.intercept(内置) | page.route(内置) | 需要外部工具 |
| 时间旅行调试 | 是(GUI 快照) | Trace viewer | 否 |
| 多标签页支持 | 有限(需变通) | 完全支持 | 完全支持 |
| 组件测试 | 内置 | 实验性 | 否 |
| 并行执行 | Cypress Cloud(付费) | 内置(免费) | Selenium Grid |
Cypress 在开发者体验、调试和组件测试方面表现出色。Playwright 在多浏览器支持、多标签页场景和免费并行方面领先。当开发者体验和调试速度是优先时选择 Cypress;当需要跨浏览器覆盖时选择 Playwright。
安装和项目配置
Cypress 作为开发依赖安装,自带 Electron 浏览器,可即时启动。也可以在 Chrome、Firefox、Edge 和 WebKit(实验性)上运行测试。
# Install Cypress
npm install --save-dev cypress
# Open Cypress interactive runner
npx cypress open
# Run tests headlessly (CI mode)
npx cypress run
# Run with specific browser
npx cypress run --browser chrome
npx cypress run --browser firefox
# Run a single spec file
npx cypress run --spec "cypress/e2e/login.cy.ts"
# Project structure after first open:
# cypress/
# e2e/ — test spec files
# fixtures/ — test data (JSON)
# support/
# commands.ts — custom commands
# e2e.ts — global hooks/setup
# cypress.config.ts — configuration配置
cypress.config.ts 文件是核心配置点,控制超时、视口大小、基础 URL、环境变量和插件设置。
import { defineConfig } from "cypress";
export default defineConfig({
e2e: {
baseUrl: "http://localhost:3000",
specPattern: "cypress/e2e/**/*.cy.{ts,js}",
supportFile: "cypress/support/e2e.ts",
// Timeouts
defaultCommandTimeout: 10000,
requestTimeout: 15000,
responseTimeout: 30000,
pageLoadTimeout: 60000,
// Viewport
viewportWidth: 1280,
viewportHeight: 720,
// Retry configuration
retries: {
runMode: 2, // CI retries
openMode: 0, // No retries in interactive mode
},
// Screenshots and videos
screenshotOnRunFailure: true,
video: false, // Disable in CI for speed
screenshotsFolder: "cypress/screenshots",
videosFolder: "cypress/videos",
// Environment variables
env: {
apiUrl: "http://localhost:4000/api",
coverage: false,
},
setupNodeEvents(on, config) {
// Register plugins here
// on("task", { ... })
return config;
},
},
component: {
devServer: {
framework: "react",
bundler: "vite", // or "webpack"
},
specPattern: "src/**/*.cy.{ts,tsx}",
},
});编写第一个测试
Cypress 测试文件位于 cypress/e2e 目录,使用 .cy.ts 或 .cy.js 扩展名。测试遵循熟悉的 Mocha 风格 describe/it 结构。每个 Cypress 命令都是异步的,但使用可链式调用的 API。
// cypress/e2e/homepage.cy.ts
describe("Homepage", () => {
beforeEach(() => {
cy.visit("/");
});
it("displays the hero section", () => {
cy.get("[data-cy=hero-title]")
.should("be.visible")
.and("contain", "Welcome");
cy.get("[data-cy=hero-subtitle]")
.should("exist");
});
it("navigates to the about page", () => {
cy.get("[data-cy=nav-about]").click();
cy.url().should("include", "/about");
cy.get("h1").should("contain", "About Us");
});
it("submits the contact form", () => {
cy.get("[data-cy=contact-name]").type("Jane Doe");
cy.get("[data-cy=contact-email]").type("jane@example.com");
cy.get("[data-cy=contact-message]").type("Hello!");
cy.get("[data-cy=contact-submit]").click();
cy.get("[data-cy=success-message]")
.should("be.visible")
.and("contain", "Thank you");
});
it("loads products from the API", () => {
cy.get("[data-cy=product-card]")
.should("have.length.greaterThan", 0);
cy.get("[data-cy=product-card]")
.first()
.find("[data-cy=product-name]")
.should("not.be.empty");
});
});Login Test Example
// cypress/e2e/auth/login.cy.ts
describe("Authentication", () => {
beforeEach(() => {
cy.visit("/login");
});
it("logs in with valid credentials", () => {
cy.get("[data-cy=email-input]").type("user@example.com");
cy.get("[data-cy=password-input]").type("securePass123");
cy.get("[data-cy=login-button]").click();
// Should redirect to dashboard
cy.url().should("include", "/dashboard");
cy.get("[data-cy=welcome-message]")
.should("contain", "Welcome back");
});
it("shows error for invalid credentials", () => {
cy.get("[data-cy=email-input]").type("user@example.com");
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", "Invalid credentials");
cy.url().should("include", "/login");
});
it("validates required fields", () => {
cy.get("[data-cy=login-button]").click();
cy.get("[data-cy=email-error]")
.should("contain", "Email is required");
cy.get("[data-cy=password-error]")
.should("contain", "Password is required");
});
});选择器和最佳实践
可靠的选择器是可维护测试的基础。CSS 类和 ID 在重构时经常变化,导致测试不必要地失败。Cypress 推荐使用专用的 data 属性。
选择器优先级从最稳定到最不稳定:
- data-cy、data-testid 或 data-test 属性(最佳:专为测试构建)
- 通过 cy.contains() 或 cy.findByRole() 使用可访问角色和标签
- 带特定属性的 HTML 元素标签
- CSS 类名或 ID(最不稳定:随设计更新而变化)
// BAD: fragile selectors
cy.get(".btn-primary"); // CSS class changes
cy.get("#submit"); // ID may be generated
cy.get("div > form > button"); // DOM structure changes
cy.get(":nth-child(3)"); // Position changes
// GOOD: resilient selectors
cy.get("[data-cy=submit-button]"); // Purpose-built
cy.get("[data-testid=user-name]"); // Testing Library convention
cy.contains("Submit Order"); // Text content
cy.findByRole("button", { name: /submit/i }); // Accessible role
// Add data-cy to your components:
// <button data-cy="submit-button">Submit</button>
// <input data-cy="search-input" type="text" />
// <div data-cy="product-card">...</div>核心 Cypress 命令
Cypress 提供丰富的内置命令与应用交互。每个命令自动重试直到断言通过或超时。
// --- Navigation ---
cy.visit("/products"); // Navigate to URL
cy.visit("/products?page=2"); // With query params
cy.go("back"); // Browser back
cy.go("forward"); // Browser forward
cy.reload(); // Reload page
// --- Querying ---
cy.get("[data-cy=item]"); // CSS selector
cy.contains("Add to Cart"); // Text content
cy.get("ul").find("li"); // Scoped query
cy.get("li").first(); // First element
cy.get("li").last(); // Last element
cy.get("li").eq(2); // Nth element (0-indexed)
cy.get("li").filter(".active"); // Filter by selector
// --- Actions ---
cy.get("input").type("hello"); // Type text
cy.get("input").clear(); // Clear input
cy.get("input").type("hello{enter}"); // Type + press Enter
cy.get("button").click(); // Click
cy.get("button").dblclick(); // Double click
cy.get("button").rightclick(); // Right click
cy.get("select").select("Option 1"); // Select dropdown
cy.get("input[type=checkbox]").check(); // Check checkbox
cy.get("input[type=checkbox]").uncheck(); // Uncheck
cy.get("input[type=file]")
.selectFile("cypress/fixtures/doc.pdf"); // File upload
cy.get("element").scrollIntoView(); // Scroll to element
// --- Assertions ---
cy.get("h1").should("exist"); // Exists in DOM
cy.get("h1").should("be.visible"); // Visible on screen
cy.get("h1").should("contain", "Welcome"); // Contains text
cy.get("h1").should("have.text", "Welcome Home"); // Exact text
cy.get("input").should("have.value", "hello"); // Input value
cy.get("li").should("have.length", 5); // Count elements
cy.get("button").should("be.disabled"); // Disabled state
cy.get("div").should("have.css", "color", "rgb(255, 0, 0)");
cy.url().should("include", "/dashboard"); // URL check
cy.title().should("eq", "My App"); // Page title自定义命令
自定义命令将可重复的工作流封装为可复用函数。在 cypress/support/commands.ts 中定义,可在每个测试文件中使用 cy.yourCommand()。
// cypress/support/commands.ts
// Login command — reusable across all test files
Cypress.Commands.add("login", (email: string, password: string) => {
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 login — faster, bypasses UI
Cypress.Commands.add("loginByApi", (email: string, password: string) => {
cy.request("POST", "/api/auth/login", { email, password })
.then((response) => {
window.localStorage.setItem("token", response.body.token);
});
});
// Get by data-cy shortcut
Cypress.Commands.add("getByCy", (selector: string) => {
return cy.get('[data-cy="' + selector + '"]');
});
// TypeScript declarations
// cypress/support/index.d.ts
declare namespace Cypress {
interface Chainable {
login(email: string, password: string): Chainable<void>;
loginByApi(email: string, password: string): Chainable<void>;
getByCy(selector: string): Chainable<JQuery<HTMLElement>>;
}
}
// Usage in tests:
// cy.login("admin@example.com", "password123");
// cy.getByCy("submit-button").click();Fixtures 和测试数据
Fixtures 是存储在 cypress/fixtures 目录中的外部数据文件(通常是 JSON)。它们将测试数据与测试逻辑分离,使测试更整洁,数据可跨多个规格文件复用。
// cypress/fixtures/users.json
{
"admin": {
"email": "admin@example.com",
"password": "adminPass123",
"role": "admin"
},
"regular": {
"email": "user@example.com",
"password": "userPass456",
"role": "user"
}
}
// cypress/fixtures/products.json
[
{ "id": 1, "name": "Widget A", "price": 29.99, "stock": 150 },
{ "id": 2, "name": "Widget B", "price": 49.99, "stock": 75 },
{ "id": 3, "name": "Widget C", "price": 99.99, "stock": 0 }
]
// Using fixtures in tests
describe("Product Listing", () => {
beforeEach(() => {
// Load fixture and use it to stub the API
cy.fixture("products").then((products) => {
cy.intercept("GET", "/api/products", {
statusCode: 200,
body: products,
}).as("getProducts");
});
cy.visit("/products");
cy.wait("@getProducts");
});
it("displays all products", () => {
cy.get("[data-cy=product-card]").should("have.length", 3);
});
it("shows out-of-stock badge", () => {
cy.get("[data-cy=product-card]")
.last()
.find("[data-cy=out-of-stock-badge]")
.should("be.visible");
});
// Shorthand: cy.fixture as intercept body
it("can use fixture shorthand", () => {
cy.intercept("GET", "/api/products", {
fixture: "products.json",
}).as("getProducts");
});
});使用 cy.intercept() 进行网络拦截
cy.intercept() 是 Cypress 最强大的功能之一。它可以存根 API 响应、监听网络流量和模拟错误条件,无需运行后端服务器。这使测试更快、更可靠,能够测试真实 API 难以重现的边界情况。
// --- Stubbing API responses ---
cy.intercept("GET", "/api/users", {
statusCode: 200,
body: [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }],
}).as("getUsers");
cy.visit("/users");
cy.wait("@getUsers"); // Wait for the request
cy.get("[data-cy=user-row]").should("have.length", 2);
// --- Simulating errors ---
cy.intercept("POST", "/api/orders", {
statusCode: 500,
body: { error: "Internal server error" },
}).as("createOrder");
cy.get("[data-cy=place-order]").click();
cy.wait("@createOrder");
cy.get("[data-cy=error-toast]")
.should("contain", "Something went wrong");
// --- Simulating network delay ---
cy.intercept("GET", "/api/dashboard", (req) => {
req.reply({
delay: 3000, // 3 second delay
statusCode: 200,
body: { stats: { users: 100 } },
});
}).as("getDashboard");
cy.visit("/dashboard");
cy.get("[data-cy=loading-spinner]").should("be.visible");
cy.wait("@getDashboard");
cy.get("[data-cy=loading-spinner]").should("not.exist");
// --- Spying (observe without modifying) ---
cy.intercept("POST", "/api/analytics").as("analytics");
cy.get("[data-cy=buy-button]").click();
cy.wait("@analytics").then((interception) => {
expect(interception.request.body).to.have.property("event", "purchase");
expect(interception.response.statusCode).to.equal(200);
});
// --- Modifying requests ---
cy.intercept("GET", "/api/products*", (req) => {
// Add auth header to all product requests
req.headers["Authorization"] = "Bearer test-token";
req.continue();
});
// --- Conditional responses ---
let callCount = 0;
cy.intercept("GET", "/api/status", (req) => {
callCount += 1;
if (callCount === 1) {
req.reply({ status: "pending" });
} else {
req.reply({ status: "complete" });
}
}).as("checkStatus");组件测试
Cypress 组件测试让你在隔离环境中挂载和测试单个 UI 组件,使用真实浏览器而非 jsdom 模拟 DOM。这能捕获单元测试遗漏的渲染问题、CSS 问题和交互 Bug。组件测试比 E2E 测试快得多。
// src/components/Button.cy.tsx
import Button from "./Button";
describe("<Button />", () => {
it("renders with default props", () => {
cy.mount(<Button>Click Me</Button>);
cy.get("button")
.should("contain", "Click Me")
.and("not.be.disabled");
});
it("fires onClick handler", () => {
const onClick = cy.stub().as("clickHandler");
cy.mount(<Button onClick={onClick}>Submit</Button>);
cy.get("button").click();
cy.get("@clickHandler").should("have.been.calledOnce");
});
it("renders disabled state", () => {
cy.mount(<Button disabled>Disabled</Button>);
cy.get("button")
.should("be.disabled")
.and("have.css", "opacity", "0.5");
});
it("renders different variants", () => {
cy.mount(<Button variant="danger">Delete</Button>);
cy.get("button")
.should("have.css", "background-color", "rgb(239, 68, 68)");
});
});
// src/components/SearchBar.cy.tsx
import SearchBar from "./SearchBar";
describe("<SearchBar />", () => {
it("calls onSearch after debounce", () => {
const onSearch = cy.stub().as("searchHandler");
cy.mount(<SearchBar onSearch={onSearch} debounceMs={300} />);
cy.get("[data-cy=search-input]").type("cypress testing");
cy.get("@searchHandler").should("not.have.been.called");
// Wait for debounce
cy.wait(350);
cy.get("@searchHandler")
.should("have.been.calledWith", "cypress testing");
});
it("clears input on escape key", () => {
cy.mount(<SearchBar onSearch={cy.stub()} />);
cy.get("[data-cy=search-input]")
.type("test{esc}")
.should("have.value", "");
});
});CI/CD 集成
在 CI 中运行 Cypress 需要无头浏览器和适当的缓存配置。Cypress 提供官方 Docker 镜像和 GitHub Actions。
GitHub Actions
# .github/workflows/cypress.yml
name: Cypress Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
cypress-run:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Cypress run
uses: cypress-io/github-action@v6
with:
build: npm run build
start: npm start
wait-on: "http://localhost:3000"
wait-on-timeout: 120
browser: chrome
record: false
- name: Upload screenshots on failure
uses: actions/upload-artifact@v4
if: failure()
with:
name: cypress-screenshots
path: cypress/screenshots
retention-days: 7Docker
# Dockerfile.cypress
FROM cypress/included:13.6.0
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# Run tests
CMD ["npx", "cypress", "run", "--browser", "chrome"]
# docker-compose.yml
# services:
# app:
# build: .
# ports:
# - "3000:3000"
# cypress:
# image: cypress/included:13.6.0
# depends_on:
# - app
# environment:
# - CYPRESS_baseUrl=http://app:3000
# volumes:
# - ./cypress:/app/cypress
# - ./cypress.config.ts:/app/cypress.config.tspackage.json Scripts
{
"scripts": {
"cy:open": "cypress open",
"cy:run": "cypress run",
"cy:run:chrome": "cypress run --browser chrome",
"cy:run:firefox": "cypress run --browser firefox",
"cy:component": "cypress run --component",
"cy:headed": "cypress run --headed",
"test:e2e": "start-server-and-test start http://localhost:3000 cy:run",
"test:e2e:dev": "start-server-and-test dev http://localhost:3000 cy:open"
}
}最佳实践
遵循这些实践来编写随应用增长仍然可靠、快速和可维护的测试。
- 所有选择器使用 data-cy 属性。绝不依赖可能变化的 CSS 类、元素结构或生成的 ID。
- 保持测试独立。每个测试应使用 cy.intercept()、cy.session() 或 API 调用设置自己的状态。
- 避免使用固定毫秒数的 cy.wait()。使用 cy.intercept() 别名和 cy.wait("@alias") 等待特定网络响应。
- 使用 beforeEach 进行通用设置。登录流程、页面导航和数据预置应在 beforeEach 中,而非在每个测试中重复。
- 编写小而专注的测试。每个 it() 块应测试一个特定行为。避免难以调试的多步骤大型测试。
- 使用 cy.session() 进行身份验证。它跨测试缓存会话数据,无需在每个规格文件前登录。
- 按功能组织 fixtures。使用目录如 cypress/fixtures/users/、cypress/fixtures/products/ 来管理测试数据。
- 在 CI 中禁用视频录制除非调试。视频会减慢 CI 运行并消耗存储。仅在调查失败时启用。
- 在 CI 中重试失败的测试。使用 retries: { runMode: 2 } 处理临时 CI 环境问题。
- 使用 TypeScript 编写测试。对 cy 命令、自定义命令和 fixtures 的类型推断可在测试运行前捕获错误。
Anti-Patterns to Avoid
// ANTI-PATTERN: Fixed waits
cy.wait(5000); // Never do this
// CORRECT: Wait for specific condition
cy.intercept("GET", "/api/data").as("getData");
cy.wait("@getData");
// ANTI-PATTERN: Testing implementation details
cy.get("div.MuiButton-root.MuiButton-contained");
// CORRECT: Test user-facing behavior
cy.get("[data-cy=submit-button]");
// ANTI-PATTERN: Shared state between tests
let savedId;
it("creates item", () => { savedId = "..."; });
it("uses item", () => { cy.visit("/item/" + savedId); });
// CORRECT: Each test sets up its own state
it("edits an item", () => {
// Create via API, then test
cy.request("POST", "/api/items", { name: "Test" })
.then((res) => {
cy.visit("/item/" + res.body.id);
});
});
// ANTI-PATTERN: Giant multi-step test
it("does everything", () => {
// login, navigate, create, edit, delete...
// 100 lines of commands
});
// CORRECT: Focused tests with shared setup
describe("Item management", () => {
beforeEach(() => {
cy.login("admin@test.com", "password");
cy.visit("/items");
});
it("creates an item", () => { /* ... */ });
it("edits an item", () => { /* ... */ });
it("deletes an item", () => { /* ... */ });
});常见问题
Cypress 和 Selenium 有什么区别?
Cypress 在浏览器内与应用在同一执行循环中运行,而 Selenium 从外部进程通过 WebDriver 协议发送命令。Cypress 原生访问 DOM、网络层和 JavaScript 上下文,自动等待元素,提供时间旅行调试。Selenium 支持更多语言和浏览器,但需要更多设置且更容易产生不稳定测试。
Cypress 能测试需要登录的应用吗?
可以。Cypress 提供 cy.session() 跨测试缓存认证状态。你可以通过 API 调用或 UI 交互登录一次,Cypress 会为后续测试恢复会话。也可以使用 cy.intercept() 模拟认证响应。
Cypress 支持多种浏览器吗?
Cypress 支持 Chrome、Chromium、Edge、Firefox 和 WebKit(实验性)。可以在运行测试时使用 --browser 参数指定浏览器。Electron 是默认浏览器,无需额外设置。
如何处理 Cypress 中的不稳定测试?
首先找到根本原因。常见原因包括:网络请求竞态条件(用 cy.intercept 和别名解决)、动画干扰(在配置中设置 waitForAnimations: true)和测试间共享状态(让每个测试独立)。在 CI 环境中使用 retries 配置。
Cypress 能直接测试 API 端点吗?
可以。cy.request() 直接发送 HTTP 请求而不经过浏览器 UI。这对 API 测试、设置测试数据和 UI 测试前认证很有用。它返回响应体、头部和状态码供断言使用。
如何用 Cypress 测试文件上传?
使用 selectFile 命令(Cypress 9.3+):cy.get("input[type=file]").selectFile("path/to/file.pdf")。对于拖放上传,使用 { action: "drag-drop" } 选项。也可以用 cy.fixture() 从 fixtures 目录加载测试文件。
cy.intercept() 和 cy.request() 有什么区别?
cy.intercept() 拦截浏览器在页面交互时自然发出的网络请求,可以存根响应或监听流量。cy.request() 从测试代码直接发送 HTTP 请求,绕过浏览器。测试 UI 如何处理不同 API 响应时用 cy.intercept(),设置测试数据或测试 API 时用 cy.request()。
如何将 Cypress 与 TypeScript 集成?
Cypress 从版本 10 起内置 TypeScript 支持。在 cypress 目录创建 tsconfig.json。对于自定义命令,在 global.d.ts 文件中扩展 Cypress 命名空间以获得类型推断。所有规格文件可使用 .cy.ts 扩展名。cypress.config.ts 本身也可以用 TypeScript 编写。