Cypress 最佳实践 #

测试组织 #

目录结构 #

text
cypress/
├── e2e/
│   ├── auth/                    # 按功能模块组织
│   │   ├── login.cy.js
│   │   ├── register.cy.js
│   │   └── password-reset.cy.js
│   ├── user/
│   │   ├── profile.cy.js
│   │   └── settings.cy.js
│   └── api/
│       └── users.cy.js
├── support/
│   ├── commands/                # 自定义命令
│   ├── pages/                   # 页面对象
│   ├── components/              # 组件对象
│   └── utils/                   # 工具函数
├── fixtures/                    # 测试数据
└── plugins/                     # 插件

测试文件命名 #

javascript
// ✅ 好的命名 - 清晰表达测试内容
login.cy.js
user-profile.cy.js
api-users.cy.js

// ❌ 不好的命名
test1.cy.js
temp.cy.js
new.cy.js

测试套件组织 #

javascript
// ✅ 好的组织 - 清晰的层次结构
describe('用户管理', () => {
  describe('用户列表', () => {
    context('当用户已登录', () => {
      it('应该显示用户列表', () => {});
    });
    
    context('当用户未登录', () => {
      it('应该跳转到登录页面', () => {});
    });
  });
  
  describe('用户详情', () => {
    it('应该显示用户信息', () => {});
  });
});

选择器策略 #

使用 data 属性 #

html
<!-- ✅ 好的做法 -->
<button data-cy="submit-button">提交</button>
<input data-cy="email-input" type="email" />
<div data-cy="error-message">错误</div>
javascript
// ✅ 好的做法 - 使用 data 属性
cy.get('[data-cy="submit-button"]').click();
cy.get('[data-cy="email-input"]').type('test@example.com');

// ❌ 不好的做法 - 依赖样式类
cy.get('.btn.btn-primary.btn-lg').click();
cy.get('.form-control.email').type('test@example.com');

// ❌ 不好的做法 - 依赖 DOM 结构
cy.get('body > div > form > div > button').click();

选择器优先级 #

text
1. [data-cy="..."]        ⭐⭐⭐⭐⭐  最推荐
2. [data-test="..."]      ⭐⭐⭐⭐
3. [data-testid="..."]    ⭐⭐⭐⭐
4. #id                    ⭐⭐⭐
5. .class                 ⭐⭐
6. tag.class              ⭐
7. DOM 结构选择器          ❌ 避免

测试数据管理 #

使用 Fixtures #

javascript
// ✅ 好的做法 - 使用 fixture 管理数据
cy.fixture('users').then((users) => {
  cy.intercept('GET', '/api/users', users);
});

// ❌ 不好的做法 - 硬编码数据
cy.intercept('GET', '/api/users', [
  { id: 1, name: 'User 1' },
  { id: 2, name: 'User 2' }
]);

数据隔离 #

javascript
// ✅ 好的做法 - 每个测试独立
beforeEach(() => {
  cy.resetDatabase();
  cy.login('testuser', 'password');
});

afterEach(() => {
  cy.cleanupTestData();
});

// ❌ 不好的做法 - 测试之间共享状态
let sharedData;

it('测试1', () => {
  sharedData = 'data';
});

it('测试2', () => {
  // 依赖测试1的结果
  cy.log(sharedData);
});

异步处理 #

避免固定等待 #

javascript
// ✅ 好的做法 - 等待条件
cy.get('.loading').should('not.exist');
cy.get('.data').should('be.visible');

// ❌ 不好的做法 - 固定等待
cy.wait(2000);
cy.get('.data').click();

正确使用 then() #

javascript
// ✅ 好的做法 - 在 then() 中处理值
cy.get('.user-id').then(($el) => {
  const userId = $el.text();
  cy.request(`/api/users/${userId}`);
});

// ❌ 不好的做法 - 直接使用返回值
const userId = cy.get('.user-id').text();  // 错误!

网络请求 #

使用 intercept #

javascript
// ✅ 好的做法 - 使用 intercept 模拟
cy.intercept('GET', '/api/users', { fixture: 'users.json' }).as('getUsers');
cy.visit('/users');
cy.wait('@getUsers');

// ❌ 不好的做法 - 依赖真实 API
cy.visit('/users');  // 可能因网络问题失败

验证请求 #

