2026/10/30 7:39:27

JS自动随机填写脚本:表单回归测试与可复现数据构造

JS自动随机填写脚本:表单回归测试与可复现数据构造 简介问卷星自动随机填写脚本js插件是一份面向需要批量测试问卷、演示表单或进行问卷数据模拟的用户整理的小工具核心为可在浏览器中运行的JavaScript用户脚本支持单选题、多选题和比重题等题型的自动随机填写适合具备基础脚本安装能力的使用者。压缩包共5个文件以js主脚本和txt说明为主并附带若干url快捷入口整体约9KB体积轻便便于保存和按说明部署。资源已有9972人学习说明其在问卷自动填写场景中具有一定关注度。读者可获得核心填写逻辑脚本、浏览器脚本安装指引及使用入口便于快速理解脚本运行方式并在符合平台规则与授权的前提下用于表单测试、流程演示或重复性录入操作。1. 自动随机填写脚本一份 js 插件替你干了哪几步活做表单回归测试的人都撞见过这种场面一份二十来题的问卷逻辑跳转、必填校验、多选上下限全都要验手动点完一轮七八分钟一天验十个版本就是纯体力活。我最初也不信这类自动随机填写脚本能省多少事直到某次改了一个多选题的互斥逻辑靠手点漏掉了三条分支。这类 js 插件属于很典型的轻量网络软件几百行浏览器脚本不依赖后端靠解析页面结构生成合规答案再模拟点击与输入。它适合自建表单的前端测试、埋点联调和本地样本构造不适合往真实数据采集场景里塞。下面按“结构怎么拆、脚本怎么跑、参数怎么调、哪里会翻车”四段讲。2. 拆解插件内核三类表单骨架与四种题型的识别链这份资源的体积很小真正的价值全在识别链上。同样一份问卷换一套前端模板DOM 结构可能完全不同脚本要能活下来靠的不是某一个选择器而是三层降级先找题目容器再判题型最后才生成答案。顺序错了就会连锁翻车——容器抓大了矩阵题的所有行会被当成一道题题型判错了多选题按单选处理必填校验直接卡死。2.1 三种 DOM 骨架决定脚本能不能通用实际遇到的表单骨架基本就三类。第一类是老式表格布局题干在td里选项在相邻td的input里第二类是divlabel布局题干有独立的标题节点选项被label包着第三类是前端框架渲染出来的受控组件外层是自定义标签input可能是隐藏的原生控件真正被点的是一层span或div。前两类靠querySelectorAll就能拿下第三类必须结合事件派发否则值改了框架也不认。不管哪一类第一步都是把页面切成“题目数组”。这里最容易犯的错是直接document.querySelectorAll(input)平铺处理结果把隐藏域、模板节点、搜索框全拉进来。我的做法是先抓候选容器再做父子去重只保留最外层最后过滤掉不可交互的控件。// 把任意一种表单骨架归一化成 [{ index, stem, inputs, required }] function collectQuestions(root document) { // 1) 候选容器类名特征 fieldset覆盖大部分模板 const blocks [ ...root.querySelectorAll([class*question], [class*field], [class*item], fieldset) ].filter(el el.querySelector(input, textarea, select)); // 2) 父子去重只留最外层容器避免一道题被数两次 const top blocks.filter(el !blocks.some(o o ! el o.contains(el))); return top.map((el, i) { const stemEl el.querySelector([class*title], [class*label], legend); const stem (stemEl?.innerText || el.innerText || ).trim().slice(0, 160); const inputs [...el.querySelectorAll(input, textarea, select)] .filter(n { if (n.type hidden || n.disabled) return false; const r n.getBoundingClientRect(); // 用尺寸判可见比 offsetParent 稳 return r.width 0 r.height 0; }); // 3) 必填判定题干带星号或容器上有 required 类名 const required /\*|必填/.test(stem) || /required/i.test(el.className); return { index: i, stem, inputs, required }; }).filter(q q.inputs.length 0); }这段代码里三个参数值得说明。stem截断到 160 字符是因为后面要靠题干文案兜底解析“最多选 N 项”这类约束太长会把选项文本也吃进去。可见性判断没用offsetParent ! null原因是position: fixed元素的offsetParent恒为null会误杀浮动在底部的提交条。required做成双通道是因为很多模板把星号画在 CSS 伪元素里innerText根本取不到。2.2 题型识别链路从 input type 到题干文案兜底归一化之后每道题手上只有一堆原生控件和一段题干。识别顺序必须是“结构特征优先、type 次之、文案兜底”因为矩阵题和量表题的原生控件全都是 radio只按 type 判会把它们全归成单选。矩阵题的判定特征是同一个容器内存在多个不同的name且这些name对应的 radio 数量一致。量表题更简单通常是一排 radio 配数字文案但它的语义和单选不同取答案的策略也不同。排序题和拖拽题没有原生控件识别结果必然是unknown这时候正确动作是记录日志后跳过而不是硬点。function detectType(q) { const list q.inputs; const types new Set(list.map(n (n.type || n.tagName).toLowerCase())); const names new Set(list.map(n n.name).filter(Boolean)); // 矩阵题容器内有多个分组每组都是 radio if (types.size 1 types.has(radio) names.size 1) return matrix; if (types.has(checkbox)) return multi; if (types.has(radio)) return single; if (types.has(select-one) || types.has(select)) return single; if (types.has(textarea)) return open; if ([text, tel, number, email].some(t types.has(t))) return fill; return unknown; }names.size 1这一句是矩阵题的命门。有些模板会把整道矩阵题的 radio 全部命名成同一个name这时候结构上它和普通单选无差别只能退回去看题干里有没有“请对以下各项打分”“每行选择一项”这类描述。我在实际用的时候会给unknown加一条告警跑完一轮先看日志里有多少条未识别超过两道就说明这套模板的骨架超出了脚本的覆盖范围得手动补规则而不是直接提交。还有一个细节select的type在部分浏览器里返回select-one在另一些环境返回select所以两个都要判。这类跨浏览器的琐碎差异不写进代码换台机器就跑不通。2.3 答案生成器约束优先随机在后真正决定这份资源好不好用的不是随机函数而是约束解析。多选题的题干里经常藏着“最多选 3 项”“至少选 2 项”这类约束不在 DOM 属性里只在文案里如果脚本不知道上下限就会出现两种情况一是只选一个提交时提示“请至少选择 2 项”二是全选撞上“最多选 3 项”的红线。两种都会让整轮测试白跑。我的处理是把约束解析做成独立的一层先扫题干和容器的>// 从题干文案里抽多选上下限抽不到就用保守默认值 function parseBounds(q) { const text q.stem.replace(/\s/g, ); const pick (re) { const m text.match(re); return m ? parseInt(m[1], 10) : null; }; const max pick(/(?:最多|至多|不超过)选?择?(\d)项?/); const min pick(/(?:最少|至少|不少于)选?择?(\d)项?/); const total new Set(q.inputs.map(n n.value)).size; return { min: min ?? 1, max: Math.min(max ?? 3, total) }; }replace(/\s/g, )是必须的模板渲染出来的题干经常夹着换行和全角空格“最多选 3 项”被拆成三行的情况我见过不止一次。total用new Set去重是因为有些模板会给“其他”选项配一个额外的输入框value重复会让上限算大。约束拿到之后才谈随机。单选按权重抽多选先定数量再按权重不放回抽填空按input的type和maxlength生成对应格式开放题走模板池。这一层的输出统一成一个picked结构后面写回页面和做快照都用它好处是排查问题时能直接对着快照看“这道题脚本到底选了什么”不用去翻 DOM。3. 跑通一次完整填写从脚本注入到提交的六步结构拆明白之后剩下的是工程问题脚本挂在哪里、配置怎么给、主流程怎么串、失败怎么收场。这一段给的是能直接抄的骨架改三个参数就能在本地的自建表单上跑起来。3.1 运行环境与两种加载方式两种加载方式各有适用场景。临时验证用浏览器开发者工具的 Console粘贴即跑缺点是刷新就没了而且长脚本粘贴容易丢字符反复用就写成用户脚本交给浏览器的用户脚本管理器托管靠match控制生效范围。我一般先用 Console 跑通再固化成用户脚本顺序反过来会浪费很多时间在“到底是脚本没注入还是逻辑写错了”上。// UserScript // name FormAutoFill-Test // namespace local.sandbox // version 0.1 // description 自建表单的自动化填写测试脚本仅用于本地/测试环境 // match https://your-test-domain.example/* // run-at document-idle // grant none // /UserScriptmatch一定要收窄到测试域名写成*://*/*的后果是打开任何页面都可能触发填写逻辑这个坑我踩过一次页面上的搜索框被填进了随机文本。run-at document-idle比document-start稳因为框架渲染的题目容器在 DOMContentLoaded 时往往还不存在太早执行只会抓到空数组。Console 方式要注意的是执行上下文。部分页面有 iframe 嵌套的表单Console 默认在顶层窗口执行document抓到的是外壳页面题目数组直接为空。这时候得先在控制台的上下文下拉里切到对应的 frame再执行脚本。3.2 配置对象七个必须改的参数配置集中在一个对象里不要散落在各处。下面这张表是我实际用的默认值每一项都对应一个具体后果不是拍脑袋定的参数默认值作用调错的后果durationMin/durationMax90 / 240 秒整份表单的目标耗时区间太短会撞上提交频控太长测试跑不完thinkMin/thinkMax800 / 3500 毫秒单题之间的间隔间隔为 0 时页面滚动和懒加载跟不上typingDelay60 毫秒开放题逐字输入间隔太小会触发输入框的防抖校验maxOpenLen60 字符开放题截断长度超过maxlength会被截断或报错fillRate0.92非必填题的作答概率设成 1 会让答案分布过于整齐seednull随机种子null表示每次不同需要复现时忘了设问题复现不了fillRate这一项容易被忽略。非必填题如果每道都答跑出来的样本在分布上会明显偏离真实填写做埋点联调时看不出“用户跳过某题”这条分支。留 8% 的跳过率顺便把“未作答”的展示逻辑也覆盖了。3.3 主流程代码与逐段说明主流程就是一个串行循环中间夹着等待。不写成并发是有意的表单的滚动加载、焦点切换、埋点上报都有时序依赖并发点选很容易出现“元素已存在但被遮挡”的报错。const CONFIG { durationMin: 90, durationMax: 240, thinkMin: 800, thinkMax: 3500, typingDelay: 60, maxOpenLen: 60, fillRate: 0.92, seed: null }; const sleep ms new Promise(r setTimeout(r, ms)); const rand (a, b) a Math.random() * (b - a); (async function main() { // 单例保护防止脚本被注入两次导致定时器叠加 if (window.__FORM_FILLING__) return; window.__FORM_FILLING__ true; const questions collectQuestions(); console.log([fill] 识别到 ${questions.length} 个题目容器); const snapshot []; for (const q of questions) { const type detectType(q); if (type unknown) { console.warn([fill] #${q.index} 题型未识别跳过${q.stem.slice(0, 30)}); continue; } // 非必填题按 fillRate 抽样跳过 if (!q.required Math.random() CONFIG.fillRate) continue; const bounds type multi ? parseBounds(q) : null; const picked answerFor(q, type, bounds); // 生成答案见 2.3 / 4.2 applyAnswer(q, type, picked); // 写回页面见 5.2 snapshot.push({ index: q.index, type, picked }); await sleep(rand(CONFIG.thinkMin, CONFIG.thinkMax)); } window.__FORM_SNAPSHOT__ snapshot; // 留给回放与断言 console.log([fill] 完成快照已挂到 window.__FORM_SNAPSHOT__); window.__FORM_FILLING__ false; })();逐段说。单例保护用的是挂在window上的布尔标记看起来土但比闭包变量可靠因为用户脚本重复注入时会创建新的闭包而window是共享的。answerFor和applyAnswer拆成两个函数是有讲究的前者纯计算、不碰 DOM可以单独跑单测后者只负责事件派发出错也只影响一道题。最后一步不自动点提交是我刻意留的。提交按钮的文案五花八门抓错了可能触发“保存草稿”或者“退出”而且在测试环境里自动提交会污染后端数据。正确做法是跑完之后人工确认一遍高亮的题目再手动提交把最后一公里留给眼睛。4. 参数调优时长、权重、文本长度这三处最容易翻车默认配置能跑通但跑出来的数据像不像“人填的”全看这三个参数。它们之间还有耦合时长压短了逐题间隔就必须跟着缩间隔一缩开放题的逐字输入就会露馅。4.1 时长参数整卷节奏与逐题间隔怎么配durationMin/durationMax是目标thinkMin/thinkMax是实现手段两者必须能对上否则脚本要么跑得比目标慢一倍要么快到触发频控。反算的公式很朴素可用总时长除以题数得到平均单题间隔再上下浮动 50% 作为区间。// 由目标总时长反算单题间隔避免两个参数互相打架 function planTiming(questionCount, cfg) { const avgTotal (cfg.durationMin cfg.durationMax) / 2 * 1000; const avgThink avgTotal / Math.max(questionCount, 1); return { thinkMin: Math.max(200, Math.round(avgThink * 0.5)), thinkMax: Math.round(avgThink * 1.5), estimatedSeconds: Math.round(avgThink * questionCount / 1000) }; }Math.max(200, ...)这个下限是血泪经验。题量大的表单比如四十题的满意度调查反算出来的平均间隔可能只有一百多毫秒实际跑起来页面滚动动画还没结束就点下一题报错率飙升。两百毫秒是滚动和焦点切换的底线低于这个值就别指望稳定。题量特别少的时候反过来要小心三道题、目标 90 到 240 秒平均单题间隔接近一分钟跑一轮要等太久。这种场景我一般把目标时长改成“题数 × 5 秒”让时长跟着题量走而不是死守一个固定区间。4.2 选项权重让随机结果贴近你要验证的分布均匀随机在测试里经常不够用。想验证“选了 A 之后 B 题才出现”的跳转逻辑就要让 A 的出现概率明显偏高想压测某个选项对应的长文本分支就得给它加权。权重表的价值在于把“随机”变成“可控的随机”跑完一轮能确认目标分支确实被覆盖到了。// 按权重不放回抽取 n 个选项n 由 min/max 约束决定 function pickMulti(options, bounds, weights [], random Math.random) { const n Math.min( options.length, Math.max(bounds.min, Math.floor(random() * (bounds.max - bounds.min 1)) bounds.min) ); const pool options.map((opt, i) ({ opt, w: weights[i] ?? 1 })); const picked []; while (picked.length n pool.length) { const total pool.reduce((s, p) s p.w, 0); let r random() * total, k 0; while (r pool[k].w) { r - pool[k].w; k; } picked.push(pool.splice(k, 1)[0].opt); } return picked; }weights[i] ?? 1的空值兜底很关键题干选项数量是动态的权重表往往是手写的长度对不上就会取到undefinedreduce出来的total变成NaN整个循环静默退出现象是“多选题一个都没选”。这类静默失败最难查因为控制台不报错。权重设 0 表示“这一轮永不选它”用来排除掉“以上都不是”这类破坏分支覆盖的选项。但要注意和min的冲突如果min2而权重为 0 的选项占了大多数采样器会陷入死循环。稳妥做法是在采样前先过滤掉权重为 0 的项再判一次可用数量是否满足min不满足就放宽。4.3 开放题文本长度、模板与重复率控制开放题是最容易露馅的地方。随机字符串一眼假同一段模板文本重复出现十次同样一眼假。我的做法是准备一个模板池模板里留数字占位填进去之后按maxOpenLen截断再用一个Set记录已用文本命中重复就换模板或加后缀。const OPEN_POOL [ 整体流程比较顺畅第{n}步的提示可以再明确一些, 页面加载速度还可以第{n}步的按钮位置希望调整, 功能基本满足需求第{n}步的操作路径偏长, 体验还行第{n}步的必填项说明不够清楚 ]; function makeOpenText(used new Set(), maxLen 60) { for (let i 0; i 24; i) { const tpl OPEN_POOL[Math.floor(Math.random() * OPEN_POOL.length)]; const text tpl.replace({n}, 1 Math.floor(Math.random() * 6)); const cut text.slice(0, maxLen); if (!used.has(cut)) { used.add(cut); return cut; } } // 兜底模板池耗尽时加短后缀保证不出现完全相同的两条 return ${OPEN_POOL[0].replace({n}, 1)}-${Math.random().toString(36).slice(2, 6)}.slice(0, maxLen); }slice(0, maxLen)一定要在去重之前做因为截断本身会产生新的重复——两条不同的模板截到 20 字符可能变得一模一样。顺序反了的话used里存的是截断前的文本判重就失效了。还有一个坑是输入法事件。部分表单会监听compositionstart/compositionend只派发input事件时这些表单认为输入未完成提交时报“请完成输入”。遇到这种情况不必伪造输入法事件把逐字输入改成一次性setNativeValue写入整段文本再派发change就行绝大多数表单能接受。5. 避坑与排查五类翻车现象的原因与解决下面这五条是我在这类脚本上真实撞过的按发生频率排序。前两条属于识别层中间两条属于交互层最后一条属于流程层。5.1 识别层翻车选错、漏选、矩阵题串行现象一多选题永远只勾一个选项。控制台看快照picked数组长度恒为 1。原因是parseBounds没抽到约束max退回默认值 3但采样时random() * (max - min 1)算出来的n有时候确实是 1——这不是 bug是均匀分布的正常表现。真正的问题是题干里明明写了“最多选 3 项”说明正则没匹配上。解决方式是把题干预处理再狠一点去掉全角空格、把“选择”和“选”统一、兼容“不超过三项”这种中文数字。中文数字我一般直接映射成阿拉伯数字再跑正则比写一堆分支省事。现象二矩阵题所有行都选了第一个选项。快照里只有一个index但页面上有五行。原因是detectType判成了singleanswerFor只返回一个值写回时querySelector(input[typeradio])命中了第一行的第一个。修复的关键在分组按tr或者按name把inputs拆成多组每组单独抽一个再统一写回。分组时优先用最近的tr祖先取不到再退化到name因为有些矩阵题用的是div网格布局压根没有table。5.2 交互层翻车值填了但校验不认现象三输入框里明明有文字提交时提示“该项为必填”。这是最经典的一个。原因是表单用的是框架受控组件直接el.value xxx只改了 DOM 属性框架内部的 state 还是空字符串。解决方式是走原生 setter再补派input和change。// 绕过框架对 value 的劫持先拿原型上的原生 setter再派发事件 function setNativeValue(el, value) { const proto el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const setter Object.getOwnPropertyDescriptor(proto, value).set; setter.call(el, value); el.dispatchEvent(new Event(input, { bubbles: true })); el.dispatchEvent(new Event(change, { bubbles: true })); }bubbles: true不能省很多框架把监听器挂在表单根节点上而不是元素本身不冒泡就收不到。单选和多选反过来要用el.click()不要直接设checked true因为click()会连带触发框架的选中逻辑和联动题目的显隐手写checked只会让页面看起来选中了实际状态没变。现象四隐藏域被填了随机值。现象是提交后后端报“参数非法”。原因是可见性过滤失效。getBoundingClientRect()对display:none的元素返回全 0这个没问题但对visibility:hidden的元素返回的还是有尺寸的矩形会被误判为可见。补充一个getComputedStyle(n).visibility ! hidden的判断就行代价很小。5.3 流程层翻车分页、频控与定时器泄漏现象五分页表单只填了第一页就结束。日志显示识别到 5 道题实际页面有三页共 15 道。原因是脚本在document-idle时执行一次翻页后 DOM 重建新一页的题目容器根本没被扫到。解决方式是把主流程包成一个可重入的函数用MutationObserver监听容器变化翻页按钮点下去之后等新容器出现再跑一轮。这里要注意加一个“本页已处理”的标记否则 DOM 每次微调都会触发重跑同一道题被反复改写。现象六跑第二轮时报错“元素已被移除”或者点两下才生效。八成是脚本被注入了两次。用户脚本管理器在页面内路由切换单页应用时可能重复注入window.__FORM_FILLING__只挡住了执行中的并发挡不住两轮串行。更稳的做法是在window上存一个带版本号的标记发现版本不一致就先清掉旧的定时器和监听器再启动新的。提示以上所有排查都以快照为准不要靠肉眼盯页面。快照里记下每道题的index、type和picked出错时能直接定位到是哪一层的问题。6. 进阶用法用种子随机把脚本变成可复现的测试数据源随机脚本最大的软肋是不可复现。周五跑出一个诡异的分支报错周一换了台机器重跑随机走的是另一条路问题再也复现不了。解决办法是把Math.random换掉用一个带种子的伪随机数生成器种子记进快照里出问题拿种子回放就行。// mulberry3232 位种子实现短、分布够用、结果完全可复现 function makeRandom(seed) { let s seed 0; return function () { s (s 0x6D2B79F5) 0; let t Math.imul(s ^ (s 15), 1 | s); t (t Math.imul(t ^ (t 7), 61 | t)) ^ t; return ((t ^ (t 14)) 0) / 4294967296; }; } const random makeRandom(CONFIG.seed ?? Date.now()); // 之后所有采样都传这个 random包括 pickMulti 的第三个参数改造点只有一处把pickMulti、answerFor里所有的Math.random()换成传入的random。这件事必须一次性改干净漏掉一处就会出现“大部分题可复现、个别题每次都变”的诡异现象排查成本比不改还高。改完之后快照里补上seed和脚本自身的标识回放时用同一个种子跑一遍逐题比对picked数组不一致就是逻辑改动引入的回归。题型生成器回放校验点single加权抽取一个选项value相等multi约束内不放回抽取集合相等不看顺序matrix按行分组各抽一个每行一组逐行比对open模板池 截断去重文本长度与去重标记这张表的价值在于“校验点”那一列多选不能用数组相等去比因为抽取顺序不同结果就判失败必须转成集合。矩阵题要按行拆开比整体比会漏掉“行序错位”这种问题。从那以后我每次动这类脚本都强制先跑一遍固定种子、把快照存成文件改完再跑一遍同样的种子diff 干净了才敢往测试环境推。这个习惯比任何调试技巧都省时间因为大部分回归问题不是逻辑写错了而是某次顺手改了个选择器把一道题的识别结果悄悄改掉了——没有快照比对这种改动根本发现不了。希望帮到你。本文还有配套的精品资源点击获取