端到端(E2E)测试学习笔记

什么是端到端测试 (E2E Testing)?

端到端测试是一种软件测试方法,用于验证整个应用程序的流程是否从用户的角度按预期工作。它模拟真实的用户场景,从用户界面 (UI) 开始,通过应用程序的各个层面(包括与其他系统、数据库、网络等的交互),直到最终输出或结果。

与单元测试/集成测试的区别:

  • 单元测试 (Unit Testing) :测试最小的可测试单元(如函数、类、组件的独立部分),关注代码逻辑的正确性,通常是隔离的。
  • 集成测试 (Integration Testing) :测试多个单元或模块组合在一起时的交互是否正确。例如,测试前端组件与其依赖的服务模块的交互。
  • 端到端测试 (End-to-End Testing) :测试整个应用程序的完整流程,从用户的视角出发,覆盖从 UI 到后端的完整链路。

可以把它们想象成测试一辆汽车:

  • 单元测试:测试引擎的某个零件(火花塞)是否工作正常。
  • 集成测试:测试引擎组装完成后是否能正常启动并提供动力。
  • 端到端测试:测试驾驶员能否启动汽车,挂挡,踩油门,刹车,并顺利从 A 点开到 B 点。

为什么需要端到端测试?

  • 模拟真实用户场景:E2E 测试最接近用户实际使用应用的方式,能发现单元测试和集成测试可能遗漏的问题。
  • 验证系统集成:确保应用程序的各个部分(前端、后端、数据库、第三方服务等)能够正确协同工作。
  • 提高信心:通过覆盖关键用户流程,E2E 测试为应用程序的整体质量和稳定性提供了高度的信心,尤其是在部署新版本之前。
  • 减少回归风险:随着应用的迭代,E2E 测试可以帮助捕捉由于代码更改而引入的回归错误。
  • 文档作用:良好的 E2E 测试用例本身也可以作为应用程序功能和用户流程的活文档。

端到端测试的挑战

  • 速度慢:E2E 测试需要启动浏览器,加载页面,模拟用户操作,这通常比单元测试和集成测试慢得多。
  • 不稳定 (Flaky Tests) :由于依赖外部环境(网络、服务器状态)、异步操作、UI 渲染时序等因素,E2E 测试有时会无缘无故地失败,增加了维护的复杂性。
  • 维护成本高:UI 的变化(即使很小)可能导致大量 E2E 测试用例失效,需要频繁更新。
  • 环境依赖:需要一个完整的、可运行的测试环境,包括前端、后端和可能的数据库。
  • 调试困难:当 E2E 测试失败时,定位问题的根本原因可能比较复杂,因为它涉及到整个应用栈。

主流 E2E 测试框架简介

