☰
Vue3+TypeScript测试体系实战:从Vitest到Cypress全攻略
2026/10/18 16:30:56 网站建设 项目流程

做了这些年前端,我对“项目测试”这四个字感情挺复杂的。早期在小团队里,测试基本靠手动点,上线前夕反复回归,人累不说,还总有漏网之鱼。后来进了做中后台系统的公司,代码量堆上来,靠人工根本盯不住,才被逼着把测试体系一点点搭起来。这几年前后端分离、工程化成熟,好在工具链也争气了。今天想系统聊一聊我在 Vue 3 + TypeScript 项目里从 Vitest 单元测试到 Cypress E2E 的完整落地过程,包括怎么搭、怎么写、怎么解决那些文档里看不到的坑。这篇东西不是教科书,更像是我踩了两年坑之后的复盘笔记,适合已经在用 Vue 3 和 TypeScript、打算认真补齐测试的团队,也适合被“能不能补个测试”这句话逼到墙角的个人开发者。

我推荐这套组合的原因很直接:Vitest 和 Vite 同源,启动速度用起来像飞一样,对 TypeScript 原生友好;Cypress 则是 E2E 领域里对开发者最“感性”的工具,它的时间旅行调试和交互式命令控制真的能让你在排查问题时少掉一把头发。单测聚焦函数和组件的逻辑正确性,E2E 验证用户真实走链路时的系统稳定,两者中间再加一点组件测试,正好拼成完整闭环。

1. 为什么需要一套完整测试体系,而不是“补几个用例交差”

1.1 测试金字塔不是摆设,它决定投入产出比

很多人一提测试就头大,第一反应是“我代码都写不完,哪有时间写测试”。但实际上,没有测试体系的项目,后期的维护成本才真正恐怖。我曾经接手过一个中后台项目,页面几十个,每个接口改动后,测试人员要花整整一天做冒烟回归。后来我搭了一套分层的测试体系,单测挡住逻辑错误,组件测试保证页面渲染和交互行为正常,E2E 守住核心链路,原本一天的回归压缩到一条命令解决。这才是测试体系存在的意义:不是找茬,而是给项目装上安全网。

Vitest 负责单元测试和组件测试,它的定位是又快又准,跑一次全量用例通常只要几秒钟;Cypress 负责端到端测试,模拟真实用户点击、输入、跳转,验证系统整体行为。两者的测试金字塔关系大概是:底层有大量小型单测,中间是适量组件测试,顶层是少量但覆盖关键业务的 E2E。比例上,单测数量可以很多,E2E 不必贪多,把登录、核心业务流、关键异常场景覆盖住就够了。

1.2 为什么选 Vitest 而不是 Jest,为什么选 Cypress 而不是其他 E2E 工具

Vitest 早期是作为 Vite 的原生测试运行器出现的,但它的意义绝不只是“Vite 专属”。如果你在一个 Vite 工程里用过 Jest,大概率经历过那套复杂的 transform 配置、moduleNameMapper、环境切换,光 mock CSS 文件就要写半页配置。Vitest 天然复用 Vite 的 resolve 配置、插件体系和 TS 支持能力,这意味着你在 vite.config.ts 里配置的别名、插件,测试里直接生效,不需要重复维护一套独立解析规则。

速度方面,Vitest 的聪明之处在于基于 esbuild 做转换,再加上依赖预构建缓存,首次运行和后续增量测试都很快。在 monorepo 结构下,Vitest 还能按项目自动隔离依赖,这个优势在项目变大之后越明显。

Cypress 的选型理由更感性。它不像其他工具那样过于“程序化”,而是提供了一个可视化桌面服务,你用它的交互式模式运行测试时,可以实时看到每一步命令执行后的界面快照,甚至通过“时间旅行”查看命令前一刻的应用状态。排查 E2E 用例失败时,这个能力的价值是倍增的,你能直观看到是选择器没找到、请求挂掉了,还是一个样式遮挡导致点击不到。它还内置了网络请求拦截与等待机制,我们可以在测试里断言某个 API 是否被正确调用,返回值是否符合预期,这一点对前端为主的项目特别重要。

