旧的自动化测试笔记里最后留了一句页面频繁变化维护成本可能很高。这个问题值得往前想一步。假如只是想检查“满 100 元包邮”为什么每一种金额都得打开页面、找输入框、填金额、点按钮再读一次结果可是反过来如果只测计算函数页面按钮没有把它接起来是不是又可能全部通过我们用同一笔运费做一个本地实验既不把所有检查堆在页面也不把页面测试全部删掉。先算清楚这一笔这个例子的规则很简单商品金额满 100 元运费为 0不足 100 元运费 12 元。金额都用“分”计算接受 11000000 分的整数。没有优惠券、地区差异或真实订单。输入9999分也就是 99.99 元应该得到商品9999分 运费1200分 合计11199分再往上加一分商品达到10000分运费变成 0合计就是10000分。两笔金额非常接近却在规则的两侧。我们先把这些预期写下来别等程序返回了一个结果再说“这个大概对”。这条规则需要浏览器才能测吗把计算放到quote.mjsexport function quote(subtotalCents) { if (!Number.isInteger(subtotalCents) || subtotalCents 1 || subtotalCents 1000000) { throw new RangeError(invalid_subtotal); } const shippingCents subtotalCents 10000 ? 0 : 1200; return { shippingCents, totalCents: subtotalCents shippingCents }; }它只接收金额、返回运费和合计不需要知道页面按钮叫什么、接口地址是什么。我们可以直接把9999、10000等数交给它。保存下面的quote.test.mjs它和前一个文件在同一目录import test from node:test; import assert from node:assert/strict; import { quote } from ./quote.mjs; for (const [subtotal, expected] of [ [1, { shippingCents: 1200, totalCents: 1201 }], [5000, { shippingCents: 1200, totalCents: 6200 }], [9999, { shippingCents: 1200, totalCents: 11199 }], [10000, { shippingCents: 0, totalCents: 10000 }], [10001, { shippingCents: 0, totalCents: 10001 }], [1000000, { shippingCents: 0, totalCents: 1000000 }] ]) { test(quote ${subtotal}, () assert.deepEqual(quote(subtotal), expected)); } for (const invalid of [0, -1, 1.5, 10000, null, 1000001]) { test(reject ${JSON.stringify(invalid)}, () assert.throws(() quote(invalid), RangeError)); }使用 Node.js 22 运行node --test quote.test.mjs这 12 条检查直接调用函数不启动浏览器也不用找任何元素。用到的是 Node 内置的测试运行器不需要额外安装测试框架。Node 22 测试运行器说明这里没有把预期写成“再调用一次quote拿它的结果来比”。预期来自前面的规则。否则计算函数本身写错时两边可能错得一样。函数对了接口就一定对了吗不一定。函数参数是数字HTTP 请求里的查询参数却是字符串接口还得取对参数、做转换、调用正确的函数并按约定返回结果。本例的接口是GET /quote?subtotal9999 预期HTTP状态200 shippingCents1200 totalCents11199这是另外一条检查。它会经过真正的本地 HTTP 服务不是直接调用函数但也没有必要经过输入框。如果接口把查询参数读错了或者忘了返回运费函数测试仍可能通过接口检查就有机会发现问题。所以这两种测试不是把同一行断言换一个地方写而是多经过了一段需要验证的连接。页面只改结构哪些检查不该跟着坏本例做了两个正常页面版本旧版输入框的id是subtotal改版后叫order-amount。标签和用户操作含义不变服务和运费函数也没变。改版前输入框idsubtotal 改版后输入框idorder-amount 两者仍填写“商品金额分” 都调用同一个报价接口这次改版不应改变9999的运费。函数测试不用改接口检查也不用改。如果旧的页面脚本写死了#subtotal改版后定位会失败。它确实需要维护但这并不意味着运费规则一起错了更不意味着应该把同一套金额规则复制成一批新的页面用例。页面上的这条真实路径仍然需要保留填写金额、提交、取到报价、把报价展示给用户。这里用用户能识别的角色和标签定位而不是依赖改版时变化的id。Playwright 定位建议如果改版忘了接接口前两层会怎样再故意做一个错误版本按钮还能点金额也能填但页面不再请求接口而是直接把商品金额当成合计运费显示 0。在9999这个输入上就变成了函数直接算运费1200合计11199 接口直接查运费1200合计11199 页面实际显示运费0合计9999前两层测试仍然可以通过。它们没有经过这段已经断开的页面逻辑。输入9999 | 点击按钮 | 错误版本没有调用报价接口 | 显示9999而不是11199这就是不能把浏览器测试全部删掉的原因。我们需要一条真正走完页面到服务、再到显示结果的检查确认它们没有接错。而且这里不能只断言“按钮能点”或“页面出现一个数字”。数字9999也确实出现了却不是正确结果。下面的浏览器检查同时核对金额以及本地服务确实收到这次报价请求。自己运行三个版本前面的quote.mjs保持不变再加入下面的server.mjs。它只监听本机不接真实订单命令行选择正常旧版、正常改版或故意断线版。import { createServer } from node:http; import { pathToFileURL } from node:url; import { quote } from ./quote.mjs; export async function start(variant classic) { if (![classic, layout, unwired].includes(variant)) throw new Error(unknown_variant); const inputId variant classic ? subtotal : order-amount; const received []; const html !doctype htmlhtml langzh-CN meta charsetutf-8meta nameviewport contentwidthdevice-width,initial-scale1 title运费报价实验/title stylebody{max-width:580px;margin:40px auto;padding:20px;font:17px/1.8 sans-serif} h1{font-size:28px}input{width:120px;font:inherit}button{font:inherit} output{display:block;margin-top:16px;overflow-wrap:anywhere}/style h1运费报价/h1 formlabel for${inputId}商品金额分/label input id${inputId} typenumber min1 max1000000 step1 required value9999 button typesubmit计算运费/button/form output aria-livepolite/output script document.querySelector(form).addEventListener(submit, async event { event.preventDefault(); const input document.querySelector(input); const output document.querySelector(output); output.dataset.done 0; try { let result; if (${variant unwired}) { result { shippingCents: 0, totalCents: input.valueAsNumber }; } else { const response await fetch(/quote?subtotal encodeURIComponent(input.value)); if (!response.ok) throw new Error(quote_failed); result await response.json(); } output.textContent 运费 result.shippingCents 分合计 result.totalCents 分; } catch { output.textContent 报价失败; } output.dataset.done 1; }); /script/html; const server createServer((req, res) { const url new URL(req.url, http://127.0.0.1); if (req.method GET url.pathname /) { res.writeHead(200, { content-type: text/html; charsetutf-8 }); return res.end(html); } if (req.method GET url.pathname /quote) { received.push(url.searchParams.get(subtotal)); try { const text url.searchParams.get(subtotal); const value text ! null /^\d$/.test(text) ? Number(text) : NaN; const result quote(value); res.writeHead(200, { content-type: application/json }); return res.end(JSON.stringify(result)); } catch { res.writeHead(400, { content-type: application/json }); return res.end(JSON.stringify({ error: invalid_subtotal })); } } res.writeHead(404); res.end(); }); await new Promise((resolve, reject) { server.once(error, reject); server.listen(0, 127.0.0.1, resolve); }); return { server, received, url: http://127.0.0.1:${server.address().port} }; } if (process.argv[1] import.meta.url pathToFileURL(process.argv[1]).href) { console.log((await start(process.argv[2] ?? classic)).url); }先运行node server.mjs classic打开打印的本地地址就能试页面。结束服务后可以分别换成layout或unwired再运行。新进程的地址可能不同unwired是故意留错的实验版本。先不操作页面直接运行接口检查。下面保存为api-check.mjsimport assert from node:assert/strict; const base process.argv[2]; assert.ok(base, Usage: node api-check.mjs http://127.0.0.1:PORT); for (const [query, status, expected] of [ [subtotal9999, 200, { shippingCents: 1200, totalCents: 11199 }], [subtotal10000, 200, { shippingCents: 0, totalCents: 10000 }], [subtotalabc, 400, { error: invalid_subtotal }], [, 400, { error: invalid_subtotal }] ]) { const response await fetch(new URL(/quote? query, base)); assert.equal(response.status, status, query); assert.deepEqual(await response.json(), expected, query); } console.log(PASS 4 API cases);在另一个终端把地址换成服务打印的地址node api-check.mjs http://127.0.0.1:实际端口三个页面版本对应的接口都应输出PASS 4 API cases包括故意断线的unwired。因为这个错误没有改接口而是页面没有用到它。浏览器检查保存为browser-check.mjs放在同一目录import assert from node:assert/strict; import { chromium } from playwright; import { start } from ./server.mjs; const app await start(process.argv[2] ?? classic); let browser; try { browser await chromium.launch({ channel: msedge, headless: true }); const page await browser.newPage(); await page.goto(app.url); await page.getByRole(spinbutton, { name: 商品金额分, exact: true }).fill(9999); await page.getByRole(button, { name: 计算运费, exact: true }).click(); await page.waitForFunction(() document.querySelector(output).dataset.done 1); assert.equal(await page.locator(output).textContent(), 运费1200分合计11199分); assert.deepEqual(app.received, [9999]); console.log(PASS browser flow); } finally { if (browser) await browser.close(); await new Promise((resolve, reject) { app.server.close(error error ? reject(error) : resolve()); app.server.closeIdleConnections(); }); }它使用 Playwright。Node 项目需安装playwright这里通过channel: msedge使用本机已经安装的 Edge。本次验证版本是 Node 22.14.0、Playwright 1.63.0没有读取任何真实账号的浏览器配置。npm install playwright1.63.0 node browser-check.mjs classic node browser-check.mjs layout node browser-check.mjs unwired前两条输出PASS browser flow。最后一条应该因金额不符而失败而不是继续打印成功。这份检查自己启动并关闭本地服务不用先手动运行server.mjs。回归怎么安排才更像在检查问题回到最初那个“每个金额都走一次浏览器”的想法我们现在可以换一种安排规则的金额范围和包邮临界点主要由直接调用函数的检查覆盖。请求参数、返回格式和错误响应由接口检查覆盖。页面到接口再到显示结果保留有代表性的真实路径如果有多种不同交互路径不能只因为这条通过就全部省掉。这不是固定比例也不是“UI 测试只能有一条”。登录权限、跳转、提交保存等问题本来就可能需要浏览器中的实际状态。要问的是这个问题真正发生在哪段路径少经过一层会漏什么同一条关键规则在不同层有少量重叠也不一定浪费。比如都用9999函数层是在确认运费规则页面层是在确认这条规则的结果真的到了用户面前。这次没有拿三种方式跑一轮计时就宣布谁快多少倍。实验实际比较的是故障影响范围只改输入框id规则与接口不受影响切断页面调用它们仍然通过但浏览器路径必须失败把包邮条件从改成函数检查会在10000抓到错误。于是输入框换了id我们就检查页面定位不必让每组运费规则都跟着依赖它页面漏接接口时也不能拿函数和接口的通过来解释用户看到的错误金额。下次看到“全绿”先看这些检查实际走过哪一段。