javascript
// ✅ 好的做法 - 验证请求和响应
cy.intercept('POST', '/api/users').as('createUser');
cy.get('.submit').click();
cy.wait('@createUser').then((interception) => {
  expect(interception.request.body.name).to.eq('John');
  expect(interception.response.statusCode).to.eq(201);
});

测试断言 #

清晰的断言 #

javascript
// ✅ 好的做法 - 清晰的断言
cy.get('.status').should('contain', '成功');
cy.get('.user-name').should('have.text', 'John Doe');
cy.get('.error').should('be.visible');

// ❌ 不好的做法 - 模糊的断言
cy.get('.status').should('exist');
cy.get('.user-name').should('not.be.empty');

合理的断言数量 #

javascript
// ✅ 好的做法 - 验证关键行为
it('登录成功后跳转到首页', () => {
  cy.login('user', 'pass');
  cy.url().should('include', '/dashboard');
  cy.get('.welcome').should('be.visible');
});

// ❌ 不好的做法 - 过度断言
it('验证所有细节', () => {
  cy.get('.header').should('have.css', 'background-color', 'rgb(0, 0, 0)');
  cy.get('.header').should('have.css', 'height', '60px');
  cy.get('.header').should('have.css', 'padding', '10px');
  // ... 太多断言
});

自定义命令 #

封装常用操作 #

javascript
// ✅ 好的做法 - 封装为自定义命令
Cypress.Commands.add('login', (username, password) => {
  cy.session([username, password], () => {
    cy.request({
      method: 'POST',
      url: '/api/auth/login',
      body: { username, password }
    }).then((response) => {
      window.localStorage.setItem('token', response.body.token);
    });
  });
});

// 使用
cy.login('testuser', 'password');

命令命名 #

javascript
// ✅ 好的命名
Cypress.Commands.add('loginAsAdmin', () => {});
Cypress.Commands.add('waitForPageLoad', () => {});
Cypress.Commands.add('createTestUser', () => {});

// ❌ 不好的命名
Cypress.Commands.add('doIt', () => {});
Cypress.Commands.add('test', () => {});

页面对象模式 #

使用页面对象 #

javascript
// ✅ 好的做法 - 使用页面对象
const loginPage = new LoginPage();
loginPage
  .visit()
  .login('user', 'pass');

// ❌ 不好的做法 - 直接操作
cy.visit('/login');
cy.get('#username').type('user');
cy.get('#password').type('pass');
cy.get('button').click();

性能优化 #

会话缓存 #

javascript
// ✅ 好的做法 - 使用 session 缓存登录
Cypress.Commands.add('login', (username, password) => {
  cy.session([username, password], () => {
    // 登录逻辑
  }, {
    cacheAcrossSpecs: true
  });
});

并行执行 #

javascript
// cypress.config.js
module.exports = {
  e2e: {
    specPattern: 'cypress/e2e/**/*.cy.js',
    // 配置并行执行
  }
};

// 运行时指定
// npx cypress run --parallel

减少视频录制 #

javascript
// cypress.config.js
module.exports = {
  e2e: {
    video: false,  // 禁用视频
    screenshotOnRunFailure: true  // 仅失败时截图
  }
};

CI/CD 集成 #

GitHub Actions 配置 #

yaml
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: Install dependencies
        run: npm ci
        
      - name: Run Cypress tests
        uses: cypress-io/github-action@v6
        with:
          browser: chrome
          start: npm start
          wait-on: 'http://localhost:3000'
          
      - name: Upload artifacts
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: cypress-results
          path: |
            cypress/screenshots
            cypress/videos

缓存优化 #

yaml
- name: Cache Cypress binary
  uses: actions/cache@v3
  with:
    path: ~/.cache/Cypress
    key: cypress-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}

错误处理 #

处理应用错误 #

javascript
// 忽略特定错误
Cypress.on('uncaught:exception', (err) => {
  if (err.message.includes('ResizeObserver')) {
    return false;  // 忽略 ResizeObserver 错误
  }
  return true;
});

重试机制 #

javascript
// 配置重试
// cypress.config.js
module.exports = {
  e2e: {
    retries: {
      runMode: 2,   // CI 模式重试 2 次
      openMode: 0   // 开发模式不重试
    }
  }
};

测试数据清理 #

测试前后清理 #