1.3 边界划分:单测覆盖什么,E2E 覆盖什么

我见过最不合理的测试方案,是试图用 E2E 覆盖所有功能细节,结果测试运行时间长、维护成本爆炸。粒度选择必须是懒人原则:能在下层解决的问题,就不要升级到上层。函数逻辑、状态管理、工具方法,全用 Vitest 直接测;组件渲染、交互行为、事件派发,用组件挂载测试;只有那些需要完整浏览器环境、涉及多接口联动、路由跳转和真实布局渲染的场景,才值得动用 Cypress。

举个具体例子:一个用户列表页,接口分页逻辑、状态更新可用单测覆盖;表格组件接收数据后渲染行数是否正确、点击操作按钮是否触发事件,属于组件测试;而从登录、进入列表页、翻页、点击详情到修改信息的完整链路,才用 Cypress 保底。这样分层下来,单测响应快、定位精准,E2E 数量少、稳定性可控。

2. 从零搭建 Vitest + Vue Test Utils 测试环境

2.1 初始化项目和基础依赖

假设你已经通过npm create vite@latest建好了一个 Vue 3 + TypeScript 项目,接下来安装测试相关的依赖。我这里直接给出我常用的命令:

npm install -D vitest @vue/test-utils @vitest/coverage-v8 jsdom

这里有几个选型说明。@vue/test-utils是 Vue 官方出品的组件测试库,提供mount、shallowMount、trigger等 API,没有替代品的争议。jsdom是一个在 Node 环境里模拟浏览器 DOM 的实现,Vitest 需要指定一个 DOM 环境来挂载组件。有人会用happy-dom,它更快但兼容性在某项目里出过问题,我个人更倾向于jsdom,项目多了之后踩坑概率更低。

如果你希望测试文件里使用describe、it、expect这些全局变量而不需要手动 import,还需要在tsconfig.json里配置:

{ "compilerOptions": { "types": ["vitest/globals"] } }

同时在vite.config.ts里开启 globals 支持:

/// <reference types="vitest" /> import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], test: { globals: true, environment: 'jsdom', setupFiles: ['./src/test/setup.ts'], css: false } })

注意css: false这个配置,它表示测试过程中不处理 CSS 文件,就算你在组件里 import 了样式,测试时也不会去解析,速度更快,也不会因为样式解析报错。setupFiles指向一个初始化文件,我们通常在里面注册一些全局的测试工具或 mock 方法。

2.2 编写第一个组件测试:计数器 Demo

搭建好环境之后,最直观的上手方式就是写一个最简单的组件测试。我建一个Counter.vue:

<script setup lang="ts"> import { ref } from 'vue' const count = ref(0) const increment = () => count.value++ const decrement = () => count.value-- </script> <template> <div> <p>import { mount } from '@vue/test-utils' import Counter from './Counter.vue' describe('Counter', () => { it('renders initial count', () => { const wrapper = mount(Counter) expect(wrapper.get('[data-testid="count"]').text()).toContain('0') }) it('increments when clicking the button', async () => { const wrapper = mount(Counter) await wrapper.get('[data-testid="inc"]').trigger('click') expect(wrapper.get('[data-testid="count"]').text()).toContain('1') }) it('decrements when clicking the button', async () => { const wrapper = mount(Counter) await wrapper.get('[data-testid="dec"]').trigger('click') expect(wrapper.get('[data-testid="count"]').text()).toContain('-1') }) })

这里要说一下为什么用>import { vi, expect } from 'vitest' class ResizeObserverMock { observe() {} unobserve() {} disconnect() {} } global.ResizeObserver = ResizeObserverMock