有许多优秀的前端 E2E 测试框架可供选择:

  1. Cypress:

    • 优点: 一体化测试运行器和仪表盘,对开发者友好,调试体验佳(时间旅行、自动截图/录屏),自动等待,网络请求控制强大,文档完善,社区活跃。直接在浏览器中运行测试,与应用在同一事件循环中。
    • 缺点: 仅支持 JavaScript/TypeScript 编写测试,官方主要支持 Chrome 系浏览器和 Firefox(对 Safari 的支持仍在实验阶段),不支持同时控制多个浏览器标签页或窗口(有变通方法但非原生支持)。
  2. Playwright (Microsoft) :

    • 优点: 支持多种语言 (JavaScript, TypeScript, Python, Java, C#),跨浏览器 (Chromium, Firefox, WebKit/Safari),支持并行测试,自动等待,强大的网络拦截,支持移动端模拟,支持多标签页/窗口/iframe。
    • 缺点: 相对 Cypress 学习曲线稍陡峭一些,社区生态仍在发展中。
  3. Selenium:

    • 优点: 最老牌、最成熟的框架,支持几乎所有浏览器和编程语言,拥有庞大的社区和丰富的资源。
    • 缺点: API 相对底层,配置和使用起来比较复杂,需要 WebDriver,测试编写和维护成本较高,原生等待机制较弱。
  4. Puppeteer (Google) :

    • 优点: Google Chrome 团队开发,主要用于控制 Chrome/Chromium (也支持 Firefox),API 丰富,非常适合浏览器自动化任务和爬虫,性能较好。
    • 缺点: 主要针对 Chrome,跨浏览器支持有限,更偏向于浏览器自动化库而非完整的测试框架(需要与其他测试运行器如 Jest 结合)。

我们将重点使用 Cypress 进行详细的代码讲解。

Cypress 深度解析与工作流程

我们将围绕一个假设的简单 Web 应用(例如一个任务管理应用或简单的博客)来编写 Cypress 测试。

1. 环境搭建与项目初始化

前提: 你需要安装 Node.js 和 npm (或 yarn)。

步骤:

  1. 创建项目目录并初始化:

    bash 复制代码
    mkdir cypress-e2e-demo
    cd cypress-e2e-demo
    npm init -y
  2. 安装 Cypress:

    bash 复制代码
    npm install cypress --save-dev

    或者使用 yarn:

    bash 复制代码
    yarn add cypress --dev
  3. 打开 Cypress Test Runner (首次运行会初始化项目结构) :

    bash 复制代码
    npx cypress open

    或者,将其添加到 package.jsonscripts 中:

    json 复制代码
    // package.json
    {
      "scripts": {
        "cy:open": "cypress open",
        "cy:run": "cypress run"
      }
    }

    然后运行 npm run cy:open

    首次运行 cypress open 时,Cypress 会在你的项目根目录下创建以下结构:

    scss 复制代码
    cypress-e2e-demo/
    ├── cypress/
    │   ├── fixtures/         # 存放测试数据,如 JSON 文件
    │   │   └── example.json
    │   ├── integration/      # (Cypress 9 及更早版本) 存放测试 spec 文件 (现在是 e2e/)
    │   ├── e2e/              # (Cypress 10+) 存放测试 spec 文件
    │   │   └── (示例 spec 文件)
    │   ├── plugins/          # (Cypress 9 及更早版本) 存放插件 (现在集成到配置文件)
    │   ├── support/          # 存放自定义命令和全局配置
    │   │   ├── commands.js   # 注册自定义命令
    │   │   ├── e2e.js        # (Cypress 10+) E2E 测试的全局 setup
    │   │   └── index.js      # (Cypress 9 及更早版本) 全局 setup
    │   └── (其他文件夹如 downloads, screenshots, videos)
    ├── cypress.config.js     # (Cypress 10+) Cypress 配置文件
    ├── node_modules/
    └── package.json

    Cypress Test Runner 会打开一个图形界面,允许你选择浏览器、查看和运行测试文件。

2. 第一个 Cypress 测试

cypress/e2e/ 目录下创建一个新的测试文件,例如 first_test.cy.js

(Cypress 约定测试文件以 .cy.js, .cy.ts, .spec.js 等结尾)。

js 复制代码
// cypress/e2e/first_test.cy.js

// describe 用于组织测试套件,可以嵌套
describe('My First Test Suite', () => {
  // it 或 test 用于定义单个测试用例
  it('Visits the Kitchen Sink and checks title', () => {
    // cy 是 Cypress 的全局对象,所有 Cypress 命令都从它开始
    // cy.visit() 用于导航到指定的 URL
    cy.visit('https://example.cypress.io'); // Cypress 官方提供的示例网站

    // cy.title() 获取当前页面的标题
    // .should() 是一个断言方法,可以链接多种 Chai.js 断言
    cy.title().should('include', 'Cypress.io: Kitchen Sink'); // 断言标题包含特定文本
  });

  it('Finds an element and interacts with it', () => {
    cy.visit('https://example.cypress.io/commands/actions');

    // cy.get() 用于根据选择器获取一个或多个 DOM 元素
    // .type() 用于在可输入的元素中输入文本
    cy.get('.action-email') // 通过 class 选择器获取元素
      .type('[email protected]')
      .should('have.value', '[email protected]'); // 断言输入框的值

    // .click() 用于点击元素
    cy.get('.action-btn').click();

    // 检查元素是否可见
    cy.get('#action-canvas').should('be.visible');
  });
});

运行测试 :

在 Cypress Test Runner 中,点击 first_test.cy.js 文件名即可运行。你会看到浏览器自动执行这些操作,并在左侧面板显示命令日志。

3. 元素定位与选择器

Cypress 提供了多种定位元素的方法:

  • cy.get(selector, options?) : 最常用的命令,使用 CSS 选择器。

    • ID: cy.get('#my-id')
    • Class: cy.get('.my-class')
    • Attribute: cy.get('[name="email"]'), cy.get('[data-testid="submit-button"]')
    • Tag: cy.get('button')
    • 组合和层级: cy.get('form .field input[type="text"]')
    • 伪类: cy.get('li:first-child')
  • cy.contains(text, options?)cy.contains(selector, text, options?) : 根据文本内容定位元素。

    • cy.contains('Submit') // 查找任何包含 "Submit" 文本的元素
    • cy.contains('button', 'Submit') // 查找包含 "Submit" 文本的 button 元素
    • 支持正则表达式: cy.contains(/^Submit$/) // 精确匹配

最佳实践:使用 data-* 属性进行测试

为了使测试更健壮,不易受样式或文本内容(可能因国际化而改变)变化的影响,推荐使用专门为测试添加的 data-* 属性,如 data-cydata-testid

HTML:

html 复制代码
<button data-cy="login-button">Login</button>
<input type="email" data-testid="email-input" />

Cypress 测试:

js 复制代码
cy.get('[data-cy="login-button"]').click();
cy.get('[data-testid="email-input"]').type('[email protected]');

4. 交互操作

Cypress 提供了丰富的命令来模拟用户交互:

  • .type(text, options?) : 输入文本。

    js 复制代码
    cy.get('input[name="username"]').type('john.doe');
    cy.get('textarea').type('This is a long message.{enter}With a new line.'); // {enter} 等特殊字符
  • .click(options?) : 点击元素。

    js 复制代码
    cy.get('button.submit').click();
    cy.contains('Next Page').click();
    cy.get('div').click('topLeft'); // 点击元素的特定位置
    cy.get('button').click({ multiple: true }); // 点击所有匹配的按钮
    cy.get('button').click({ force: true }); // 强制点击,即使元素不可见或被覆盖(谨慎使用)
  • .dblclick(options?) : 双击元素。

  • .rightclick(options?) : 右键点击元素。

  • .select(valueOrTextOrIndex, options?) : 选择 <select> 下拉框中的选项。

    js 复制代码
    cy.get('select[name="country"]').select('USA'); // 按 value 或文本选择
    cy.get('select[name="options"]').select(['option1', 'option3']); // 多选
  • .check(options?) : 选中复选框或单选按钮。

    js 复制代码
    cy.get('input[type="checkbox"][value="agree"]').check();
    cy.get('input[type="radio"][name="gender"]').check('female');
  • .uncheck(options?) : 取消选中复选框。

  • .clear(options?) : 清除输入框或文本域的内容。

  • .focus() , .blur() : 触发焦点和失焦事件。

  • .trigger(eventName, options?) : 触发任意 DOM 事件。

    js 复制代码
    cy.get('.menu').trigger('mouseover');
    cy.get('.draggable').trigger('mousedown').trigger('mousemove', { clientX: 100, clientY: 200 }).trigger('mouseup');
  • .scrollIntoView(options?) : 将元素滚动到可视区域。

  • .scrollTo(position, options?).scrollTo(x, y, options?) : 滚动窗口或可滚动元素。

    js 复制代码
    cy.scrollTo('bottom'); // 滚动到页面底部
    cy.get('.scrollable-div').scrollTo(0, 500); // 滚动 div 到垂直位置 500px

5. 断言 (Assertions)

Cypress 内置了 Chai、Chai-jQuery 和 Sinon-Chai 断言库,最常用的是通过 .should().and() (与 .should() 等价,用于链式可读性) 进行断言。

js 复制代码
// cypress/e2e/assertions_test.cy.js
describe('Assertion Examples', () => {
  beforeEach(() => {
    // 假设我们有一个简单的 HTML 页面用于测试
    // content of public/test-page.html
    /*
    <!DOCTYPE html>
    <html>
    <head><title>Test Page</title></head>
    <body>
      <h1 id="main-heading" class="title">Welcome!</h1>
      <p class="subtext" style="color: blue;">This is a test page.</p>
      <input type="text" id="name-input" value="Cypress" />
      <input type="checkbox" id="agree-checkbox" checked disabled />
      <button id="submit-btn" class="btn primary">Submit</button>
      <div id="message-area" style="display: none;">Success!</div>
      <ul>
        <li>Item 1</li>
        <li>Item 2</li>
        <li>Item 3</li>
      </ul>
    </body>
    </html>
    */
    // 你需要确保这个 public/test-page.html 文件存在,并且 Cypress 可以访问它
    // 通常通过配置 baseUrl 或直接访问文件路径 (如果测试服务器已配置)
    // 为了简单,我们这里假设 baseUrl 指向一个能提供此页面的服务
    // 如果没有服务,可以 cy.visit('public/test-page.html') 如果 cypress.config.js 中配置了 e2e: { supportFile: false }
    // 或者直接用 file:/// 协议,但这不推荐用于真实应用测试
    // 更好的方式是启动一个简单的 HTTP 服务器服务 public 目录
    // 例如,使用 `npx http-server public -p 8081`,然后在测试中 `cy.visit('http://localhost:8081/test-page.html')`
    // 这里我们假设 `baseUrl` 已配置为 `http://localhost:8081`
    // 并且 `test-page.html` 在 `public` 目录下
    cy.visit('/test-page.html'); // 路径相对于 baseUrl
  });

  it('should verify element properties', () => {
    // 存在性
    cy.get('#main-heading').should('exist');
    cy.get('#non-existent-element').should('not.exist');

    // 可见性
    cy.get('#main-heading').should('be.visible');
    cy.get('#message-area').should('not.be.visible'); // 因为 display: none

    // 文本内容
    cy.get('#main-heading').should('have.text', 'Welcome!');
    cy.get('#main-heading').should('include.text', 'Welcome');
    cy.get('.subtext').contains('test page', { matchCase: false }); // cy.contains 也是一种断言

    // 值 (通常用于 input, select, textarea)
    cy.get('#name-input').should('have.value', 'Cypress');

    // Class
    cy.get('#main-heading').should('have.class', 'title');
    cy.get('#submit-btn').should('not.have.class', 'secondary');

    // CSS 属性
    cy.get('.subtext').should('have.css', 'color', 'rgb(0, 0, 255)'); // 注意颜色值格式

    // 属性 (HTML attributes)
    cy.get('#agree-checkbox').should('have.attr', 'type', 'checkbox');
    cy.get('#agree-checkbox').should('be.checked');
    cy.get('#agree-checkbox').should('be.disabled');
    cy.get('#submit-btn').should('not.be.disabled');

    // 元素数量
    cy.get('ul li').should('have.length', 3);
    cy.get('ul li').should('have.length.greaterThan', 2);
    cy.get('ul li').should('have.length.lessThan', 5);

    // 也可以使用 expect (TDD 风格)
    cy.get('#main-heading').then(($el) => {
      expect($el.text()).to.equal('Welcome!');
      expect($el).to.have.class('title');
    });
  });

  it('should verify URL and title', () => {
    cy.url().should('include', '/test-page.html');
    cy.title().should('eq', 'Test Page');
  });

  it('should verify list items', () => {
    cy.get('ul li').eq(0).should('have.text', 'Item 1'); // eq(index) 选择特定索引的元素
    cy.get('ul li').eq(1).should('have.text', 'Item 2');
    cy.get('ul li').last().should('have.text', 'Item 3');

    cy.get('ul li').each(($li, index, $list) => {
      cy.wrap($li).should('contain.text', `Item ${index + 1}`);
      if (index === 0) {
        expect($li.text()).to.equal('Item 1');
      }
    });
  });
});

6. 处理异步与等待

Cypress 的一个核心优势是其内置的自动等待和重试机制。

  • 自动等待 : 当你执行一个命令(如 cy.get(), .click(), .should()),Cypress 会自动等待元素出现、变为可操作状态或断言通过,直到达到默认的超时时间 (defaultCommandTimeout, 默认为 4000ms)。

    csharp 复制代码
    // 假设 #dynamic-content 元素会在 2 秒后出现
    cy.get('#dynamic-content').should('be.visible'); // Cypress 会自动等待
    cy.get('#clickable-button').click(); // Cypress 会等待按钮可点击
  • .should() 的重试: 断言也会自动重试,直到成功或超时。

  • cy.wait(timeInMs) : 显式等待固定时间(应尽量避免,因为它会使测试变慢且可能不稳定)。

    scss 复制代码
    cy.wait(500); // 等待 500ms (不推荐)
  • cy.wait('@alias') : 等待一个被别名标记的网络请求完成(非常有用,见网络请求部分)。

  • 超时配置:

    • 全局: cypress.config.js -> defaultCommandTimeout
    • 单个命令: cy.get('#slow-element', { timeout: 10000 }).should('be.visible');

7. 测试组织与 Hooks

Cypress 使用 Mocha 的 BDD 语法 (describe, it) 和 Hooks。

js 复制代码
// cypress/e2e/hooks_test.cy.js
describe('Hooks Example Suite', () => {
  before(() => {
    // 在套件中所有测试用例运行之前执行一次
    cy.log('Before all tests in the suite');
    // 例如:设置全局测试数据,登录一次(如果状态可跨测试共享)
  });

  after(() => {
    // 在套件中所有测试用例运行之后执行一次
    cy.log('After all tests in the suite');
    // 例如:清理测试数据
  });

  beforeEach(() => {
    // 在套件中每个测试用例运行之前执行
    cy.log('Before each test');
    cy.visit('/test-page.html'); // 确保每个测试都从干净的页面开始
  });

  afterEach(() => {
    // 在套件中每个测试用例运行之后执行
    cy.log('After each test');
    // 例如:截屏、登出
  });

  it('Test case 1', () => {
    cy.log('Running Test Case 1');
    cy.get('#main-heading').should('be.visible');
  });

  it('Test case 2', () => {
    cy.log('Running Test Case 2');
    cy.get('#submit-btn').should('exist');
  });

  describe('Nested Suite', () => {
    beforeEach(() => {
      cy.log('Before each test in NESTED suite'); // 会覆盖外部的 beforeEach 吗?不,都会执行,外部先执行
    });

    it('Nested Test case A', () => {
      cy.log('Running Nested Test Case A');
      cy.get('ul li').should('have.length', 3);
    });
  });

  // 跳过测试
  it.skip('Skipped test case', () => {
    // 此测试不会执行
    cy.log('This will not run');
  });

  // 只运行此测试 (在当前 describe 块内,如果多个 .only,则都运行)
  // it.only('Focused test case', () => {
  //   cy.log('This is the only test that will run in this suite (if uncommented)');
  //   cy.get('#name-input').type('Focus');
  // });
});
```执行顺序: `before` -> `beforeEach` (outer) -> `beforeEach` (inner, if applicable) -> `it` -> `afterEach` (inner, if applicable) -> `afterEach` (outer) -> `after`.

### 8. 网络请求处理 (XHR/Fetch)

`cy.intercept(method?, urlMatcher, routeHandler?)` 是 Cypress 中处理网络请求的核心命令。

```javascript
// cypress/e2e/network_test.cy.js
describe('Network Request Handling', () => {
  beforeEach(() => {
    cy.visit('https://jsonplaceholder.typicode.com/'); // 访问一个提供公共 API 的网站
  });

  it('should listen to a GET request and assert its response', () => {
    // 拦截 GET 请求到 /posts/1
    cy.intercept('GET', '/posts/1').as('getPost1'); // .as() 给拦截器起一个别名

    // 触发请求的操作 (例如点击一个按钮,或者直接在应用中加载)
    // 这里我们手动通过 cy.request 模拟一个应用发起的请求,或者如果页面加载时就发请求,cy.visit() 就会触发
    // 假设页面上有一个按钮会获取 post 1
    // cy.get('#fetch-post-1-button').click();

    // 为演示,我们直接用 cy.request 触发,实际应用中是应用自身发请求
    cy.request('https://jsonplaceholder.typicode.com/posts/1');

    // 等待别名为 @getPost1 的请求完成,并获取其响应
    cy.wait('@getPost1').then((interception) => {
      console.log('Interception object:', interception);
      expect(interception.response.statusCode).to.equal(200);
      expect(interception.response.body).to.have.property('userId', 1);
      expect(interception.response.body).to.have.property('id', 1);
      expect(interception.response.body).to.have.property('title');
    });
  });

  it('should stub a GET request with a fixture', () => {
    // 准备一个 fixture 文件: cypress/fixtures/post.json
    // { "userId": 100, "id": 100, "title": "Mocked Post Title", "body": "This is a mocked post body." }
    cy.fixture('post.json').as('mockPostData'); // 加载 fixture 并别名

    cy.intercept('GET', '/posts/1', { fixture: 'post.json' }).as('getPostStubbed');
    // 或者直接提供对象:
    // cy.intercept('GET', '/posts/1', {
    //   statusCode: 200,
    //   body: { userId: 100, id: 100, title: "Mocked Post Title", body: "This is a mocked post body." }
    // }).as('getPostStubbed');


    // 假设应用代码会请求 /posts/1
    // 例如,在页面上显示这个 post 的标题
    // 为了演示,我们直接访问一个会显示 post 数据的页面(如果存在)或模拟请求
    // 假设有个函数 displayPostTitle 会 fetch('/posts/1') 并显示标题
    // displayPostTitle();

    // 这里我们用 cy.request 来模拟应用发起的请求
    cy.request('https://jsonplaceholder.typicode.com/posts/1').then(response => {
        // 这里的 response 会是我们 stub 的数据
        expect(response.body.title).to.equal('Mocked Post Title');
    });


    cy.wait('@getPostStubbed'); // 确认 stub 被调用
    // 可以在页面上断言显示的是 stubbed data
    // cy.get('#post-title-display').should('have.text', 'Mocked Post Title');
  });

  it('should stub a POST request and verify request body', () => {
    const newPost = { title: 'My New Post', body: 'Content of the new post', userId: 1 };
    const serverResponse = { ...newPost, id: 101 };

    cy.intercept('POST', '/posts', (req) => {
      // 可以在这里断言请求体
      expect(req.body.title).to.equal(newPost.title);
      expect(req.body.userId).to.equal(newPost.userId);

      // 回复一个模拟的响应
      req.reply({
        statusCode: 201,
        body: serverResponse,
        headers: { 'x-custom-header': 'mocked' },
      });
    }).as('createPost');

    // 模拟应用提交新 post
    cy.request({
        method: 'POST',
        url: 'https://jsonplaceholder.typicode.com/posts',
        body: newPost
    }).then(response => {
        expect(response.status).to.equal(201);
        expect(response.body.id).to.equal(101);
        expect(response.headers).to.have.property('x-custom-header', 'mocked');
    });


    cy.wait('@createPost').its('response.statusCode').should('eq', 201);
    // cy.get('#new-post-id-display').should('have.text', '101');
  });

  it('should modify response headers', () => {
    cy.intercept('GET', '/users', (req) => {
      req.continue((res) => { // 让原始请求继续,然后修改响应
        res.headers['x-powered-by'] = 'Cypress Magic';
      });
    }).as('getUsers');

    cy.request('https://jsonplaceholder.typicode.com/users');

    cy.wait('@getUsers').then((interception) => {
      expect(interception.response.headers).to.have.property('x-powered-by', 'Cypress Magic');
    });
  });

  it('should simulate a network error', () => {
    cy.intercept('GET', '/non-existent-resource', {
      statusCode: 404,
      body: { error: 'Resource not found' },
      delayMs: 100, // 模拟延迟
    }).as('getError');

    // 假设应用会处理这个错误并显示错误信息
    // cy.get('#fetch-error-button').click();
    cy.request({url: 'https://jsonplaceholder.typicode.com/non-existent-resource', failOnStatusCode: false})
      .then(response => {
        expect(response.status).to.equal(404);
        expect(response.body.error).to.equal('Resource not found');
      });


    cy.wait('@getError');
    // cy.get('.error-message').should('contain.text', 'Resource not found');
  });
});

9. Fixtures (测试数据管理)

Fixtures 用于存储静态的测试数据,通常是 JSON 文件,存放在 cypress/fixtures 目录下。

cypress/fixtures/user.json:

js 复制代码
{
  "username": "testuser",
  "email": "[email protected]",
  "password": "password123"
}

cypress/fixtures/products.json:

js 复制代码
[
  { "id": 1, "name": "Laptop", "price": 1200 },
  { "id": 2, "name": "Mouse", "price": 25 },
  { "id": 3, "name": "Keyboard", "price": 75 }
]

使用 cy.fixture(filePath, options?):

js 复制代码
// cypress/e2e/fixtures_test.cy.js
describe('Using Fixtures', () => {
  beforeEach(() => {
    // 加载 fixture 数据,可以在 beforeEach 中为所有测试用例准备
    cy.fixture('user.json').as('userData'); // 使用 .as() 将数据存储在 this.userData 中 (在 it 块中通过 this.userData 访问)
    cy.fixture('products.json').as('productsData');
  });

  it('should use user data from fixture for login form', function() { // 注意这里用 function() 而不是箭头函数,才能用 this
    cy.visit('/login'); // 假设有登录页

    // this.userData 现在可用
    expect(this.userData.username).to.equal('testuser');

    cy.get('[data-cy="username-input"]').type(this.userData.username);
    cy.get('[data-cy="password-input"]').type(this.userData.password);
    // cy.get('[data-cy="login-button"]').click();
    // ... 断言登录成功
  });

  it('should display products from fixture data', function() {
    cy.visit('/products'); // 假设有产品列表页

    // 假设页面会根据 API 返回的数据渲染产品列表
    // 我们可以拦截 API 请求并使用 fixture 数据
    cy.intercept('GET', '/api/products', { fixture: 'products.json' }).as('getProducts');
    cy.wait('@getProducts');

    // 断言页面上显示的产品数量和内容
    cy.get('.product-item').should('have.length', this.productsData.length);

    this.productsData.forEach((product, index) => {
      cy.get('.product-item').eq(index).within(() => { // .within() 将后续命令的作用域限制在此元素内
        cy.get('.product-name').should('have.text', product.name);
        cy.get('.product-price').should('contain.text', product.price);
      });
    });
  });

  it('can load fixture directly in test', () => {
    cy.fixture('user.json').then((user) => {
      // user 对象就是 fixture 文件的内容
      cy.log(`Username from fixture: ${user.username}`);
      expect(user.email).to.contain('@');
    });
  });
});

10. 自定义命令 (Custom Commands)

自定义命令允许你创建可复用的操作序列,使测试更简洁、更具可读性。定义在 cypress/support/commands.js (旧版) 或 cypress/support/e2e.js 中引入的 commands.js

cypress/support/commands.js:

js 复制代码
// cypress/support/commands.js

/**
 * Logs in a user with a given username and password.
 * @param {string} username - The username.
 * @param {string} password - The password.
 * @example cy.login('testuser', 'password123')
 */
Cypress.Commands.add('login', (username, password) => {
  cy.log(`Logging in as ${username}`);
  cy.visit('/login'); // 假设登录页路由
  cy.get('[data-cy="username-input"]').type(username);
  cy.get('[data-cy="password-input"]').type(password);
  cy.get('[data-cy="login-button"]').click();
  // 可选:断言登录成功,例如 URL 改变或欢迎信息出现
  cy.url().should('include', '/dashboard'); // 假设登录后跳转到 dashboard
});

/**
 * Gets an element by its data-cy attribute.
 * @param {string} selector - The value of the data-cy attribute.
 * @example cy.getByCy('submit-button')
 */
Cypress.Commands.add('getByCy', (selector, ...args) => {
  return cy.get(`[data-cy=${selector}]`, ...args);
});

/**
 * A more complex command that might interact with an API and UI.
 * Creates an item via API bellezza then verifies it on the UI.
 * @param {object} itemData - Data for the item to be created.
 */
Cypress.Commands.add('createItemViaApiAndVerify', (itemData) => {
  cy.request('POST', '/api/items', itemData).then((response) => {
    expect(response.status).to.eq(201);
    const itemId = response.body.id;
    cy.visit(`/items/${itemId}`);
    cy.getByCy('item-name-display').should('have.text', itemData.name);
  });
});

// 如果自定义命令返回一个 Promise,Cypress 会自动处理
Cypress.Commands.add('fetchToken', (username, password) => {
  return cy.request({
    method: 'POST',
    url: '/api/auth/token',
    body: { username, password }
  }).then(response => response.body.token);
});

// 覆盖 Cypress 原有命令 (谨慎使用)
// Cypress.Commands.overwrite('type', (originalFn, element, text, options) => {
//   if (options && options.sensitive) {
//     // Mask sensitive text in the command log
//     options.log = false; // Disable logging for this specific call
//     Cypress.log({
//       name: 'type',
//       message: '*'.repeat(text.length),
//       $el: element,
//       consoleProps: () => {
//         return {
//           'Typed text': '*'.repeat(text.length),
//           'Original text': text,
//           'Applied to': element,
//         };
//       },
//     });
//   }
//   return originalFn(element, text, options);
// });

确保在 cypress/support/e2e.js (Cypress 10+) 或 cypress/support/index.js (旧版) 中导入 commands.js:

javascript 复制代码
// cypress/support/e2e.js  
import './commands';

在测试中使用自定义命令:

// cypress/e2e/custom_commands_test.cy.js
describe('Using Custom Commands', () => {
  it('should login using the custom login command', () => {
    cy.login('standard_user', 'secret_sauce'); // 使用自定义的 login 命令
    cy.getByCy('welcome-message').should('contain.text', 'Welcome, standard_user');
  });

  it('should use getByCy to find elements', () => {
    cy.visit('/some-page');
    cy.getByCy('search-input').type('Cypress');
    cy.getByCy('search-button').click();
    cy.getByCy('search-results').should('be.visible');
  });

  it('should use fetchToken command', () => {
    cy.fetchToken('user', 'pass').then(token => {
      expect(token).to.be.a('string');
      // localStorage.setItem('authToken', token); // 可以在这里使用 token
    });
  });

  // it('should use overwritten type for sensitive data', () => {
  //   cy.visit('/login');
  //   cy.getByCy('username-input').type('testuser');
  //   cy.getByCy('password-input').type('supersecret', { sensitive: true }); // 密码不会明文显示在日志中
  // });
});

11. 页面对象模型 (Page Object Model - POM)

POM 是一种设计模式,用于增强测试用例的可维护性和可读性。它将 UI 页面或页面片段抽象为对象,这些对象封装了与页面交互的方法和元素定位符。

优势:

  • 代码复用: 页面交互逻辑封装在 Page Object 中,可在多个测试用例中复用。
  • 可维护性: 如果 UI 发生变化,只需修改对应的 Page Object,而不需要修改所有使用该页面的测试用例。
  • 可读性: 测试用例更关注业务流程,而不是底层的 UI 交互细节。

创建 Page Object 类 (例如,在 cypress/page-objects/ 目录下)

cypress/page-objects/LoginPage.js:

js 复制代码
// cypress/page-objects/LoginPage.js
class LoginPage {
  // 元素定位符
  get usernameInput() {
    return cy.get('[data-cy="username-input"]');
  }

  get passwordInput() {
    return cy.get('[data-cy="password-input"]');
  }

  get loginButton() {
    return cy.get('[data-cy="login-button"]');
  }

  get errorMessage() {
    return cy.get('[data-cy="error-message"]');
  }

  // 页面交互方法
  visit() {
    cy.visit('/login');
    return this; // 支持链式调用
  }

  typeUsername(username) {
    this.usernameInput.type(username);
    return this;
  }

  typePassword(password) {
    this.passwordInput.type(password);
    return this;
  }

  clickLogin() {
    this.loginButton.click();
    // 通常,点击登录后会导航到新页面,可以返回新的 Page Object
    // return new DashboardPage();
  }

  login(username, password) {
    this.typeUsername(username);
    this.typePassword(password);
    this.clickLogin();
    // return new DashboardPage();
  }

  getErrorMessageText() {
    return this.errorMessage.invoke('text'); // 获取文本内容
  }
}

export default new LoginPage(); // 导出单例,或在测试中 new LoginPage()
// 导出单例方便,但如果页面有状态或需要多次实例化,则导出类本身更好
// export default LoginPage;

cypress/page-objects/DashboardPage.js:

js 复制代码
// cypress/page-objects/DashboardPage.js
class DashboardPage {
  get welcomeMessage() {
    return cy.get('[data-cy="welcome-message"]');
  }

  get logoutButton() {
    return cy.get('[data-cy="logout-button"]');
  }

  verifyWelcomeMessage(username) {
    this.welcomeMessage.should('be.visible').and('contain.text', `Welcome, ${username}`);
    return this;
  }

  clickLogout() {
    this.logoutButton.click();
    // return new LoginPage();
  }
}
export default new DashboardPage();
// export default DashboardPage;

在测试中使用 Page Objects:

js 复制代码
// cypress/e2e/pom_login_test.cy.js
import loginPage from '../page-objects/LoginPage'; // 如果导出单例
import dashboardPage from '../page-objects/DashboardPage';
// import LoginPage from '../page-objects/LoginPage'; // 如果导出类
// import DashboardPage from '../page-objects/DashboardPage';

describe('Login Functionality using POM', () => {
  // const loginPage = new LoginPage(); // 如果导出类
  // const dashboardPage = new DashboardPage();

  beforeEach(() => {
    loginPage.visit();
  });

  it('should login successfully with valid credentials', () => {
    cy.fixture('user.json').then((userData) => {
      loginPage.login(userData.username, userData.password);
      // 如果 login 方法返回 DashboardPage 实例:
      // const dashboardPageInstance = loginPage.login(userData.username, userData.password);
      // dashboardPageInstance.verifyWelcomeMessage(userData.username);
    });
    // 假设登录后跳转,由 dashboardPage 处理后续断言
    dashboardPage.verifyWelcomeMessage('testuser'); // 假设 fixture 中的 username 是 testuser
  });

  it('should display error message with invalid credentials', () => {
    loginPage.login('invaliduser', 'wrongpassword');
    loginPage.errorMessage.should('be.visible').and('contain.text', 'Invalid credentials');
    // 或者 loginPage.getErrorMessageText().should('contain', 'Invalid credentials');
  });

  it('should logout successfully', () => {
    cy.fixture('user.json').then((userData) => {
      loginPage.login(userData.username, userData.password);
    });
    dashboardPage.verifyWelcomeMessage('testuser');
    dashboardPage.clickLogout();
    // 断言回到了登录页
    cy.url().should('include', '/login');
    loginPage.usernameInput.should('be.visible'); // 验证登录页元素可见
  });
});

12. Cypress 配置

Cypress 的配置主要在 cypress.config.js (Cypress 10+) 或 cypress.json (旧版) 文件中。

cypress.config.js 示例:

js 复制代码
// cypress.config.js
const { defineConfig } = require('cypress');

module.exports = defineConfig({
  // 全局配置
  projectId: 'your-project-id', // 用于 Cypress Dashboard
  viewportWidth: 1280,
  viewportHeight: 720,
  defaultCommandTimeout: 5000, // 命令默认超时时间 (ms)
  pageLoadTimeout: 60000, // 页面加载超时时间 (ms)
  requestTimeout: 15000, // cy.request 超时时间
  responseTimeout: 30000, // 等待 XHR 响应的超时时间
  watchForFileChanges: true, // 是否监听文件变化并自动重跑测试 (仅限 cypress open)
  video: true, // 是否录制测试过程视频 (cypress run 默认 true)
  videosFolder: 'cypress/videos',
  screenshotsFolder: 'cypress/screenshots',
  screenshotOnRunFailure: true, // cypress run 失败时自动截图
  trashAssetsBeforeRuns: true, // 运行前清空 screenshots 和 videos 文件夹

  // 环境变量
  env: {
    apiUrl: 'http://localhost:3001/api',
    adminUsername: 'admin',
    // adminPassword: ' ควรใช้ Cypress.env.json หรือตัวแปรสภาพแวดล้อมของระบบสำหรับข้อมูลลับ'
  },

  // E2E 测试特定配置
  e2e: {
    baseUrl: 'http://localhost:3000', // 测试应用的基础 URL, cy.visit('/') 会访问它
    specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}', // 测试文件匹配模式
    supportFile: 'cypress/support/e2e.js', // 全局支持文件
    // excludeSpecPattern: ['**/__snapshots__/*', '**/__image_snapshots__/*'],

    setupNodeEvents(on, config) {
      // 在这里可以注册插件,监听 Node.js 事件
      // 例如,集成代码覆盖率工具,或者执行一些构建前的任务
      // require('@cypress/code-coverage/task')(on, config);

      // on('task', {
      //   log(message) {
      //     console.log(message);
      //     return null;
      //   },
      //   seedDatabase(data) {
      //     // 在 Node 环境执行数据库操作
      //     // return db.seed(data);
      //     return 'Database seeded with: ' + JSON.stringify(data);
      //   }
      // });

      // 重要的是返回 config 对象,否则 Cypress 不会加载这些修改
      return config;
    },
  },

  // Component 测试特定配置 (如果使用)
  // component: {
  //   devServer: {
  //     framework: 'react', // 或 'vue', 'angular'
  //     bundler: 'webpack', // 或 'vite'
  //   },
  //   specPattern: 'src/**/*.cy.{js,jsx,ts,tsx}',
  //   supportFile: 'cypress/support/component.js',
  // },

  // 其他配置项,如 chromeWebSecurity: false (谨慎使用,用于处理跨域问题)
  // reporter: 'mochawesome', // 配置报告器
  // reporterOptions: {
  //   reportDir: 'cypress/reports/mochawesome',
  //   overwrite: false,
  //   html: false,
  //   json: true,
  // },
});

