e2e测试稳定性工程化实践:从工具选型到CI落地
凌晨十二点半,CI的提醒把我从沙发上拽起来。又是红灯——e2e用例在登录步骤卡了30秒,“欢迎回来”四个字迟迟没出现。说真的,这种场景我已经不陌生了。e2e(端到端测试)这两年几乎成了质量保障体系里的标配,但它的口碑一直两极化:有人觉得它是最接近用户体感的真测试,有人觉得它就是跑得慢、天天红、改一行页面就崩的祖宗。
我在不同项目里写过三轮e2e,经历过45分钟跑一圈的巨型测试集,也经历过从黑盒猜测到一步步把随机失败变成确定性问题的过程。今天不打算复述官方文档,我想把目前认为最务实的一套做法完整拆一遍:从要不要上e2e、工具选型、profile档案设计,到用例写法、排查链路,再到CI里的工程化落地。如果你正在犹豫要不要补e2e,或者已经被e2e的稳定性磨得没脾气,这篇应该能给你一些可参考的答案。
1. 为什么我对e2e的态度从“不写”变成“挑着写”
先说结论:我不是e2e原教旨主义者,不认为“有了e2e才叫有质量保障”。我见过太多团队把e2e当成集邮,用例堆了几百条,实际维护成本高到让整个质量体系差点崩盘。但在另外一些项目里,e2e又确实是唯一能在发布前拦住致命问题的防线。关键在于搞清楚它到底该站在什么位置。
1.1 e2e到底测的是什么:一条完整用户路径
单元测试测函数,集成测试测模块接口,e2e测的是“真实用户能不能把一件事办成”。这句话看起来简单,但它决定了e2e的定位。单元测试和集成测试再完备,也回答不了那个最讨厌的问题——所有组件单独看都对,合起来到底通不通?
举一个我实际遇到的例子。某次发版,登录模块、额度计算、支付网关各自的单元测试全绿,但用户实际转账时,因为一个cookie的domain写错,登录态在页面跳转后丢失,整个过程直接废掉。这种跨页面、跨服务、跨状态才能暴露的问题,几乎只有e2e能在上线前拦住。所以我在项目里经常跟研发说一句话:e2e不是给测试报告凑数的,它是最后一道用户视角的闸门。
当然,这也意味着e2e运行的成本天然就高。一条用例要拉起整个前端、依赖后端、数据库、第三方服务,跑一次最快也要几十秒。它不适合在每次提交时全量跑,更适合放在合并前的重要分支、以及上线前的冒烟阶段。它是回归保险,不是最先反馈的那个哨兵。
1.2 什么场景必须上e2e,什么场景别硬上
我根据自己的踩坑经验,把“必须上”和“别硬上”两类场景列一下:
必须上e2e的场景:
- 核心交易链路:注册、登录、下单、支付、退款这类用户天天走的流程
- 跨服务协作场景:前端要同时依赖多个后端服务,或涉及单点登录、网关转发
- 高频重构但行为不变的页面:前端经常改结构,但业务结果必须保持稳定
- 上线前的冒烟回归:哪怕只跑10条核心链路,也比人工点一遍可靠
别硬上的场景:
- 原型探索期:界面可能一周改三版,e2e还没写完,页面已经变了
- 纯展示页、一次性活动页:生命周期太短,维护成本远大于收益
- 测试环境本身不稳定:环境三天两头挂,用例天天跟着红,最后没人看红灯
- 团队没有维护预算:写用例一小时,维护一个月,这种账怎么算都亏
我之前给某个内部管理后台写过一堆e2e,后来发现这个后台几乎一个月改版一次,每次改版都要花三分之二的精力去修e2e,而不是老老实实测新功能。后来我几乎把UI层的e2e全部删掉,只保留几条核心审批流程,配合单元测试,整个节奏反而顺了。所以,上e2e之前先回答一个问题:这条链路如果坏了,线上用户会不会骂娘?如果不会,那它未必配得上e2e的维护成本。
1.3 被45分钟测试集折腾之后,我砍掉了80%的用例
说一段真实经历。我接手过一个老项目,e2e测试集有200多个用例,基于老式Selenium写成,跑一圈45分钟,只能在夜间CI跑。每天晚上红灯,第二天上午修,修完往往发现开发当天已经忘了昨天改了什么,排查成本高得离谱。
更要命的是用例之间还有隐性的数据依赖:第五个用例挂了,后面四十个全被污染,全红。整个团队对e2e的态度从“认真修”变成“看一眼谁改的,甩给前端”,最后演变成“红灯就红灯,合并先过”。
后来我推动做了一件事:不是继续堆用例,而是把200多个用例全部摊开,按“用户核心价值路径”重新梳理,最终只留下20条左右,切到Playwright+分片,跑一圈只要6分钟。从那以后,红灯才重新变得“值得去看”。这让我形成了一个非常坚定的判断——e2e的价值不取决于数量,取决于它是不是用户真正在走的路径,以及红灯出现后团队愿不愿意花三分钟去定位。
2. 工具选型:Selenium、Cypress、Playwright,我为什么停在Playwright
e2e的地基是工具选型。选错了,后面写多少用例都是在给自己挖坑。这几年我三个主流工具都深度用过,至少Selenium写过大几千行,Cypress写过两个完整项目,Playwright用了两年多。我的选择标准很简单:稳定性、调试效率、并行能力。
2.1 三个老对手的底层差异
先看一张我常给团队看的对照表:
| 维度 | Selenium | Cypress | Playwright |
|---|---|---|---|
| 通信架构 | WebDriver远程协议 | 与浏览器同进程注入 | CDP协议驱动 |
| 自动等待 | 高层自动等待偏弱,大量依赖显式等待 | 内置自动等待,机制成熟 | 内置自动等待 + web-first断言 |
| 多标签/多上下文 | 支持但较笨重 | 弱,单标签为主 | 原生支持多页面、多上下文 |
| 并行分片 | 需要自己搭框架 | 可配但麻烦 | projects + shard开箱即用 |
| 调试体验 | 截图/录像偏原始 | 时间旅行调试体验很好 | trace viewer + codegen,非常直观 |
| 维护成本 | 样板代码多,偏底层 | 中等 | 相对最低 |
这里的“通信架构”不是纯概念问题,它直接决定了你能不能用得省心。Selenium走WebDriver协议,浏览器被外部进程控制,很多高层语义需要自己写封装;Cypress和浏览器同进程注入,理论上跑得非常快,但因为控制逻辑和页面在同一个上下文,多标签页和跨域场景会受限;Playwright走CDP协议,既保留了外部控制的能力,又能精细地做多标签、多上下文、网络拦截这些操作。
我自己最直观的感受是:用Selenium写用例,一半时间在写“等待某个元素出现”的模板代码;用Cypress写,体验好很多,但一到多标签页和分片并行就头疼;用Playwright写,常规动作基本不需要考虑“要不要等一下”,因为框架自己会等。
2.2 自动等待和trace:稳定性才是第一生产力
很多人选型时看的是API好不好记、代码好不好写,我后来意识到,这些都不如“能不能稳定跑”重要。e2e用例的天然敌人是时序:页面跳转、接口返回、渲染完成,每一步都有延迟,且延迟不稳定。单元测试里你等100毫秒就拿到了数据,e2e里这个时间可能从100毫秒漂移到3秒。
Playwright的action方法会主动等待元素出现、可见、可点击,而不是“执行点击,如果没点中就报错”。web-first断言也是一样,expect会轮询等待目标状态满足,最长等到配置的timeout。这彻底改变了我写用例的方式:以前我满脑子都在想“这里要不要sleep两秒”,现在我只想“用户要看到什么”。
再加上trace viewer,失败用例会记录每一步的DOM快照、网络请求、控制台日志。排查一个失败,不再是从日志里猜,而是直接打开失败步骤看当时页面到底长什么样。这套调试体验,是我从Selenium迁移过来后觉得最值得的地方。
2.3 团队已经在用Cypress或Selenium怎么办
如果团队已经在某个工具上积累了成百上千条用例,我不建议为了“换新”而换。迁移成本是真实存在的,而且e2e的核心难点从来不是工具API,而是稳定性和工程化配套。Cypress继续用,把分片、重试、报告补齐,一样能跑得稳。Selenium在需要高度定制底层行为、或者团队以Java技术栈为主时,也依然有一席之地。
但如果是从零起步、没有历史包袱,我的建议很直接:默认选Playwright。尤其是需要覆盖多浏览器、多环境、多并行分片的项目,它几乎把这些事变成了内置能力。工具只是底座,但底座选对了,后面省下来的力气足够你多修几十条用例。
3. 从“e2e profile1”说起:先定义环境、用户、浏览器矩阵,再谈用例
很多人拿到e2e的第一步就是写用例,我不建议这么做。我所有项目的e2e第一步都是先写profile——配置档案。业界聊“e2e profile1”,指的通常是第一份基础档案,它至少要回答三件事:测哪个环境、用哪个用户、按什么规格跑。这三件事不定义清楚,用例写得越多,后面越乱。
3.1 环境档案:baseURL不该写死在用例里
我见过最头疼的用例就是里面写死了https://dev.example.com,换环境跑就得全局替换。正确做法是环境本身是外部输入的参数,用例里永远用相对路径。
我一般会在项目里放一个环境档案文件:
// profiles/env.ts export interface EnvProfile { name: string; baseURL: string; apiBaseURL: string; withMocks: boolean; } export const ENV_PROFILES: Record<string, EnvProfile> = { dev: { name: 'dev', baseURL: 'https://dev.example.com', apiBaseURL: 'https://dev-api.example.com', withMocks: true, }, staging: { name: 'staging', baseURL: 'https://staging.example.com', apiBaseURL: 'https://staging-api.example.com', withMocks: false, }, preprod: { name: 'preprod', baseURL: 'https://preprod.example.com', apiBaseURL: 'https://preprod-api.example.com', withMocks: false, }, }; export function getEnvProfile(): EnvProfile { const key = process.env.E2E_ENV || 'staging'; const profile = ENV_PROFILES[key]; if (!profile) { throw new Error(`Unknown E2E_ENV: ${key}`); } return profile; }然后在playwright.config.ts里统一取用:
import { defineConfig, devices } from '@playwright/test'; import { getEnvProfile } from './profiles/env'; const envProfile = getEnvProfile(); export default defineConfig({ testDir: './tests/e2e', timeout: 30_000, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 4 : 1, use: { baseURL: envProfile.baseURL, trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure', }, projects: [ { name: 'chromium-desktop', use: { ...devices['Desktop Chrome'] } }, { name: 'chromium-mobile', use: { ...devices['iPhone 14'] } }, ], });这样做的理由很朴素:用例不该知道自己在哪个环境跑,环境是运行时的输入项。今天在staging跑,明天在preprod跑,只要设置E2E_ENV,整套用例自动切换。否则一到预发环境就全红,然后你就会开始怀疑人生。
3.2 用户与数据档案:角色、种子数据和幂等清理
环境档案之外,第二个档案是用户档案。一个真实的业务系统里,同一个页面给不同角色看到的入口和状态完全不同:普通用户看到下单按钮,VIP用户看到折扣专区,管理员看到后台入口。如果e2e用例不区分角色,就会经常出现“我明明该点这个入口,但它不在页面上”的诡异失败。
我维护用户档案的方式是单独建一个users.ts:
export const TEST_USERS = { regular: { email: 'e2e-regular@qa.example.com', password: 'E2e-pass-123', }, vip: { email: 'e2e-vip@qa.example.com', password: 'E2e-pass-123', }, admin: { email: 'e2e-admin@qa.example.com', password: 'E2e-pass-123', }, };这些测试账号有几个硬性要求:只在测试环境存在,不进生产账号库;密码不过真实密码找回流程;不参与真实营销活动,避免被风控拦截。另外,每次跑测试前最好有一段数据清理脚本,把上一轮用例创建的订单、购物车、优惠券记录清掉,让用例幂等可重复跑。我见过太多CI红灯是因为昨天用例创建的脏数据污染了今天的环境。
3.3 浏览器矩阵与运行规格档案
第三个档案是浏览器矩阵和运行规格。我不建议所有项目都全量跑三套浏览器矩阵。路径简单的展示型页面,chromium一套就够;核心交易链路至少要覆盖桌面和移动端两种viewport;如果你的用户里有相当比例的Safari,那再补一套WebKit。
实际配置就在上面的config里:每个project可以定义自己的浏览器和设备规格。运行规格则统一管起来,比如timeout、retries、是否headless、失败时保留哪些现场证据。我把这些统一放config,而不是散落在各个用例里,就是为了保证全项目行为一致。一份配置管所有用例,比在用例里各写各的规范要可靠得多。
3.4 动态profile:用API准备数据和清理数据
环境档案、用户档案、浏览器矩阵是静态profile,但真实业务还有一类动态需求:某个用例要一个刚注册的企业账号,或者要一张未过期的优惠券。这种场景不该靠测试人员在后台手工造数据,而应该让用例自己在beforeAll里调接口把数据准备好。
Playwright提供了requestfixture,可以直接在测试里发起API请求。我在项目里会扩展一个fixture,统一封装动态数据准备和清理:
import { test as base, expect } from '@playwright/test'; export const test = base.extend({ vipUser: async ({ request }, use) => { const timestamp = Date.now(); const email = `e2e-vip-${timestamp}@qa.example.com`; const res = await request.post('/api/testing/users', { data: { email, role: 'vip', plan: 'annual' }, }); expect(res.ok()).toBeTruthy(); const user = await res.json(); await use({ email, password: user.password, userId: user.id }); await request.delete(`/api/testing/users/${user.id}`); }, });这样一来,每个用例拿到的都是独立、干净、用后即焚的数据,不会出现“换个账号跑一次,上次的订单还挂在那边影响断言”的问题。动态profile的核心理念是:用例的数据依赖尽量动态创建,而不是长期共享静态资源。
4. 第一个稳定用例:登录到下单的五步成文法
三种档案定义完之后,才轮得到写用例。我拿电商里最经典的一条链路做例子:登录 → 搜索商品 → 加入购物车 → 结算 → 支付 → 看到订单确认页。这条链路覆盖了页面跳转、接口返回、状态变更、支付回调,是典型的“真实用户完整路径”。
4.1 一条用例一条链路,别做“页面接力赛”
新手容易把e2e写成“页面接力赛”:用例一测登录,用例二测搜索,用例三测购物车,然后用例二假设用例一已经登录了。这种写法的毁灭性在于用例之间产生隐式依赖,任何一个用例挂了,后面一串全被带崩。我在1.3节提到的45分钟测试集就是这么烂掉的。
正确做法是:一条用例就是一条完整业务链路,用例之间尽量独立。每条用例自己准备数据、自己登录、自己完成整条路径。看起来重复代码多了,但换来的是稳定性和可并行性,这笔账非常划算。登录本身可以抽成一个公共的loginAs函数,让每条用例调用,但登录后的流程不能依赖其他用例的“遗泽”。
4.2 定位器:优先getByRole,少碰XPath
定位器选择也是稳定性大头。最忌讳的是用那种带随机class名的CSS选择器,或者长到离谱的XPath。产品重构UI时,样式的class几乎一定会变,但“登录按钮”“搜索框”“提交订单”这种文本语义不会随便变。
所以我现在的优先顺序是:getByRole(按按钮、输入框等语义角色定位) >getByLabel(表单标签) >getByText(可见文本) >getByPlaceholder(占位符) > CSS选择器兜底。实际上只要组件语义合理,getByRole基本能覆盖90%的场景。
举个例子,别写await page.locator('button.button-32x9').click(),而是写:
await page.getByRole('button', { name: '登录' }).click();前者class一变就挂,后者只要按钮语义还是“登录”,无论样式怎么重构都稳如老狗。页面可访问性做得越好的团队,e2e用getByRole就越顺手,这是一个可以反向推动前端规范的好习惯。
4.3 等待与断言:web-first,杜绝固定sleep
等待策略是e2e从“能跑”到“稳定跑”的关键分水岭。我在项目里定了两条铁律:
第一,禁止在用例里写page.waitForTimeout(3000)这种固定sleep。真实页面加载时间是波动的,固定sleep只有两种结果:等短了偶发失败,等长了拖慢整个测试集。
第二,断言必须用web-first断言。正确的写法是这样的:
await expect(page.getByText('支付成功')).toBeVisible();expect会自动等待目标状态满足,最长等到你配置的timeout。页面一秒没渲染好,它会等;五秒还没渲染好,它才报错。这比expect(await page.locator('.success').isVisible()).toBe(true)这种“只看一眼就下结论”的写法不知道高到哪里去了——后面那种写法,页面还没出来就断言一次,必然挂。
某些实在避免不了的场景,比如第三方地图canvas需要动画完成才能操作,我会用locator.waitFor({ state: 'visible' })配合条件等待,而不是闭眼sleep。
4.4 完整用例与有效性验证
下面是一段完整的下单链路用例:
import { test, expect } from '@playwright/test'; import { TEST_USERS } from '../../profiles/users'; test('普通用户能完成一次完整下单', async ({ page }) => { // 登录 await page.goto('/'); await page.getByRole('link', { name: '登录' }).click(); await page.getByLabel('邮箱').fill(TEST_USERS.regular.email); await page.getByLabel('密码').fill(TEST_USERS.regular.password); await page.getByRole('button', { name: '登录' }).click(); await expect(page.getByText('欢迎回来,测试用户')).toBeVisible(); // 搜索商品 await page.getByPlaceholder('搜索商品').fill('无线鼠标'); await page.getByRole('button', { name: '搜索' }).click(); await page.getByText('无线鼠标 罗技M185').click(); // 加购与结算 await page.getByRole('button', { name: '加入购物车' }).click(); await page.getByRole('button', { name: '去结算' }).click(); await page.getByRole('button', { name: '提交订单' }).click(); // 支付(测试环境使用沙箱网关) await page.getByPlaceholder('支付密码').fill('000000'); await page.getByRole('button', { name: '确认支付' }).click(); // 断言最终结果 await expect(page.getByText('支付成功,订单号')).toBeVisible(); });用例写完跑绿之后,还有一个关键动作我称为“有效性验证”:故意在测试环境停掉一个后端接口,跑一遍用例,看它会不会红。如果停了接口用例还是绿的,说明用例根本没走到那条依赖,这条用例就是自嗨。这个验证动作太容易被忽略,很多团队以为自己在做e2e回归,实际上用例早就没有覆盖真实逻辑了。
5. e2e变红不一定是bug:四层排查链路
e2e跑红了,第一时间不要冲过去改代码。我经历过太多次“改了一晚上发现是环境问题”的案例。现在我的排查顺序基本固定成四层,从外到内,层层排除。
5.1 先排除基建:网络、CDN、第三方登录
打开失败报告,第一件事看失败发生前的网络请求和上下文信息。如果失败发生在第三方OAuth登录、CDN资源加载、外部地图脚本初始化这类“非我方可控”的步骤上,先怀疑基建。
快速验证方法特别简单:在本地跑一遍同一条用例,或切到另一个环境跑一遍。本地稳定通过而CI偶发失败,基本可以锁定是CI网络隔离或外部依赖抖动。如果是某个第三方服务本身在降级,你要做的不是改用例,而是去查第三方状态页。
5.2 看trace与video:是没出现还是出现得晚
排除基建后,打开Playwright的trace viewer,找到失败的那一步,看当时的DOM快照。这一步的核心是区分两种完全不同的失败原因:
- 元素根本没有出现在DOM快照里:这属于“页面渲染逻辑被破坏了”,基本是真bug或数据问题。
- 元素在DOM快照里存在,但状态一直没达到预期:这属于“出现得晚”,可能是接口慢、图片加载阻塞、或js执行耗时,不一定是逻辑错了。
这两种情况的修法完全不同。前者要找前端或后端排查功能逻辑;后者要优化等待策略,或者在用例里调整交互步骤,避免在页面还没 settle 的时候抢着点。我见过不少开发因为分不清这两种情况,把“加载太慢”当成“定位器失效”去改代码,越改越乱。
5.3 环境数据、缓存与AB实验随机
第三层是环境数据问题,这类失败的特征是“昨天还绿,今天红了,代码完全没改”。常见的元凶有:
- 测试账号被前一轮用例改了密码或被风控锁定
- 上一轮用例创建的订单、优惠券残留,导致本轮断言不匹配
- 浏览器缓存或登录态残留,导致页面状态不是预期初始态
- AB实验或灰度发布让不同用户看到了不同页面
针对这类问题,我的防御手段很明确:动态创建测试数据并清理(见3.4节)、用固定的隔离测试账号、每次跑测试前清空浏览器上下文、AB实验在测试环境固定到特定实验桶。环境数据的污染是e2e失败最大的隐形占比项,也是排查成本最高的,因为它不给你报错,只让你看到”界面和预期不一样“。
5.4 三个“随机失败”案例复盘
分享三个我实际踩过的坑,都属于典型的“表面随机、实际确定”。
第一个是strict mode报错。用例点击一个按钮,结果页面上有两个按钮文本相同,Playwright的默认strict mode直接拒绝。第一次遇到时我以为偶发,后来才发现是产品在页面上同时渲染了“提现”和“余额提现”两个入口。解决方案不是关掉strict mode,而是把定位器改得更精确,比如用{ name: '提现', exact: true }。
第二个是第三方地图偶发白屏。我的用例要点地图上的标记,但地图有时加载快、有时加载慢,固定sleep时好时坏。最后我发现地图组件加载完成时会往某个容器上追加一个特定class,于是我改为等待这个class出现,问题根除。这个教训是:固定sleep永远在猜,条件等待才是真正等到了“目标时刻”。
第三个是测试账号被前一轮用例改了密码。夜间CI里用例A创建了用户并改了密码,用例B复用了同一个账号,结果B永远登录失败。这个问题的根治方法是让每条用例动态创建自己独有的测试用户,用完就删。从那以后“随机失败”直接少了一大半。
6. 把e2e塞进CI的日常:分片、重试和有效报告
e2e只有真正接入CI、每天跑起来,才有价值。但接入CI不是写个脚本执行npx playwright test就完事了。这一章讲几个让e2e在CI里“能持续活下去”的工程化细节。
6.1 分片与并发:把40分钟压到8分钟
e2e最大的槽点是慢。解决方案是分片并行。Playwright原生支持--shard,CI里可以按机器分片:
strategy: matrix: shard: [1, 2, 3, 4] steps: - run: npx playwright test --shard=${{ matrix.shard }}/4这个配置会同时起4台机器,每台跑四分之一的用例。我那个45分钟测试集,分片后压到了8分钟左右。慢这件事如果不解决,团队就会找各种理由不跑e2e,然后e2e就彻底沦为摆设。
分片的另一个好处是天然隔离了用例间的资源竞争。如果用例本身写得独立、数据动态创建,并发跑就互相不干扰。我强烈建议在本地也养成跑分片的习惯,别只用单线程自测,因为并发才是真实CI的常态。
6.2 重试策略:兜底而非遮羞
我在config里会设置:本地跑不重试,CI跑重试1-2次。重试的目的是解决偶发的网络抖动、三方服务超时这类瞬间问题,它不应该成为掩盖真问题的遮羞布。
一个很实用的原则:如果同一用例每次都要靠重试才能绿,那它就是有问题的用例。要么定位器不稳定,要么依赖了不该依赖的共享数据,要么触发了真实的产品缺陷。我每隔一周会把CI里“首次失败但重试成功”的用例拉出来看一遍,这些用例就是整个e2e体系里最需要优先治理的隐患。
6.3 失败报告:让开发点开就能看懂
e2e红灯之后,最重要的事情是让开发三分钟内能定位问题,而不是让他们在铺天盖地的日志里找线索。我用的几个手段:
- 通知里直接带失败用例名、错误摘要、截图和trace的下载链接
- 用例命名遵循“角色+动作+预期结果”,比如
vip用户能使用优惠券完成支付 - HTML报告作为CI的artifact保留,供事后复盘
这里特别建议团队把用例名写在通知里。很多测试框架默认显示的是文件路径,开发还得点进去才知道是哪条用例,多一步操作就多一层抵触情绪。用例名就是测试的基本文档,写好它,排查成本直接降一半。
6.4 用例评审与维护预算,控制e2e规模的唯一办法
最后是团队层面的机制。e2e这条线要长期健康,必须控制规模。我的经验是核心链路每个模块保留1-2条e2e,多写一条都要过一轮“评审”:
回答三个问题:
- 这个场景单元测试或接口测试真的覆盖不了吗?
- 这条链路真实用户真的会走吗?
- 明天它红了,我能半小时内定位是前端、后端还是环境问题吗?
三个问题只要有一个回答不了,这条用例就不要加。同时,每季度清理一次“僵尸用例”——那些半年都没红过的用例,要么太弱没覆盖到真实逻辑,要么根本没人看结果。留着它们只会拖慢测试集、增加无意义的失败噪音。
我个人现在的习惯是,每次加e2e之前都会先问自己一句:这条链路如果明天红了,我能很快说清楚它在测什么吗?如果再打开代码看一眼还得想半天,那说明抽象和命名出了问题。e2e不是越多越好,而是每一句都经得起追问。这个标准,我建议所有团队在开始引入e2e的第一天就定下来。