在线考试系统测试分析:链路测试、异常边界与性能压测
1. 测试对象与整体思路拆解
1.1 考问通系统的业务链路到底长什么样
拿到“考问通系统测试分析报告”这个任务时,我第一件事不是打开用例管理工具,而是先把这条业务链路在白板上画了一遍。原因很简单:这类在线考试与问答测评系统,单点功能的正确性从来不是最大风险,链路断在哪个环节才是。考问通系统在我这里的定位是一套面向教学与培训场景的在线测评平台,核心能力包括题库维护、组卷排考、考生作答、自动与人工判分、成绩发布与复核归档。它解决的问题是把线下纸笔测评搬到线上,并且让出题、组卷、监考、阅卷、统计这几件事在同一套数据上流转,不用反复导表对数。
适合读这份分析的人有三类:一是刚接手类似项目、需要一份可复用的测试拆解思路的测试同学;二是负责系统交付、想知道验收该盯哪些点的研发和产品;三是需要向上汇报测试结论、但手里只有零散执行记录、不知道怎么组织成报告的项目负责人。这三类人的关注点不一样,但底层需求是同一个——想知道这套系统在真实考试压力下会不会出事,出事会在哪里出。
所以我把整条链路拆成十段:题库入库、题目审核、组卷策略、考试发布、考生身份校验、作答与暂存、交卷与超时处理、判分核算、成绩发布、成绩复核与归档。每一段的失败成本完全不同。题库里错一道题,影响的是所有抽到它的考生;组卷策略出错,可能整场考试的难度分布全部失衡;而作答环节的暂存丢失,往往只影响个别考生,但投诉率最高。测试资源的分配必须按失败成本来,而不是按功能模块的数量来平均切。
这里有个容易被忽略的点:考问通系统里“考试”是一个有时效性的实体,它有开始时间、结束时间、交卷方式(到时自动交卷还是手动交卷)、允许迟到时长、允许中断次数。这些时间相关的配置项之间是有交互的,两两组合起来的场景数量远超直觉。我通常会把这类时间配置单独拉一张表做正交组合,而不是混在普通功能用例里,否则很容易漏掉“迟到15分钟入场 + 中途断线3分钟 + 距结束2分钟手动交卷”这种叠加场景。
1.2 为什么把测试重心从“功能点”挪到“链路优先”
早期我做过一版按功能模块划分的测试计划,题库、组卷、考试、判分、成绩五大块,每块独立写用例,独立验收。执行下来通过率很好看,接近百分之九十八,但上线后第一场真实考试就出了问题:考生在考试中途切换了一下网络环境,页面刷新后答题记录回退了三四道题。这个问题单看任何一个模块都测不出来——前端认为本地缓存还在,后端认为最近一次提交是十秒前,中间那几次异步提交在弱网下被合并或者丢弃了,没有任何一方觉得是自己的错。
那次之后我改了策略,把用例按链路组织。每条链路是一个完整业务目标的端到端路径,比如“一名迟到考生通过手机端完成一场90分钟考试的完整过程”,里面串起了登录、身份校验、取卷、作答、暂存、断线重连、交卷、判分、出分九个环节。链路用例的数量比功能用例少得多,但能挖出的缺陷质量完全不在一个层级。功能用例保证的是“这个按钮点了有反应”,链路用例保证的是“这个人考完试能拿到分”。
这个转变背后有个判断依据:考问通系统属于典型的有状态长事务系统。一场考试对单个考生来说是一个持续几十分钟到几个小时的事务,中间会经历几十次网络请求,任何一次请求的时序错乱都可能导致状态不一致。传统按模块切的测试方法天然假设状态是干净的,而真实考试里状态永远是脏的。所以我给自己定的规矩是,链路用例的优先级永远高于功能用例,时间不够的时候先砍功能用例。
还有一层考虑是团队协作成本。链路用例天然是跨模块的,写的时候必须把前端、后端、数据库三方的人拉到一起对齐,这个过程本身就能提前暴露接口约定上的分歧。我见过太多项目,前后端各自对着文档开发,联调时才发现对“交卷成功”的定义不一样——前端认为接口返回成功就算交卷,后端认为要等判分任务入队成功才算。这种分歧在功能测试阶段几乎发现不了,在链路测试里一测就现原形。
1.3 测试环境与数据基线怎么定
环境这件事我必须多说两句,因为考问通系统的测试环境如果建得随意,后面所有结论都不可信。我一般分四级:开发自测环境、集成测试环境、预发环境、生产影子环境。前两级可以共用数据库,第三级必须数据隔离,第四级只读生产数据做验证。预发环境的关键要求是配置与生产一致,尤其是考试时长、自动交卷宽限时间、判分任务的重试次数这些参数,测试环境和生产环境差一个值,压测出来的结论就废了。
数据基线我习惯在测试开始前锁定一份,写成配置表固下来。下面这张表是我在考问通系统项目里实际用过的一份基线,数值是按中等规模教学场景估的,可以按实际业务量等比放大。
| 基线项 | 取值 | 设定理由 |
|---|---|---|
| 题库总题量 | 20000 道 | 覆盖多学科多题型,模拟两年积累量 |
| 单场考试考生数 | 3000 人 | 按单院系集中排考的上限估 |
| 单套试卷题量 | 80 题 | 含单选、多选、判断、填空、简答五类 |
| 主观题占比 | 15% | 主观题决定人工阅卷压力 |
| 考试时长 | 90 分钟 | 常规课程考试时长 |
| 平均答题间隔 | 60 秒/题 | 用于推算请求频率 |
| 同时进行中的考试场次 | 3 场 | 模拟排考叠加 |
| 判分任务单场耗时上限 | 120 秒 | 自动判分必须在此时间内出分 |
这份基线的作用是让所有测试结论有参照物。比如压测报告里写“系统支持 3000 并发”,必须说清楚这个 3000 对应的题量、题型分布、答题间隔是多少,脱离基线谈并发数字没有任何意义。我在评审别人的测试报告时,第一眼看的就是有没有这张基线表,没有的话后面的数据基本只能当参考。
还有一点是数据造数的成本控制。题库数据不能全靠人工录,我一般写脚本批量生成,但生成的题目必须满足业务约束:题干长度分布合理、选项数量符合题型要求、知识点标签不能全挤在同一个分类下、难度系数要接近正态分布。如果造出来的数据全是短题干、四选项、同一知识点,那组卷算法里的去重逻辑和知识点覆盖逻辑就永远测不到边界。数据造得好,一半的缺陷在造数阶段就能暴露。
2. 核心细节解析与实操要点
2.1 题库与组卷:最容易埋雷的地方
组卷是考问通系统里逻辑密度最高的一个模块,也是最容易被低估的。表面上看就是“按规则抽题凑成一张卷子”,实际上要同时满足一堆互相牵制的约束:总分必须等于设定分值、题型配比必须符合模板、知识点覆盖度必须达到阈值、难度分布必须落在目标区间、同一知识点下的题目不能重复出现、同一道题不能被同一考生在不同场次重复抽到。这些约束里任何一条处理不好,生成的卷子就是废卷。
我踩过最典型的一个坑是总分凑不齐。组卷算法按难度分层抽题,抽完发现总分是 97 分,目标 100 分,于是触发补题逻辑,补了一道 3 分的题。看起来没问题,但补进来的这道题属于另一个知识点,把原本刚好达标的知识点覆盖度给破坏了。这种缺陷单看抽题结果是对的,总分对、题型对,只有把知识点分布单独拉出来统计才会发现异常。所以我后来在用例里强制加了一条校验:组卷完成后必须输出一份分布报告,包含总分、题型数量、知识点分布、难度分布、重复率五个指标,任何一个指标越界都算失败。
另一个高频问题是抽题去重的作用域搞错。去重的作用域至少有三层:同一张试卷内不能重复、同一考生跨场次不能重复、同一场次内不同考生之间可以重复但要控制重复率。这三层的实现方式完全不同,第一层在内存里做集合判断就行,第二层必须落库记录考生历史抽题,第三层需要做频次统计。我见过有项目把三层混成一层做,结果考生第二次参加补考时抽到了和上次一模一样的卷子。这种问题在测试环境里几乎不会自己冒出来,因为你不会主动去造“同一考生考两次”的数据,必须专门写用例去撞。
组卷模块的用例设计我一般用约束矩阵的方式,把所有约束两两配对,看是否存在互斥。比如“高难度题占比不低于 30%”和“简答题不超过 5 道”这两个约束,如果高难度题里简答题占比很高,就可能出现无解的情况——算法必须在限定时间内要么放宽某个约束要么报错。这时候要验证的不是算法能不能解,而是解不出来的时候系统怎么表现:是静默生成一张不合规的卷子,还是明确提示“当前题库无法满足组卷规则”。前者是灾难,后者才是可接受的。
注意:组卷失败的处理路径一定要单独设计用例。很多团队只测“组卷成功”的路径,结果真实排考时题库某个知识点被抽空了,系统直接返回一张残缺试卷,考务人员毫无察觉。
2.2 考试过程与异常边界:真实考生不会按剧本操作
考试过程的测试,核心不是“正常作答能不能交卷”,而是“各种不正常的情况下,考生的答案还在不在”。我做过统计,考问通系统上线后收到的考生投诉里,超过六成和“答案丢失”或“答案回退”有关,而真正因为判分错误引起的投诉不到一成。这个比例很说明问题。
异常场景我按触发源分成三类。第一类是网络类:断网、弱网、网络切换(从移动数据切到无线网络)、请求超时、响应乱序。第二类是客户端类:页面刷新、浏览器返回键、切到后台再切回、多标签页同时打开同一场考试、多设备同时登录同一账号。第三类是服务端类:暂存接口超时、判分任务堆积、考试时间配置被中途修改。
这三类里,最需要重点投入的是请求乱序。作答页面的暂存通常是定时提交加事件触发提交,两种机制并存时就容易乱序。比如考生答完第 20 题触发了事件提交,同时定时器正好在提交第 18 题的状态,如果服务端没有做版本号或时间戳校验,后到的旧请求就会覆盖新状态,考生看到的第 20 题答案就没了。验证这个场景的方法很直接:在浏览器开发者工具里人为延迟某个暂存请求,让它晚于后续请求到达,观察最终落库的状态。这个手法我每次做考试类系统都要用,屡试不爽。
多设备登录这块有个业务判断需要提前和产品对齐:同一账号在两台设备上同时进入考试,是踢掉前一台,还是两台都允许作答、以最后提交的为准?两种策略都合理,但实现和测试方法完全不同。前一种要验证踢下线的时机和提示文案,后一种要验证冲突合并逻辑。我遇到过最糟糕的情况是两套策略都没实现,两台设备各自暂存,最后交卷时谁先提交谁生效,另一台的答案直接丢掉,考生完全无感知。这类问题的根因往往是需求没写清楚,测试也没往这个方向想。
超时自动交卷的边界也值得单独说。系统一般在考试结束时间点触发批量交卷,如果 3000 名考生在同一秒被强制交卷,瞬时请求量会非常可观。测试时要验证的不只是“能不能交上”,还包括:自动交卷期间考生手动点击交卷会怎样、自动交卷失败会不会重试、重试失败后考生的答案有没有兜底留存。我的做法是在预发环境把考试结束时间设成几十秒后,用脚本模拟三百个在线会话同时跨过结束点,观察交卷成功率和失败重试记录。
2.3 判分与成绩核算:小数点和边界值最伤人
判分模块的测试有个特点:正常数据怎么测都对,出问题的永远是边界。客观题判分逻辑简单,但多选题的“少选得分、多选不得分”这种规则,如果分值配置支持小数,就很容易在累加时出现精度问题。我用 0.5 分、0.25 分、0.1 分这种分值做过一轮专项,发现某个实现里用浮点数累加二十道题之后,总分出现了 0.30000000000000004 这样的结果,直接显示在成绩页面上。修复方式是把分值统一转成整数分(乘以 100)再计算,最后除回去。
主观题判分的问题更复杂一些,因为它涉及人工阅卷和双评机制。双评的核心是两位阅卷人独立打分,分差在阈值内取平均,超出阈值转三评。这里的边界值特别多:分差恰好等于阈值算不算超阈值、三评分数和双评平均分怎么合成、阅卷人中途退出留下的半成品任务怎么处理。我一般会画一张判定表,把所有分支的输入输出列全,然后逐行写用例,这样不容易漏。
成绩核算还有一个隐蔽的点是及格线与总分的一致性。有的考试配置里及格线是百分比(比如 60%),有的配置是绝对分值(比如 60 分),如果两种配置方式同时存在,很容易出现配置了 60 分但系统按 60% 算的情况。测试方法很土但有效:把总分设成 150 分,及格线设成 90 分,然后构造一个 89 分和一个 90 分的成绩,看判定结果。如果系统把 89 分判成及格,说明它按 60% 算了(89/150 约等于 59.3%,应该不及格),这时候就能定位到配置读取的问题。
成绩复核环节常被忽略,但对教学场景很重要。复核意味着成绩发布后还能被修改,修改后要留下完整的变更记录:谁改的、什么时间改的、原值多少、新值多少、改的原因。测试时我会专门验证两点:一是复核修改后,统计报表里的数据是否同步更新(很多系统报表是定时同步的,会出现成绩页新、报表页旧);二是复核修改是否触发重新排名,如果排名算法是增量更新的,很可能漏掉。
2.4 性能与容量:别用错误的模型压出好看的数字
性能测试最容易犯的错是压测模型和真实业务不符。用固定并发一直压接口,压出来的 TPS 很好看,但真实考试根本不是这种流量形态。真实考试的流量是一条明显的曲线:开考后前五分钟是登录和取卷的高峰,中间七十分钟是低频的暂存请求,结束前五分钟是交卷高峰。三个高峰的请求类型完全不同,压力特征也完全不同。
我的做法是按阶段分别建模。取卷高峰关注的是并发读和缓存命中率,暂存阶段关注的是长连接的维持和低频写的响应时间,交卷高峰关注的是瞬时写入和判分任务的排队。这三个阶段分别压,分别出结论,最后再合成一份整体容量评估。
下面这张表是我在某次容量评估里用的分阶段压测参数,可以作为模板参考。
| 阶段 | 时长占比 | 并发用户数 | 请求类型 | 关键指标 |
|---|---|---|---|---|
| 登录取卷 | 前 8% | 峰值为平均值的 4 倍 | 读为主 | 取卷响应时间 P95 |
| 正常作答 | 中间 84% | 平均并发 | 低频写 | 暂存成功率、响应时间 |
| 集中交卷 | 后 8% | 峰值为平均值的 5 倍 | 写为主 | 交卷成功率、判分入队时延 |
并发用户数的推算我一般用这个思路:单场 3000 人,考试时长 90 分钟,假设考生在考试过程中的操作是分散的,那么平均并发大约在几百的量级;但登录和交卷这两个动作是高度集中的,按经验大概有七成考生会在开考后十分钟内完成登录取卷,这七成里的峰值瞬间并发就需要单独估。具体的数字没法拍脑袋,我通常先按经验值设一个初值,然后用阶梯加压找出拐点,再拿拐点反推能支撑的考生规模。
提示:压测结论里一定要标注测试数据的题型分布。全是单选题的试卷和含 15% 主观题的试卷,判分阶段的压力差好几倍,混在一起说没有参考价值。
3. 实操过程与核心环节实现
3.1 环境搭建与题库造数脚本
环境搭建这一步,我的习惯是先写一份可重复执行的初始化脚本,而不是手工点。考问通系统涉及的表不少,题库、试卷、考试场次、考生、作答记录、成绩,手工造数据既慢又容易不一致。下面这段是我常用的题库造数脚本骨架,用 Python 写,核心思路是按题型模板批量生成并保证分布合理。
import random import json from datetime import datetime QUESTION_TYPES = ["single", "multiple", "judge", "blank", "essay"] TYPE_WEIGHT = [0.45, 0.20, 0.15, 0.12, 0.08] KNOWLEDGE_POINTS = [f"kp_{i:03d}" for i in range(1, 41)] DIFFICULTY_LEVELS = [1, 2, 3, 4, 5] DIFFICULTY_WEIGHT = [0.15, 0.25, 0.30, 0.20, 0.10] def gen_stem(qtype, kp, idx): # 题干长度按题型区分,避免全部同质化 base_len = {"single": 40, "multiple": 55, "judge": 25, "blank": 35, "essay": 120}[qtype] jitter = random.randint(-8, 25) return f"[{kp}] 第{idx}题:" + "内容" * max(8, (base_len + jitter) // 2) def gen_question(idx): qtype = random.choices(QUESTION_TYPES, TYPE_WEIGHT)[0] kp = random.choice(KNOWLEDGE_POINTS) difficulty = random.choices(DIFFICULTY_LEVELS, DIFFICULTY_WEIGHT)[0] item = { "id": f"Q{idx:06d}", "type": qtype, "knowledge_point": kp, "difficulty": difficulty, "stem": gen_stem(qtype, kp, idx), "created_at": datetime.now().isoformat(timespec="seconds"), } if qtype in ("single", "multiple"): opt_count = random.choice([3, 4, 4, 4, 5]) item["options"] = [f"选项{c}" for c in "ABCDE"[:opt_count]] if qtype == "single": item["answer"] = random.choice(item["options"]) item["score"] = random.choice([1, 2, 2, 2, 3]) else: correct = random.sample(item["options"], random.randint(2, 3)) item["answer"] = correct item["score"] = random.choice([2, 3, 3, 4]) item["partial_score_rule"] = random.choice(["none", "half", "by_count"]) elif qtype == "judge": item["answer"] = random.choice([True, False]) item["score"] = 1 elif qtype == "blank": item["answer"] = [f"答案{i}" for i in range(random.randint(1, 3))] item["score"] = random.choice([2, 3]) else: item["answer"] = "参考答案要点" * random.randint(3, 6) item["score"] = random.choice([8, 10, 12, 15]) return item def build_bank(total=20000): bank = [gen_question(i) for i in range(1, total + 1)] with open("question_bank.json", "w", encoding="utf-8") as f: json.dump(bank, f, ensure_ascii=False, indent=1) return bank if __name__ == "__main__": data = build_bank() print(f"generated={len(data)}") dist = {} for q in data: dist[q["type"]] = dist.get(q["type"], 0) + 1 print("type_dist=", dist)这段脚本有几个设计意图值得说明。题型权重和难度权重不是平均分配,而是按真实卷面结构反推的,平均分配造出来的题库会让组卷算法的分支覆盖率虚高,看起来很全面,实际碰不到真实的配比约束。题干长度加抖动,是为了避免全文等长导致分页、折叠、富文本渲染这些前端逻辑测不出问题。partial_score_rule这个字段是我特意加上去的,因为多选题的给分规则在不同考试里差异很大,如果造数时不带这个维度,判分模块的那几个分支就永远是死代码。
脚本跑完之后我会再跑一段校验,检查生成的题库在知识点覆盖度和难度分布上是否接近目标。如果发现某些知识点一道题都没有,就补几条定向数据。这一步不能省,因为组卷算法里“知识点覆盖度”的约束一旦在题库层面就无法满足,后面所有组卷测试都会变成无效测试。
3.2 接口自动化用例的组织方式
自动化这块我不追求覆盖率数字,更看重用例的可维护性。考问通系统的接口数量中等,但如果把所有接口都写成独立的断言脚本,维护成本会高到没人愿意碰。我的组织方式是三层:底层是请求封装和登录态管理,中间层是按业务动作聚合的关键字(比如“登录并取卷”“作答并暂存”“交卷并轮询判分结果”),最上层才是用例。
用例本身我用 YAML 描述,这样才能让不写代码的同学也参与维护。下面是一个链路用例的描述片段,描述的是“迟到考生入场并完成考试”这条路径。
case_id: exam_late_join_001 title: 迟到考生在允许迟到时间内入场并正常交卷 priority: P0 tags: [链路, 考试过程, 时间边界] precondition: exam: start_offset_min: 0 duration_min: 90 allow_late_min: 15 submit_mode: auto_and_manual steps: - name: 登录并进入考试 action: login_and_enter params: user: candidate_0001 exam_id: "${exam_id}" expect: enter_result: success paper_question_count: 80 - name: 在第10分钟入场 action: wait_until params: exam_elapsed_min: 10 - name: 连续作答20题并暂存 action: answer_and_save params: count: 20 interval_sec: 3 expect: saved_count: 20 - name: 模拟断网30秒后恢复 action: network_interrupt params: duration_sec: 30 - name: 校验断网期间答案未丢失 action: query_answer_state expect: answered_count: 20 - name: 手动交卷 action: submit_paper expect: submit_result: success status: submitted - name: 轮询判分结果 action: poll_score params: timeout_sec: 120 expect: score_not_null: true用 YAML 描述的好处是把“做什么”和“怎么做”分开了。执行层解析 YAML 调用中间层关键字,用例变更时只改 YAML,不动代码。这套结构在项目里跑了半年多,维护成本明显低于纯代码用例。缺点是复杂断言写起来啰嗦,我的折中方案是允许在 YAML 里内嵌一小段 Python 表达式,只在少数需要复杂计算的断言里用,其余场景一律走标准断言。
自动化的执行节奏我也调整过。最早是每天全量跑一次,跑完出一份报告,但全量用例跑一轮要四十多分钟,反馈太慢。后来改成三层:提交触发的冒烟集(五分钟内出结果,只覆盖核心链路)、每小时跑一次的回归集、每天夜间的全量集。冒烟集失败就阻断合并,回归集失败只告警。这个分层不是拍脑袋定的,是按“缺陷发现后修复成本”倒推的——越早发现越省钱,所以冒烟集必须快。
3.3 压测脚本与并发参数的实际推算
压测我用过两套工具,脚本化能力强的时候用代码写,需要快速出图的时候用现成的压测平台。下面这段是用代码写的压测脚本核心部分,重点是模拟真实的作答行为,而不是无脑打接口。
import random import time from locust import HttpUser, task, between class CandidateUser(HttpUser): wait_time = between(2, 6) def on_start(self): self.token = None self.exam_id = None self.answered = 0 self.login_and_enter() def login_and_enter(self): resp = self.client.post("/api/auth/login", json={ "account": f"candidate_{random.randint(1, 3000):04d}", "password": "test_only" }) if resp.status_code != 200: return self.token = resp.json().get("token") headers = {"Authorization": f"Bearer {self.token}"} enter = self.client.post("/api/exam/enter", json={"examId": "E2024001"}, headers=headers) if enter.status_code == 200: self.exam_id = "E2024001" @task(20) def answer_and_save(self): if not self.token: return headers = {"Authorization": f"Bearer {self.token}"} payload = { "examId": self.exam_id, "questionId": f"Q{self.answered + 1:06d}", "answer": ["A"], "clientVersion": self.answered + 1, } self.client.post("/api/exam/save", json=payload, headers=headers, name="/api/exam/save") self.answered += 1 @task(2) def heartbeat(self): if not self.token: return headers = {"Authorization": f"Bearer {self.token}"} self.client.get("/api/exam/heartbeat", headers=headers, name="/api/exam/heartbeat") @task(1) def submit(self): if not self.token: return headers = {"Authorization": f"Bearer {self.token}"} self.client.post("/api/exam/submit", json={"examId": self.exam_id}, headers=headers, name="/api/exam/submit")@task后面的数字是权重,不是次数。暂存请求权重 20、心跳 2、交卷 1,这个比例是根据真实考试的行为分布估的:一名考生在 90 分钟里大约提交八十次暂存、九十次心跳、一次交卷。权重设错了,压出来的瓶颈就是假的。我见过有人把交卷权重设成和暂存一样高,结果压测报告显示交卷接口是最大瓶颈,实际上真实场景里交卷请求占比不到百分之一。
并发规模的推算我一般这么走:先用小并发跑一轮,拿到单用户的平均响应时间和资源占用,然后按目标考生规模做线性外推,得到一个理论初值,再用阶梯加压验证。举个例子,单场 3000 人,假设七成考生在开考后十分钟内完成取卷,那么取卷峰值的瞬时并发大概在两千一百人的量级,但这些人不是同一秒到达的,分散在十分钟里,按经验取一个集中系数,实际压测的时候从 300 并发开始,每轮加 100,观察 P95 响应时间和错误率,找到错误率开始抬头的拐点。
拐点找到之后不要直接用拐点数值当结论,要留出余量。我的习惯是取拐点的六到七成作为容量上限,因为压测环境永远比生产环境干净,中间层的网络、缓存、连接池在真实环境里都会打折扣。这个余量不是拍脑袋,是我在几个项目里对比压测结果和上线后监控数据得出的经验值。
3.4 测试分析报告到底该写什么
回到这份报告的本身。标题叫“考问通系统测试分析报告”,但很多团队交上来的东西其实是测试执行记录的堆砌:跑了多少用例、通过多少、发现多少缺陷、严重级别分布。这些数据有用,但撑不起“分析”两个字。分析报告的核心是回答三个问题:系统在什么条件下是可靠的、在什么条件下会出问题、出问题的影响范围有多大。
我的报告骨架一般是这样:第一部分写测试范围与不在范围内的内容,明确边界,避免后面被追问“为什么没测这个”;第二部分写测试环境与数据基线,把前面那张基线表放进去;第三部分写各维度的测试结论,每个维度给结论、给依据、给风险;第四部分写缺陷分析,不罗列所有缺陷,只挑有代表性的和趋势性的;第五部分写风险清单和改进建议,每条风险要标影响面和触发条件。
第三部分是重头戏,也是最容易写虚的地方。我的写法是每个维度都用“结论 + 数据 + 边界”三段式。比如性能维度,结论是“在基线数据下支持单场三千人规模”,数据是“峰值并发下的 P95 响应时间和错误率”,边界是“当主观题占比超过 25% 时判分时延会超出预期”。三段缺一不可,只有结论没有边界的报告,读者没法判断这个结论能用到什么场景。
缺陷分析我一般不按严重级别简单堆砌,而是按根因归类。考问通系统的缺陷根因我大致归成五类:状态同步类、时间边界类、并发竞争类、数据精度类、配置读取类。按根因归类的好处是能看出系统性的薄弱点,比如状态同步类缺陷如果占了四成,说明前后端的状态管理设计本身有问题,不是修几个 bug 能解决的,得从架构上重新对齐。
风险清单我坚持写“触发条件”这一列。没有触发条件的风险等于没说,谁都不知道什么时候会踩上。比如“判分任务堆积”这条风险,触发条件写成“单场主观题超过八百份且阅卷人同时在线数低于十人”,这样运维和产品都能判断自己场景下会不会碰到。
4. 常见问题与排查技巧实录
4.1 高频缺陷速查表
下面这张表是我在这个项目里实际遇到过、并且认为复现价值比较高的缺陷类型,整理成速查表,遇到类似现象可以先照着对。
| 现象 | 高概率根因 | 快速验证方法 | 处理方向 |
|---|---|---|---|
| 考生刷新页面后答题记录回退 | 暂存请求乱序,旧状态覆盖新状态 | 在开发者工具里延迟某次暂存请求 | 服务端加版本号或时间戳校验 |
| 组卷总分差几分 | 分层抽题后补题破坏其他约束 | 导出组卷分布报告核对五项指标 | 补题逻辑需重新校验全部约束 |
| 多选题少选得分不对 | 给分规则按选项数算而非按权重 | 构造少选1项、少选2项的用例 | 统一用整数分值计算后除回 |
| 自动交卷后成绩查不到 | 判分任务入队失败无重试 | 查看判分任务队列的失败记录 | 加失败重试与死信告警 |
| 同一考生两次考试抽到相同卷 | 抽题去重作用域未覆盖跨场次 | 同一账号连续参加两场 | 去重记录需按考生维度落库 |
| 成绩复核后报表数据未变 | 报表为定时快照,未联动更新 | 复核后立即查报表与成绩页对比 | 复核触发报表重算或标脏 |
| 迟到考生入场后剩余时间不对 | 剩余时间按入场时刻算而非开考时刻 | 构造迟到 10 分钟入场场景 | 统一以考试结束时间为基准 |
| 及格判定与配置不符 | 百分比与绝对值配置混用 | 总分设 150、及格线设 90 验证 | 配置项明确单位并加校验 |
这张表我自己用了很久,基本上新同学进项目第一周我就会给他,让他照着表里的验证方法自己动手复现一遍。复现的过程比看文档学得快,而且能顺便验证环境是不是好的。
4.2 三个典型故障的排查路径
第一个是答案回退。排查这类问题我有一套固定顺序:先看前端本地存储里有没有完整答案,有的话说明是提交环节丢了,没有的话说明是本地存储被清了。然后看服务端的作答记录表,按时间倒序取最近十条,看有没有版本号倒退的情况。再看网关或接入层的日志,确认请求到达顺序和前端发出顺序是否一致。这三步走完,基本能定位到是前端提交策略的问题、服务端幂等处理的问题,还是中间层的乱序问题。我遇到过一次是前端定时器和事件触发同时提交,两次请求几乎同时发出,服务端先处理了后发的那个,导致版本号倒退。改法是前端只保留一个提交入口,用队列串行化。
第二个是判分超时。现象是考试结束后超过两分钟还没出分。排查顺序是先看判分任务队列的积压量,再看单条判分任务的耗时分布,最后看判分服务依赖的资源。有一次发现积压量不大但耗时很长,追下去是主观题的相似度计算在逐条跑,没有批量化,单条耗时随题库规模线性增长。这类问题的处理方向是批量化加缓存,而不是加机器,加机器只能缓解不能解决。
第三个是取卷接口偶发超时。偶发问题最烦人,因为不好复现。我的做法是先看超时请求的分布特征:是不是集中在某个时间段、是不是集中在某类考生、是不是和某个特定试卷相关。有一次发现超时全都集中在开考后第三到第五分钟,而这三分钟正好是缓存预热没完成的时间窗口。根因是缓存过期时间设得太短,开考瞬间大量请求穿透到数据库。改法是延长缓存有效期,并在考试开始前主动预热。这个问题的教训是,缓存策略必须结合业务的时间特征来设计,不能照搬通用配置。
4.3 踩过的坑和几条实操心得
说几条文档里不会写的东西。第一条是关于测试数据的时效性。考问通系统里的考试是有时间属性的,你今天造的考试场次,明天就过期了,所有涉及考试的用例全部失效。我早期的做法是每次执行前重新造数据,但造数本身要花十几分钟,很拖节奏。后来的解法是把考试时间设成一个相对偏移量,脚本执行时动态生成,比如“当前时间加十分钟开始、时长九十分钟”,这样数据永远有效。这个改动看着小,实际把整条自动化流水线的可用性提了一大截。
第二条是关于不要相信界面上看到的时间。考试剩余时间这类倒计时,前端一般是本地计算加服务端校准,两边的时钟一旦有偏差,界面显示的时间和实际的服务端判定时间就会不一致。我专门做过一次验证,把测试机的系统时间调快五分钟,结果界面上显示还剩五分钟的时候,服务端已经判超时了。所以所有和时间相关的断言,必须以服务端返回的时间戳为准,前端显示只能作为辅助参考。这个坑我在两个项目里都踩过。
第三条是关于缺陷的复现环境。有些缺陷只在预发环境出现,在测试环境怎么都复现不了,原因往往是配置差异或数据量差异。我的习惯是遇到这种问题先做环境比对,把两边的关键配置项和表数据量拉出来逐项对比,十有八九能看出端倪。最没效率的做法是先在测试环境反复试,试不出来再去预发试,来回折腾半天。
第四条心得是关于评审。测试分析报告写完之后,我一定会找一个非测试岗位的同事读一遍,最好是产品或者运维。如果他们看完之后问“所以这场考试到底能不能撑住”,说明报告没写到位,结论不够直白。好的报告应该让不看细节的人也能在三十秒内拿到核心结论,细节留给需要的人往下翻。我现在的做法是在报告开头放一段不超过两百字的结论摘要,把“能支撑多大规模”“有哪些已知风险”“哪些场景没覆盖”三句话说清楚。
最后分享一个我在报告里固定加的小节,叫“本轮测试未覆盖的场景”。这一节经常被要求删掉,理由是显得测试不充分,但我坚持保留。原因很实在:任何测试都有边界,把边界写清楚,后续出问题时大家知道去哪找原因,而不是一上来就怀疑测试漏了。写清楚没测什么,比含糊地说“已充分测试”要负责得多。这个习惯是我在做第一个大型项目时被一次线上故障教出来的,那次故障恰好落在一个没人明确说过“不测”的角落里。