13. 环境变量

可以通过多种方式设置环境变量,并在测试中使用 Cypress.env('varName') 获取。

  1. cypress.config.js 中的 env 对象 (如上例)。

  2. cypress.env.json 文件 (在项目根目录,会被 .gitignore 忽略,适合存放敏感信息)。

    json 复制代码
    // cypress.env.json
    {
      "adminPassword": "supersecretpassword",
      "apiKey": "xyz123abc"
    }
  3. 命令行参数:

    bash 复制代码
    npx cypress run --env foo=bar,baz=qux
    # 或者
    CYPRESS_API_URL=http://test.api.com CYPRESS_USER_ID=123 npx cypress run

    (前缀为 CYPRESS_ 的系统环境变量会自动加载)

使用:

ini 复制代码
const apiUrl = Cypress.env('apiUrl');
const adminPass = Cypress.env('adminPassword'); // 从 cypress.env.json 或命令行获取

cy.request(`${apiUrl}/users`);
// cy.login(Cypress.env('adminUsername'), adminPass);

14. 插件 (Plugins)

插件允许你扩展 Cypress 的行为,通过监听 Node.js 进程中的事件。在 Cypress 10+ 中,插件逻辑主要配置在 cypress.config.jssetupNodeEvents 函数中。

用途示例:

  • 任务 (task) : 在 Node.js 环境中执行任意代码(如数据库操作、文件系统操作)。
  • 文件预处理: 在测试运行前处理测试文件(如编译 TypeScript、CoffeeScript)。
  • 修改配置: 动态修改 Cypress 配置。
  • 集成第三方服务: 如代码覆盖率、可视化测试工具。