很多第三方 UI 组件库内部依赖ResizeObserver,而jsdom环境里没有这个 API。如果不在 setup 里 polyfill,挂载这些组件时会直接报ResizeObserver is not defined。这个坑非常经典,顺手加上能省不少事。

3. 单元测试的进阶设计:可维护性与覆盖率

3.1 对组合式函数做纯函数级测试

随着 Vue 3 组合式函数(Composable)的普及,越来越多的业务逻辑被抽取到useXXX函数中,这给了单测极大的发挥空间。比如我们有一个分页逻辑usePagination:

import { ref, computed } from 'vue' export function usePagination(initialPage = 1, pageSize = 10) { const page = ref(initialPage) const total = ref(0) const totalPages = computed(() => Math.ceil(total.value / pageSize)) const setPage = (p: number) => { if (p < 1 || p > totalPages.value) return page.value = p } const reset = () => { page.value = initialPage total.value = 0 } return { page, total, totalPages, setPage, reset } }

测试时我们不需要挂载任何组件,直接调用函数、操作响应式变量、断言计算结果。这种测试速度极快,覆盖逻辑分支的效率非常高。我在项目里已经把几乎所有涉及金钱计算、状态转换、日期处理的方法都抽成纯函数或组合式函数,单测覆盖率轻松拉高。对于复杂业务,这些底层测试的价值远大于表面上的 UI 测试。

测试异步的逻辑时,Vitest 提供了强大的vi.useFakeTimers()和vi.runAllTimers()。比如定时轮询接口的逻辑,不需要真的等几秒钟,用假计时器可以让测试瞬间完成,同时保证时间相关的分支都被执行到。

3.2 什么时候用 mount,什么时候用 shallowMount

这几乎是组件测试里最高频的取舍问题。mount会完整渲染组件及其所有子组件,适合验证最终的 DOM 结构和跨组件交互;shallowMount则只渲染当前组件,用桩替代子组件,适合把注意力集中在当前组件的逻辑上。对于拥有深层子组件树的大型页面组件,shallowMount的隔离性和速度都更友好。

我的习惯是:单元测试尽量shallowMount,组件集成测试使用mount,并且对无关的子组件用global.stubs做覆盖。比如测试一个带弹窗的列表组件,弹窗内容不属于当前测试的职责,就 stub 掉,避免触发弹窗内部的接口请求和生命周期。具体做法:

import CreateModal from '@/components/CreateModal.vue' const wrapper = mount(ListPage, { global: { stubs: { CreateModal: true } } })

stubs: { CreateModal: true }会直接把该组件替换成一个占位标签,不执行其内部脚本。这能让我们快速验证父组件行为,而不被子组件的副作用干扰。

3.3 mock 掉外部依赖,但不要 mock 到自己想测的逻辑

项目里总免不了请求后端接口、调用第三方 SDK,这些在单测环境都要被 mock 掉。Vitest 的vi.mock用法比较灵活,支持模块级别的自动 mock,也支持工厂函数返回自定义实现。比如一个 api 模块:

// src/api/user.ts export const fetchUser = (id: number) => { return fetch(`/api/users/${id}`).then(res => res.json()) }

对应测试时:

import { vi, describe, it, expect, beforeEach } from 'vitest' vi.mock('@/api/user', () => ({ fetchUser: vi.fn(() => Promise.resolve({ id: 1, name: 'mock user' })) })) import { fetchUser } from '@/api/user' describe('fetchUser mock', () => { beforeEach(() => { vi.clearAllMocks() }) it('should return mock user', async () => { const user = await fetchUser(1) expect(user.name).toBe('mock user') }) })

这里有一个重要原则:mock 的层级要尽可能低。如果你 mock 了整个 api 模块,但测试目标又是调用 api 模块的函数本身,那测试就变成了自己测自己。合理的方式是只 mock 最底层的网络请求库(如fetch或axios),让上层 api 函数真实执行。这样 api 函数内部的 URL 拼接、参数处理逻辑才算真正被覆盖。

