页面改了,业务回归也要全重跑浏览器吗?用一笔运费拆开三层测试

发布时间:2026/10/28 1:20:31
页面改了,业务回归也要全重跑浏览器吗?用一笔运费拆开三层测试

旧的自动化测试笔记里,最后留了一句:页面频繁变化,维护成本可能很高。

这个问题值得往前想一步。假如只是想检查“满 100 元包邮”,为什么每一种金额都得打开页面、找输入框、填金额、点按钮,再读一次结果?

可是反过来,如果只测计算函数,页面按钮没有把它接起来,是不是又可能全部通过?

我们用同一笔运费做一个本地实验,既不把所有检查堆在页面,也不把页面测试全部删掉。

先算清楚这一笔

这个例子的规则很简单:商品金额满 100 元,运费为 0;不足 100 元,运费 12 元。金额都用“分”计算,接受 1~1000000 分的整数。没有优惠券、地区差异或真实订单。

输入9999分,也就是 99.99 元,应该得到:

商品:9999分 运费:1200分 合计:11199分

再往上加一分,商品达到10000分,运费变成 0,合计就是10000分。

两笔金额非常接近,却在规则的两侧。我们先把这些预期写下来,别等程序返回了一个结果,再说“这个大概对”。

这条规则,需要浏览器才能测吗?

把计算放到quote.mjs:

export 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?subtotal=9999 预期HTTP状态:200 shippingCents:1200 totalCents:11199

这是另外一条检查。它会经过真正的本地 HTTP 服务,不是直接调用函数,但也没有必要经过输入框。

如果接口把查询参数读错了,或者忘了返回运费,函数测试仍可能通过,接口检查就有机会发现问题。

所以,这两种测试不是把同一行断言换一个地方写,而是多经过了一段需要验证的连接。

页面只改结构,哪些检查不该跟着坏?

本例做了两个正常页面版本:旧版输入框的id是subtotal,改版后叫order-amount。标签和用户操作含义不变,服务和运费函数也没变。

改版前:输入框id=subtotal 改版后:输入框id=order-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 html><html lang="zh-CN"> <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <title>运费报价实验</title> <style>body{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> <form><label for="${inputId}">商品金额(分)</label> <input id="${inputId}" type="number" min="1" max="1000000" step="1" required value="9999"> <button type="submit">计算运费</button></form> <output aria-live="polite"></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; charset=utf-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.mjs:

import 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 [ ['subtotal=9999', 200, { shippingCents: 1200, totalCents: 11199 }], ['subtotal=10000', 200, { shippingCents: 0, totalCents: 10000 }], ['subtotal=abc', 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 playwright@1.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,我们就检查页面定位,不必让每组运费规则都跟着依赖它;页面漏接接口时,也不能拿函数和接口的通过来解释用户看到的错误金额。下次看到“全绿”,先看这些检查实际走过哪一段。