cypress.config.jssetupNodeEvents 示例:

js 复制代码
// cypress.config.js (部分)
// ...
const fs = require('fs');
const path = require('path');

module.exports = defineConfig({
  e2e: {
    setupNodeEvents(on, config) {
      on('task', {
        log(message) {
          console.log(`[Plugin Task Log] ${message}`);
          return null; // task 必须返回 null 或一个 Promise
        },
        readFileMaybe(filename) {
          const filePath = path.join(__dirname, 'cypress', 'fixtures', filename);
          if (fs.existsSync(filePath)) {
            return fs.readFileSync(filePath, 'utf8');
          }
          return null;
        },
        // 示例:数据库操作 (需要安装相应的 DB 驱动)
        // queryDb(query) {
        //   return new Promise((resolve, reject) => {
        //     dbConnection.query(query, (error, results) => {
        //       if (error) return reject(error);
        //       resolve(results);
        //     });
        //   });
        // }
      });

      // 修改配置的示例
      // config.defaultCommandTimeout = 10000;
      // config.env.anotherVar = 'setByPlugin';

      // 集成代码覆盖率
      // require('@cypress/code-coverage/task')(on, config);

      return config; // 必须返回 config 对象
    },
    // ...
  },
});

在测试中使用 cy.task():

js 复制代码
// cypress/e2e/tasks_test.cy.js
describe('Using Tasks', () => {
  it('should use log task', () => {
    const message = 'Hello from test to plugin!';
    cy.task('log', message).then((result) => {
      // result 会是 null,因为 task 返回 null
      expect(result).to.be.null;
    });
    // 你会在运行 Cypress 的终端看到 "[Plugin Task Log] Hello from test to plugin!"
  });

  it('should read a file using task', () => {
    cy.task('readFileMaybe', 'user.json').then((content) => {
      expect(content).to.be.a('string');
      const userData = JSON.parse(content);
      expect(userData.username).to.equal('testuser');
    });

    cy.task('readFileMaybe', 'nonexistent.json').then((content) => {
      expect(content).to.be.null;
    });
  });

  // it('should query database using task', () => {
  //   cy.task('queryDb', 'SELECT * FROM users WHERE id = 1').then(users => {
  //     expect(users).to.have.lengthOf(1);
  //     expect(users[0].name).to.equal('Admin User');
  //   });
  // });
});