3.4 组件交互测试中的异步处理:flushPromises 的坑

Vue 组件更新是异步的,trigger('click')后 DOM 不会立刻变化。await的时候 Vue Test Utils 内部会等待 Vue 的下一个 tick。但如果你触发的操作引发了 Promise 链(比如点击后请求接口再更新数据),仅await trigger可能等不到 Promise 完成。这时候要显式地将 Promise 推到解决状态:

const flushPromises = () => new Promise((resolve) => setImmediate(resolve)) it('submits form', async () => { await wrapper.get('form').trigger('submit') await flushPromises() expect(wrapper.get('[data-testid="success"]').exists()).toBe(true) })

或者更优雅方式是用 Vitest 提供的心智模型,直接await new Promise((r) => setTimeout(r, 0))。不要迷信“等一下就好”的固定延时写法,那只会让测试越来越慢、越来越不稳定。

4. Cypress 端到端测试:环境、选型与核心配置

4.1 安装配置,以及为什么要有独立的测试配置

Cypress 的安装过程比较直接:

npm install -D cypress npx cypress open

第一次打开 Cypress 后,它会生成官网推荐目录结构。但使用中我更推荐手动配置,以便和源码目录做命名区分。主要配置文件cypress.config.ts里可以配置e2e模块:

import { defineConfig } from 'cypress' export default defineConfig({ e2e: { baseUrl: 'http://localhost:5173', supportFile: 'cypress/support/e2e.ts', specPattern: 'cypress/e2e/**/*.cy.ts', viewportWidth: 1280, viewportHeight: 720, experimentalMemoryManagement: true } })

我们在测试时往往需要一个独立的开发服务器。如果你直接用 Vite 的默认端口,每次跑测试前要先手动npm run dev,很容易忘记。我的做法是把 E2E 脚本和开发服务器串起来:

{ "scripts": { "e2e:run": "npm run build && npm run preview -- --port 4173 & wait-on http://localhost:4173 && cypress run --e2e --config baseUrl=http://localhost:4173" } }

这里的核心点是先启动一个生产构建的预览服务,这样 E2E 跑的是最终构建产物,而不是开发模式下的服务,可以提前暴露一些构建后才有问题的情况。

4.2 Cypress 与单测环境的核心差异

很多从单测转过来的同学会对 Cypress 产生误解,试图在 E2E 里对接口做细致入微的 mock。但实际上,E2E 测试的价值恰恰在于“接近真实”:真实的路由跳转、真实的浏览器渲染、真实的前端代码和后端接口。过度的 mock 会让 E2E 失去意义。我的原则是:前端业务主链路的接口优先走真实环境(通常是测试环境后端),只有第三方依赖或者无法控制的支付回调等场景才用cy.intercept模拟。

拦截还是有必要掌握的强大能力。比如在测试“提交表单成功跳转”这一流程时,我们不想真的往数据库里写入数据,或者后端环境暂时不可用,就可以拦截:

cy.intercept('POST', '/api/users', { statusCode: 201, body: { id: 1 } }) .as('createUser')

然后在表单提交后断言这个请求确实发生且响应成功:

cy.wait('@createUser').its('response.statusCode').should('eq', 201)

这样既验证了前端确实调了接口、发送了正确的 payload,又避免了污染测试数据。但要注意:一旦你拦截了接口,就不能断言后端真实返回的数据逻辑了,这不是 E2E 该干的活。

4.3 自定义命令和数据驱动设计

E2E 测试里最高频的操作就是登录。每次用例都写一遍打开登录页、输入账号密码、点击登录,不仅啰嗦,还容易在选型调整时波及所有用例。Cypress 提供了自定义命令机制:

// cypress/support/e2e.ts Cypress.Commands.add('login', (username: string, password: string) => { cy.visit('/login') cy.get('[data-testid="username"]').type(username) cy.get('[data-testid="password"]').type(password) cy.get('[data-testid="login-btn"]').click() cy.url().should('not.contain', '/login') })