javascript
describe('用户测试', () => {
  before(() => {
    cy.resetDatabase();
  });
  
  beforeEach(() => {
    cy.login('testuser', 'password');
  });
  
  afterEach(() => {
    cy.clearCookies();
    cy.clearLocalStorage();
  });
  
  after(() => {
    cy.cleanupTestData();
  });
});

可访问性测试 #

使用 cypress-axe #

javascript
// 安装
// npm install --save-dev cypress-axe

// cypress/support/e2e.js
import 'cypress-axe';

// 使用
it('应该没有可访问性问题', () => {
  cy.visit('/');
  cy.injectAxe();
  cy.checkA11y();
});

调试技巧 #

使用日志 #

javascript
// ✅ 好的做法 - 添加有意义的日志
cy.log('开始登录流程');
cy.get('#username').type('user');
cy.log('用户名输入完成');
cy.get('#password').type('pass');
cy.log('密码输入完成');

使用截图 #

javascript
// 关键步骤截图
cy.get('.form').screenshot('before-submit');
cy.get('.submit').click();
cy.screenshot('after-submit');

完整示例 #

最佳实践测试文件 #

javascript
// cypress/e2e/auth/login.cy.js
import LoginPage from '../../support/pages/LoginPage';
import DashboardPage from '../../support/pages/DashboardPage';

describe('登录功能', () => {
  const loginPage = new LoginPage();
  const dashboardPage = new DashboardPage();
  
  const testUser = {
    username: 'testuser',
    password: 'Password123!'
  };

  before(() => {
    cy.resetDatabase();
  });

  beforeEach(() => {
    cy.clearCookies();
    cy.clearLocalStorage();
    loginPage.visit();
  });

  after(() => {
    cy.cleanupTestData();
  });

  context('表单验证', () => {
    it('空表单应该显示验证错误', () => {
      loginPage.clickLoginButton();
      loginPage.verifyErrorMessage('请填写用户名');
    });

    it('无效邮箱格式应该显示错误', () => {
      loginPage
        .typeUsername('invalid-email')
        .typePassword('password')
        .clickLoginButton();
      
      loginPage.verifyErrorMessage('邮箱格式不正确');
    });
  });

  context('登录流程', () => {
    it('正确的凭据应该登录成功', () => {
      cy.intercept('POST', '/api/auth/login').as('login');
      
      loginPage.login(testUser.username, testUser.password);
      
      cy.wait('@login').then((interception) => {
        expect(interception.response.statusCode).to.eq(200);
      });
      
      dashboardPage.verifyWelcomeMessage(testUser.username);
    });

    it('错误的密码应该显示错误提示', () => {
      loginPage
        .typeUsername(testUser.username)
        .typePassword('wrongpassword')
        .clickLoginButton();
      
      loginPage.verifyErrorMessage('用户名或密码错误');
    });
  });

  context('性能测试', () => {
    it('登录响应时间应该小于 2 秒', () => {
      cy.intercept('POST', '/api/auth/login').as('login');
      
      loginPage.login(testUser.username, testUser.password);
      
      cy.wait('@login').its('duration').should('be.lessThan', 2000);
    });
  });
});

检查清单 #

测试编写检查清单 #

  • [ ] 使用 data-cy 属性选择器
  • [ ] 测试之间相互独立
  • [ ] 使用有意义的测试名称
  • [ ] 避免固定等待
  • [ ] 验证关键行为
  • [ ] 清理测试数据
  • [ ] 使用页面对象模式
  • [ ] 添加适当的日志
  • [ ] 处理异步操作正确
  • [ ] 配置合理的超时

CI/CD 检查清单 #

  • [ ] 配置缓存
  • [ ] 设置合理的重试次数
  • [ ] 上传失败截图和视频
  • [ ] 配置并行执行
  • [ ] 设置测试超时
  • [ ] 配置环境变量
  • [ ] 使用 session 缓存

总结 #

遵循这些最佳实践可以帮助你编写高质量、可维护的 Cypress 测试:

  1. 组织清晰:合理的目录结构和命名
  2. 选择器稳定:使用 data 属性
  3. 数据隔离:每个测试独立
  4. 异步正确:避免固定等待
  5. 断言明确:验证关键行为
  6. 代码复用:使用自定义命令和页面对象
  7. 性能优化:使用会话缓存和并行执行
  8. CI/CD 友好:配置合理的重试和超时

持续学习和实践这些最佳实践,你的测试代码质量会不断提升!

最后更新:2026-03-28