15. 调试技巧

  • Cypress Test Runner:

    • 时间旅行: 点击左侧命令日志中的任何命令,可以看到该命令执行时应用的快照。DOM 快照、网络请求详情等。
    • 固定命令: 点击命令日志中的图钉图标,可以固定快照,方便检查。
    • 控制台输出 : cy.log() 的输出,以及 console.log (在 cy.then() 中使用) 会显示在浏览器开发者工具的控制台中。
  • debugger : 在测试代码中加入 debugger; 语句,当浏览器开发者工具打开时,执行会在此处暂停。

    js 复制代码
    cy.get('button').then(($button) => {
      debugger; // 可以在这里检查 $button
      // $button 是一个 jQuery 对象
    });
  • cy.debug() : 类似于 debugger;,但会在命令日志中打印更多信息,并暂停。

    js 复制代码
    cy.get('input').type('hello').debug();
  • .pause() : 暂停测试执行,允许你在 Test Runner 中手动恢复或单步执行后续命令。

    js 复制代码
    cy.get('form').submit().pause();
  • 浏览器开发者工具: 可以检查元素、网络请求、控制台错误等。

  • 截图和视频 : Cypress 自动为失败的测试(在 cypress run 时)截图,并可以配置录制视频。

16. 运行测试

  • 交互模式 (cypress open) :

    bash 复制代码
    npx cypress open
    # 或 npm run cy:open (如果已配置到 package.json)

    打开 Test Runner GUI,选择浏览器,点击 spec 文件运行。适合编写和调试测试。

  • 命令行模式 (cypress run) :

    bash 复制代码
    npx cypress run
    # 或 npm run cy:run

    在无头浏览器 (默认 Electron,可配置) 中运行所有测试。适合 CI 环境。

    • 指定浏览器: npx cypress run --browser chrome

    • 指定测试文件: npx cypress run --spec "cypress/e2e/login_test.cy.js"

    • 指定文件夹: npx cypress run --spec "cypress/e2e/smoke-tests/*"

    • 并行运行 (需要 Cypress Dashboard): npx cypress run --parallel --record --key YOUR_RECORD_KEY

    • 生成报告: Cypress 默认输出到控制台,可以集成 Mochawesome 等报告器。

      如果配置了 Mochawesome:

      bash 复制代码
      # 安装
      # npm install --save-dev mochawesome mochawesome-merge mochawesome-report-generator
      # cypress.config.js 中配置 reporter: 'mochawesome' 和 reporterOptions
      # 运行后,报告会生成在 cypress/reports/mochawesome
      # 可以用 mochawesome-merge 合并多个 JSON 报告,然后用 marge 生成 HTML 报告