这里有一个细节:在判断登录后的页面跳转时,我用cy.url().should('not.contain', '/login')而不是简单等待几秒,这样的断言式等待更可靠。之后所有用例开头只需要:

beforeEach(() => { cy.login('test-user', 'password-123') })

多个用户场景可以用数据驱动来组织。假如我们有一个角色权限系统,管理员能看到“系统设置”,普通用户看不到,那我们可以用 fixtures 传入不同的用户信息:

// cypress/fixtures/users.json [ { "username": "admin", "password": "a123", "expectSettingsVisible": true }, { "username": "normal", "password": "n123", "expectSettingsVisible": false } ]

测试循环读取这些数据:

describe('user permissions', () => { cy.fixture('users').then((users) => { users.forEach((user) => { it(`checks settings visibility for ${user.username}`, () => { cy.login(user.username, user.password) cy.get('[data-testid="settings-link"]').should( user.expectSettingsVisible ? 'be.visible' : 'not.exist' ) }) }) }) })

这样的好处是新增测试账号时只需改 fixtures 文件,测试逻辑本体完全不动。

5. E2E 实操:从登录到一条核心业务链路的完整用例

5.1 以“后台用户管理”系统为例,设计完整 E2E 流程

纸上谈兵没意思,我拿一个典型后台场景来说明:管理员登录,进入用户列表,点击某个用户查看详情,再点击返回按钮回到列表。

这种链路最适合做成 E2E“冒烟用例”,它验证了路由守卫、列表接口、详情接口、页面跳转、按钮状态等好几层是否都正常。

下面写一个惯例化的用例:

describe('user management smoke test', () => { beforeEach(() => { cy.login('admin', 'admin123') }) it('visits user list, opens details and goes back', () => { cy.visit('/users') // 列表加载出数据 cy.intercept('GET', '/api/users').as('getUsers') cy.wait('@getUsers').its('response.statusCode').should('eq', 200) cy.get('[data-testid="user-row"]').should('have.length.at.least', 1) // 点击第一条数据的详情按钮 cy.get('[data-testid="user-row"]').first().find('[data-testid="detail-btn"]').click() // 详情页出现用户姓名 cy.get('[data-testid="user-name"]').should('not.be.empty') // 点击返回按钮 cy.get('[data-testid="back-btn"]').click() cy.url().should('contain', '/users') }) })

这段代码看起来好像很普通,但里面反复出现的关键是“别为了等而等”。我没有写一个cy.wait(1000),而是用cy.intercept加cy.wait判断接口返回。Cypress 的默认should断言自带重试机制,例如should('have.length.at.least', 1)会持续轮询直到列表元素出现或超时。

5.2 选择器策略是 E2E 稳定性的命门

E2E 测试最让人抓狂的失败原因之一就是选择器失效。为了追求稳定,我会在代码里给关键交互元素加上><input >cy.get('[data-cy="search-input"]').type('张三')

这种命名约定可以把测试选择器和样式选择器彻底分离。我曾经在维护一个老项目时,因为一个样式重构统一切换了类名前缀,几十条 E2E 脚本瞬间全挂,最后被迫全面返工。后来定下规矩:凡是需要被 E2E 操作的元素,一律增加>cy.intercept('GET', '/api/users?page=1&size=10').as('fetchUsers') cy.wait('@fetchUsers').then((interception) => { const body = interception.response?.body expect(interception.response?.statusCode).to.eq(200) expect(body.data.items).to.be.an('array') expect(body.data.items.length).to.be.gte(1) })

有人觉得把后端响应结构写死进 E2E 有点过度绑定,但我认为对于核心业务链路,这恰恰是“端到端”应该做的事——前端和后端的契约本来就应该被验证。只是别在自己的页面里把所有接口都断言一遍,那样用例的可维护性反而降低。

5.4 处理弹窗、toast 和异步状态

后台项目几乎离不开弹窗和 toast。弹窗是否出现、点击遮罩是否能关闭、确认按钮是否触达接口,这些在 Cypress 里都有讲究。

对于可访问性友好的项目,弹窗通常是半透明遮罩加居中的容器。断言弹窗出现可以检查它的某个特性文本,但更推荐检查它的存在性及可见性:

cy.get('[data-cy="confirm-modal"]').should('be.visible') cy.get('[data-cy="modal-ok"]').click() cy.get('[data-cy="confirm-modal"]').should('not.exist')

弹窗关闭后通常会伴随一个 toast 提示。断言 toast 的方法也一样,尽量断言文案可见,而不是隐式等待:

cy.contains('保存成功').should('be.visible')

如果项目中使用了 Components 库,toast 通常挂载在 body 下,在 Cypress 的默认作用域里能够直接找到。万一不好找,可以用cy.get('body').contains(...)兜底。

6. 测试度量、CI 集成和提速经验

6.1 覆盖率阈值到底设多少,才能不让人摆烂

Vitest 的 coverage 集成很轻松,只要安装了@vitest/coverage-v8,在vite.config.ts里加:

test: { coverage: { provider: 'v8', reporter: ['text', 'html', 'lcov'], include: ['src/**/*.{ts,vue}'], exclude: ['src/main.ts', 'src/router/**', 'src/types/**'], thresholds: { lines: 80, functions: 80, branches: 70, statements: 80 } } }

阈值是双刃剑。定高了,团队成员为了凑行号覆盖率瓶颈,测试大量失去意义;定低了,覆盖率数据形同虚设。我的经验是,核心业务模块单独设高阈值,全局阈值定在 80 左右比较合理。另外,无论阈值多少,都要在package.json里加上脚本:

"test:coverage": "vitest run --coverage"

提交代码前跑一遍,低于阈值直接失败。很多团队把覆盖率当摆设,用--coverage.skipFull等方式把全绿数据混过去,我强烈反对,这不是给老板看的数据,而是给自己留的底牌。

6.2 让 E2E 在 CI 里稳定运行:无头模式与重试策略

Cypress 在本地跑可以打开可视化界面,但在 CI 环境里需要无头运行。官方提供了cypress run命令。在 CI 中,我们通常会这样定义任务步骤:先启动服务,再跑 E2E,失败后保留屏幕截图和视频作为调试证据。

cypress.config.ts里可以配置video和截图行为:

{ video: false, screenshotOnRunFailure: true }

视频在排查问题时作用有限,还会消耗大量 CI 时间和磁盘空间,我通常关闭。保留失败截图反而是最高效的定位手段。Cypress Dashboard 不是必需品,但在本地跑完后,你会看到cypress/screenshots目录下生成失败截图,直接打开看即可。

E2E 的稳定性一直是痛点。网络抖动、定时任务、动画延迟,都可能让用例失败。Cypress 提供retries配置:

{ retries: { runMode: 2, openMode: 0 } }

runMode指 CI 或命令行模式下失败自动重试 2 次,openMode是交互式模式下不重试。注意重试率不要设得太高,否则会把真实问题掩盖掉。我一般只在回归阶段开启重试,提交验证阶段保持一次即可。

6.3 提速方案:并行分片、语法层面优化

测试时间一长,大家就不愿意跑了,很多团队就是这么把测试体系搞废的。Vitest 本身支持多线程,默认会按 CPU 核心数分摊工作。如果你的测试太慢,先看看是 transform 慢还是 expect 重,一般而言poolOptions默认配置已经够用。

Cypress 的提速空间更大一些。官方支持的并行测试需要 Dashboard 服务,但如果你不想付费,也可以用简单的分片思路:手动把 spec 文件按功能分组,开多台 CI 机器分别跑不同分组,最后合并结果。这需要 CI 平台的矩阵构建能力,但效果显著。

尽量别在 E2E 里打开无谓的页面。如果一条用例只是验证某个表单校验,就不需要先跑一遍完整登录流程,可以给表单页面定制一个 mock 的登录态。Cypress 里设置 cookie 或 localStorage 缓存可以加快登录:

Cypress.Commands.add('loginByLocalStorage', () => { cy.visit('/') cy.get('body').then(() => { localStorage.setItem('token', 'fake-token') }) cy.visit('/form-page') })

这种方式相比完整登录流程确实快很多,但容易让你错过登录态相关的隐性问题,所以我只建议在非核心链路的内部页面使用。

7. 常见问题与排查技巧实录

7.1 Vitest 相关高频问题

我整理了一张问题速查表,很多卡壳场景其实都是同一个原因的变体。

问题现象常见原因解决方案
TypeError: window.matchMedia is not a functionjsdom 未实现 matchMedia在 setup 文件里 mockwindow.matchMedia
SyntaxError: Cannot use import statement outside a module某个依赖未被 transform在test配置里添加deps.inline
ResizeObserver is not defined组件库使用 ResizeObserver在 setup 里设置全局 mock
Unable to find element with selector选择器没写对或者组件未渲染先wrapper.html()打印检查
Promise not resolved异步链未完成使用flushPromises或在await后再断言

matchMedia缺失出现在很多用 ant-design-vue、element-plus 这类组件库的项目里。一旦组件内部用matchMedia判断响应式断点,jsdom 里直接报错。setup 里加一个简易实现即可:

Object.defineProperty(window, 'matchMedia', { writable: true, value: vi.fn().mockImplementation((query) => ({ matches: false, media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })), })

deps.inline问题比较隐蔽。某些 ESM-only 包不支持 Vitest 默认的转换方式,报出 “Cannot use import statement outside a module”。解决方法是在配置里显式指定它们走转换:

test: { deps: { inline: [/element-plus/, /@vueuse/] } }

7.2 Cypress 跨域与登录态保持问题

Cypress 默认的cy.visit是针对单一 origin 的,如果你测试的应用中需要跳转到另一个完全不同域的页面(比如 SSO 登录跳到统一认证域名再跳回来),默认配置会直接报错。Cypress 10 之后支持多 origin 测试:

cy.origin('https://sso.example.com', () => { cy.get('[data-cy="sso-username"]').type('admin') cy.get('[data-cy="sso-password"]').type('password') cy.get('[data-cy="sso-login"]').click() })

另外一个常见问题是后端测试环境偶发登录验证码导致 E2E 失效。为此,我们和运维约定了一条测试专用登录接口,在 E2E 环境跳过验证码,或者用固定验证码。这种做法在真实团队里太常见了,属于务实的取舍。

7.3 测试数据污染问题:怎么让用例可重复执行

如果 E2E 每次跑都往数据库里新增一条用户,第二次跑就会遇到重复用户名。我的方案是在用例中尽量使用唯一前缀:

const uniqueName = `test-user-${Date.now()}`

然后再校验这个名字出现在列表中。对于需要删除数据的场景,直接在测试结束时调用接口清理:

cy.request('DELETE', `/api/users/${id}`)

Cypress 的cy.request可以在测试中向后端发送真实请求,这一点很容易被忽略,但却是测试数据治理的利器。注意对 DELETE 这类接口确认好鉴权方式,尤其在测试环境通常没有强鉴权,安全上要额外小心。

7.4 一条独家经验:别和动画较劲

中后台项目常用表格行高亮、弹窗淡入淡出等动画效果。Cypress 会自动等待元素达到可交互状态吗?并不总是。很多隐藏问题在于动画未结束时的不可见或不稳定。

我踩过最深刻的坑是删除确认弹窗的“淡出”动画。关闭弹窗后立即断言它not.exist,结果偶发失败,因为弹窗 DOM 还在内存里做过渡动画。解决办法有两个方向:一是在测试环境禁用动画,很多组件库支持全局配置;二是在断言前显式等待动画结束:

cy.get('[data-cy=confirm-modal]', { timeout: 5000 }).should('not.exist')

本来想着用cy.wait(500)糊弄过去,但实践证明这只是把偶发失败推迟了,没有根治。后来我们团队干脆在测试环境入口处用一行代码把动画时间设为 0:

if (import.meta.env.MODE === 'e2e') { document.documentElement.classList.add('no-animation') }

配合全局样式把动画时间清零,E2E 稳定性立刻上升了一个档次。

8. 测试维护成本和团队协作:几个真实建议

8.1 测试用例也是代码,需要同样严格的 review

很多团队对待测试的态度是“能过就行”,但测试代码的腐烂速度往往不亚于业务代码。我在推测试体系时,把测试代码纳入和业务代码一样的 code review 流程,要求:每个测试用例必须清晰命名,意图单一;断言必须有价值,不能只写“摸一下”式的空断言;mock 的作用域尽量小,不允许全局 mock 到影响其他用例。

还有一点容易被忽视:不要让测试文件无限膨胀。当一个测试文件超过 200 行,通常意味着需要拆分或抽象公共工具函数。单元测试文件按模块对应,E2E 文件按业务功能模块划分,比如login.cy.ts、user-management.cy.ts,这样在多人并行开发时 merge 冲突也会少一些。

8.2 数据生成器和“稳定的假数据”

前后端并行开发时,测试环境接口可能还没准备好。这时 E2E 用例可以先配合cy.intercept使用固定 fixtures 数据,保证流程可以先打通。但项目上线前一定要把 fixtures 逐步替换为真实接口数据,否则 E2E 只是在测“一个戴着真实 UI 壳子的静态页面”。

单元测试更是如此。我写了一个轻度数据工厂,用于生成用户、订单、权限组等测试对象,避免每个测试文件里都手写大段构造数据:

const buildUser = (overrides: Partial<User> = {}) => ({ id: 1, name: '张三', role: 'admin', ...overrides })

这样当后端字段变更时,大部分改动只需要调整工厂函数,而不是逐个测试文件改断言。

8.3 完整支撑:从开发到 CI 的全流程测试钩子

我在团队里推动了一套流程:开发者提交 MR 前,必须本地跑通npm run test:unit和npm run test:e2e,两条命令缺一不可。为了让执行成本最低,我在package.json里定义了组合脚本:

{ "scripts": { "test:unit": "vitest run", "test:e2e": "cypress run --e2e", "test:all": "npm run test:unit && npm run test:e2e" } }

CI 流水线里,单元测试跑在合并请求阶段,E2E 跑在构建产物生成后的验收阶段。这样既能尽早反馈逻辑问题,又不会让耗时的 E2E 挡住每次小迭代。

测试体系的建设从来不是写完几条用例就完事,它更像是一种项目文化。团队里是否有人愿意为测试重构付出耐心,能不能让新人在写业务代码时习惯性地补上测试,这些比工具本身更重要。

最后分享一个我最近在做的扩展:对组件快照测试初步评价。组件快照测试就是把渲染后的 DOM 结构快照存储下来,下次运行对比差异。它在检测无意义的样式或结构改动时有奇效,但快照文件维护不好也会变成噪音来源。我现在的取舍是:仅在核心展示型组件上启用快照测试,业务组件一律不启用,避免因为后端数据结构微调导致大片快照失效。

如果你快要被“测试”两个字折磨疯掉,我的建议是从最小的范围开始:先挑一个高频、易出错、还让你焦虑到整晚睡不着的业务模块,给它写好单元测试和一条 E2E 冒烟用例。跑起来之后你会发现,原来那些藏在黑暗里的重构恐惧,真的能被几个用例就照亮一大片。测试这东西,一开始觉得是负担,后来却成了我改代码时最胆大的底气。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询