17. CI/CD 集成

将 Cypress 测试集成到 CI/CD 流程(如 Jenkins, GitLab CI, GitHub Actions)非常重要。

基本步骤:

  1. 安装依赖: CI 服务器需要 Node.js 环境,并能够安装项目依赖 (包括 Cypress)。

    bash 复制代码
    # GitHub Actions 示例
    # .github/workflows/ci.yml
    name: Cypress Tests
    on: [push]
    jobs:
      cypress-run:
        runs-on: ubuntu-latest
        steps:
          - name: Checkout
            uses: actions/checkout@v3
    
          - name: Setup Node.js
            uses: actions/setup-node@v3
            with:
              node-version: '18' # 或你的项目版本
    
          - name: Install dependencies
            run: npm ci # 使用 ci 更快更可靠
    
          - name: Start server (if needed)
            # 如果你的应用需要一个运行中的服务器
            # run: npm start & # 运行后台服务
            # wait-on http://localhost:3000 # 等待服务启动 (需要安装 wait-on)
    
          - name: Cypress run
            # 使用 Cypress 官方 GitHub Action 可以简化很多配置
            uses: cypress-io/github-action@v6
            with:
              # command: npm run cy:run # 如果你的运行命令不同
              start: npm start # Action 会帮你启动服务并等待
              wait-on: 'http://localhost:3000' # URL Action 等待的服务
              browser: chrome # 可选
              # record: true # 如果使用 Cypress Dashboard
              # parallel: true # 如果使用并行测试
            # env: # 如果需要传递环境变量给 Cypress
            #   CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
            #   GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # 用于 Dashboard 集成
    
          # - name: Cypress run (manual setup, if not using cypress-io/github-action)
          #   run: npm run cy:run -- --reporter junit --reporter-options "mochaFile=cypress/reports/junit-[hash].xml"
          #   # 上传测试报告
          # - name: Upload test results
          #   uses: actions/upload-artifact@v3
          #   if: always() # 即使测试失败也上传
          #   with:
          #     name: cypress-results
          #     path: cypress/reports/
    
          # - name: Upload screenshots and videos (if run failed)
          #   uses: actions/upload-artifact@v3
          #   if: failure()
          #   with:
          #     name: cypress-artifacts-on-failure
          #     path: |
          #       cypress/screenshots
          #       cypress/videos
  2. 运行应用服务器: E2E 测试需要一个运行中的应用实例。CI 脚本中需要包含启动应用服务器的步骤,并确保服务器在测试开始前已就绪。

  3. 执行 cypress run: 使用命令行模式运行测试。

  4. 处理结果和报告: CI 流程应能捕获测试的退出码 (0 表示成功,非 0 表示失败)。可以配置生成 JUnit 或 HTML 报告,并将其作为构建产物保存。

  5. Cypress Dashboard (可选但推荐) :

    • 提供测试结果的可视化、历史记录、并行测试协调、失败截图/视频的云存储、与 GitHub/GitLab 等的集成(如 PR 评论)。
    • 需要在 cypress.config.js 中设置 projectId,并在运行时传递 --record --key YOUR_RECORD_KEY

总结与最佳实践

  • 优先使用 data-* 属性选择器:提高测试的健壮性。
  • 保持测试独立 :每个测试用例 (it 块) 都应该可以独立运行,不依赖于其他测试用例的状态。使用 beforeEach 来设置初始状态。
  • 原子化测试:每个测试用例应专注于验证一个特定的功能点或用户行为。
  • 避免在测试中加入过多逻辑:测试应该简单直接。复杂的逻辑应封装在自定义命令或 Page Objects 中。
  • 善用 cy.intercept() :控制网络请求,使测试更稳定、更快速,并能测试各种网络场景。
  • 使用 Page Object Model (POM) :提高可维护性和可读性,尤其对于大型应用。
  • 合理使用等待 :依赖 Cypress 的自动等待,避免不必要的 cy.wait(fixedTime)
  • 编写清晰的失败信息:自定义命令或复杂断言中,如果失败,确保日志能清晰指示问题所在。
  • 定期审查和维护测试:E2E 测试需要持续投入维护。
  • 不要过度依赖 E2E 测试:E2E 测试是测试金字塔的顶端,应该与单元测试和集成测试结合使用。它们成本较高,应主要覆盖关键用户流程。
  • 在 CI 中运行 E2E 测试:确保每次代码变更都经过 E2E 测试的验证。
  • 利用 Cypress Dashboard 或类似工具:分析测试结果,管理测试运行。
相关推荐
小小小小宇13 分钟前
前端AST 节点类型
前端
小小小小宇40 分钟前
业务项目中使用自定义eslint插件
前端
babicu12343 分钟前
CSS Day07
java·前端·css
小小小小宇1 小时前
业务项目使用自定义babel插件
前端
前端码虫1 小时前
JS分支和循环
开发语言·前端·javascript
GISer_Jing1 小时前
MonitorSDK_性能监控(从Web Vital性能指标、PerformanceObserver API和具体代码实现)
开发语言·前端·javascript
余厌厌厌1 小时前
墨香阁小说阅读前端项目
前端
fanged1 小时前
Angularjs-Hello
前端·javascript·angular.js
lichuangcsdn1 小时前
springboot集成websocket给前端推送消息
前端·websocket·网络协议
程序员阿龙1 小时前
基于Web的濒危野生动物保护信息管理系统设计(源码+定制+开发)濒危野生动物监测与保护平台开发 面向公众参与的野生动物保护与预警信息系统
前端·数据可视化·野生动物保护·濒危物种·态环境监测·web系统开发