简介:这份压缩包面向完成软件测试课程设计的高校学生与Java Web测试入门者,以网上书店前台系统为被测对象,把JavaWeb开发、IDEA工程构建与软件测试流程串成一个可复用的课程设计案例。内容围绕白盒测试的逻辑覆盖与基本路径覆盖、黑盒测试的等价类与边界值分析展开,并对核心功能代码使用JUnit做单元测试,同时借助LoadRunner完成稳定性与压力测试,读者可据此理解测试用例设计、执行与结果分析。压缩包共904个文件、约45.97MB,包含jsp页面、java源码、class编译文件、jar依赖、xml配置以及gif/jpg/png界面素材,工程与测试文档结构较完整,便于按模块查阅和复现。已有2351人学习,适合需要一套完整测试方案、单测示例和性能测试思路的读者参考。
1. 一个能直接跑起来的软件测试课程设计,比你想象的好用
拿到这份《软件测试课程设计.zip》的时候,我没急着看文档,先解压翻代码——通宵赶过课程设计的人都有经验,最怕那种只有十几页 Word 的“测试方案”,系统截图全靠 P 图。这份不一样,它有一套完整的 JavaWeb + MySQL 学籍管理小系统当被测物,配套的是围绕这个系统做的功能用例、LoadRunner 压测脚本、缺陷记录和测试报告。适合两类人:一类是要交课程设计、需要能演示能复现的同学;另一类是想搞明白“单元测试、功能测试、性能测试到底怎么串起来”的开发者。它代码不复杂,Servlet + JDBC + JSP 的老三样,但正因为简单,登录链路、分页查询、并发瓶颈这些点都能一眼看穿,拿来做测试靶场再合适不过。
2. 目录结构与技术选型:一个 JavaWeb 测试靶场是怎么搭起来的
很多人拆包第一件事是找 README,我更习惯直接看目录——目录结构里藏着作者的思路,也藏着你要花多久才能跑起来。这份工程的排序很典型:源码、SQL 脚本、测试文档、压测脚本四块分开,互不干扰,改代码不会冲掉测试物料,改用例也不用碰 Java 目录。
2.1 目录结构逐层拆解
解压后根目录大致是这个样子(不同版本的小差异不影响整体判断):
software-test-course-design/ ├── src/ │ ├── main/ │ │ ├── java/com/example/stms/ │ │ │ ├── controller/ # Servlet 层:LoginServlet、StudentServlet │ │ │ ├── service/ # 业务层:接口 + impl │ │ │ ├── dao/ # 数据层:BaseDao、StudentDao、UserDao │ │ │ ├── entity/ # 实体:User、Student、PageResult │ │ │ └── util/ # DBUtil、MD5Util、StringUtils │ │ ├── resources/ │ │ │ └── jdbc.properties # 数据库连接四要素 │ │ └── webapp/ │ │ ├── WEB-INF/web.xml │ │ ├── jsp/ # login.jsp、student_list.jsp、student_edit.jsp │ │ └── static/ # css / js / 分页插件 │ └── test/java/com/example/stms/ │ └── service/ # JUnit 测试类 ├── sql/ │ └── stms.sql # 建库建表 + 初始数据 ├── docs/ # 测试计划、用例表、缺陷记录、测试报告 ├── lr/ # LoadRunner 脚本目录(.usr / .c / 参数文件) └── pom.xml这个划分最有价值的地方是controller / service / dao三层切得干净。测试同学最怕“业务逻辑埋在 JSP 里”,那种代码既没法写单元测试,也没法定位性能瓶颈。这里 Service 层是纯 Java 类,输入输出都是对象,你用 JUnit 直接 new 出来就能测,不需要启 Tomcat;DAO 层只负责 SQL 和结果集映射,出了问题一看就知道是 SQL 的事还是业务的事。docs/和lr/单独拿出来放,是为了让你在改代码之后能顺手把测试用例和脚本一起更新,而不是回头找不到原件。
pom.xml在根目录,说明这是个标准 Maven 工程,导入 IDEA 之后依赖会自动下(前提是你本地仓库能连通,这一点第 5 章会专门说)。
2.2 技术栈组合:为什么是 Servlet + JDBC 而不是上 SSM
从pom.xml和web.xml能读出这套选型,我把它列成表格,方便你判断要不要动它:
| 层次 | 技术 / 版本 | 作用 | 备注 |
|---|---|---|---|
| 运行环境 | JDK 1.8 | 编译与运行 | 别用 17,Servlet 3.1 容易踩模块化坑 |
| Web 容器 | Tomcat 8.5 | Servlet 容器 | 与 Servlet 3.1 对齐 |
| 视图 | JSP + JSTL 1.2 | 页面渲染 | 用<c:forEach>出表格 |
| 控制层 | Servlet 3.1(@WebServlet) | 路由分发 | 注解式,不用配一堆 xml |
| 数据层 | 原生 JDBC + 手写 DBUtil | CRUD | 没有 ORM,SQL 全透明 |
| 数据库 | MySQL 5.7 / 8.0 | 数据存储 | 8.0 要换com.mysql.cj.jdbc.Driver |
| 连接池 | 无(DriverManager 直连) | —— | 这恰恰是压测的教学点 |
| 单测 | JUnit 4.12 | 单元测试 | @Test、@Before够用 |
| 压测 | LoadRunner 12.x | 性能测试 | Web(HTTP/HTML) 协议 |
课程设计不上 Spring,不是水平不够,是刻意为之。第一,被测系统越简单,测试结论越干净——你压出来的响应时间就是 Servlet + JDBC + MySQL 的,不会掺进框架的 AOP 代理和连接池排队时间。第二,源码可读性高,初学者能顺着LoginServlet一路读到UserDao.findByUsername,中间不跳任何一层。第三,也是最实际的一点:没有连接池,DriverManager.getConnection()每次请求新建物理连接,50 并发就能把 MySQL 打满,这正好给你一个“性能瓶颈长什么样”的真实样本。
提示:如果你打算把这份工程当毕设往上加功能,我一般会先补 Druid 连接池和 MyBatis,但补之前一定先跑一遍原始版本,留一份基线数据,不然优化完了你没法证明“优化有效”。
2.3 数据库表结构与初始化脚本
sql/stms.sql是整个工程能不能跑起来的第一道门槛,脚本长这样(我按实际字段整理过,核心就这三张表):
CREATE DATABASE IF NOT EXISTS stms DEFAULT CHARACTER SET utf8mb4; USE stms; -- 登录用户表:password 存的是 MD5,不是明文 CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE, password CHAR(32) NOT NULL, role TINYINT NOT NULL DEFAULT 0 -- 0 学生 1 管理员 ) ENGINE=InnoDB; -- 班级表:给学生的 class_id 做关联 CREATE TABLE t_class ( id INT PRIMARY KEY AUTO_INCREMENT, class_name VARCHAR(64) NOT NULL, major VARCHAR(64) ) ENGINE=InnoDB; -- 学生表:压测和分页测试的主表 CREATE TABLE t_student ( id INT PRIMARY KEY AUTO_INCREMENT, sno VARCHAR(20) NOT NULL UNIQUE, -- 学号,唯一键 name VARCHAR(32) NOT NULL, gender TINYINT NOT NULL DEFAULT 0, class_id INT NOT NULL, phone VARCHAR(20), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_name (name), -- 模糊查询走不到,但排序能缓解 KEY idx_class (class_id) ) ENGINE=InnoDB; INSERT INTO t_user(username, password, role) VALUES ('admin', 'e10adc3949ba59abbe56e057f20f883e', 1); -- 123456 的 MD5几点说明,都是踩过才知道的。utf8mb4不是utf8,因为姓名里偶尔会有生僻字,用utf8存进去会变成问号,后面导出报表全是“?”你就知道疼了。t_user.password用CHAR(32)而不是VARCHAR(255),是因为 MD5 定长 32 位,定长字段在索引和比较上都更省。预置账号的密码是123456的 MD5,登录的时候 Service 层会把用户输入也做一次 MD5 再比对,这在功能测试里是一条典型的“等价类”用例:正确密码、错误密码、空密码、超长密码。
初始化顺序不能反:先CREATE DATABASE,再USE,最后建表。用 Navicat 或命令行source都行,但一定要确认字符集是 utf8mb4,否则后面查出来的中文全是乱码,你会以为是 Servlet 的锅,其实建库那一步就错了。
3. 环境搭建与首次运行:IDEA + Tomcat + MySQL 走到登录页
从解压到看见登录页,正常节奏是二十分钟,卡住的话一晚上。下面这条链路是我按顺序排的,每一步都有明确的“走通了是什么样”,你可以对着排查。
3.1 版本对齐:JDK 8 / Tomcat 8.5 / MySQL 5.7 为什么要配套
这套组合不是随便配的。Servlet 3.1 的注解@WebServlet需要 Tomcat 8 及以上才完整支持,Tomcat 7 也能跑但有兼容问题;JDK 8 之后的 9、11、17 引入了模块化,老工程的javax.servlet包名在新容器里会变成jakarta.servlet,直接 ClassNotFound。所以我的建议是先严格按作者给的版本跑通,跑通之后再升级,别一上来就折腾环境。
| 组件 | 推荐版本 | 检查命令 | 常见错误 |
|---|---|---|---|
| JDK | 1.8.0_xxx | java -version | 版本过高导致Unsupported class file major version |
| Maven | 3.6+ | mvn -v | 没配JAVA_HOME导致编译失败 |
| Tomcat | 8.5.x | 启动日志无红色 | 端口 8080 被占用 |
| MySQL | 5.7 / 8.0 | mysql -uroot -p | 8.0 需换驱动类名和时区参数 |
java -version输出的如果是1.8.0_181这类,就是对的;如果冒出来17.0.x,先去 IDEA 的Project Structure → SDK里把工程和模块的 SDK 都改成 8,别只改一处,两处不一致是最常见的翻车点。
3.2 在 IDEA 里导入工程、挂 Tomcat、配数据源
导入走File → Open选pom.xml,IDEA 会自动识别成 Maven 工程并下载依赖。下载完之后你去External Libraries里扫一眼,应该能看到javax.servlet-api、junit、mysql-connector-java这三个,缺哪个就手动Reload All Maven Projects。
接着配 Tomcat,Run → Edit Configurations → + → Tomcat Server → Local,几个关键项:
Application server:指向本地解压的 Tomcat 8.5 目录Deployment:加一个Artifact,选xxx:war exploded,Application context填/stmsOn 'Update' action:选Update classes and resources,改 JSP 不用重启
Application context这一项要注意,它决定了你访问的 URL 前缀。填/stms就访问http://localhost:8080/stms/jsp/login.jsp,填/就是根路径。工程里如果大量用了${pageContext.request.contextPath},那填什么都行;如果作者图省事写死了/stms,那你就得跟着填/stms,否则所有静态资源 404。
数据源不用在 IDEA 里配,工程用的是jdbc.properties文件,下一节说。
3.3 初始化数据库与修改 jdbc.properties
src/main/resources/jdbc.properties一般是这四行:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://127.0.0.1:3306/stms?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true jdbc.username=root jdbc.password=你的密码逐个说:com.mysql.cj.jdbc.Driver是 MySQL 8.0 驱动的新类名,5.x 驱动用com.mysql.jdbc.Driver,写错了就是ClassNotFoundException,这个错误信息很有迷惑性,看着像依赖没下,其实是类名不对。serverTimezone=Asia/Shanghai是 8.0 必加项,不加会报The server time zone value is unrecognized,而且这个错只在运行时报,编译期一点提示都没有。allowPublicKeyRetrieval=true是 8.0 默认加密方式导致的,不加的话本地连接可能被拒。useSSL=false是消掉那串黄色警告,本地开发无所谓。
改完这个文件记得一件事:它在resources下,编译后会进target/classes,如果你改了源文件但没重新Build,Tomcat 跑的还是旧配置,你会对着配置发呆半小时。
3.4 冒烟验证:五个必点页面与一分钟自检
环境弄好了,别急着做测试,先按顺序点一遍,验证“系统是活的”:
- 访问
/stms/jsp/login.jsp,页面能出、样式正常(样式丢了说明静态资源路径有问题) - 用
admin / 123456登录,能跳转到学生列表(跳不过去先看控制台异常栈) - 列表页有数据、分页条显示总数(没数据就回去查 SQL 脚本有没有执行成功)
- 点“新增”,填一条学号重复的数据,应该被拒绝(这一步验证唯一键和异常处理)
- 模糊搜索一个姓,结果条数正确(验证
LIKE拼接和参数绑定)
这五步走完,你可以认为基础设施没问题了。后面所有测试活动——写用例、跑 JUnit、录 LoadRunner 脚本——都建立在这五步之上。血泪经验是:千万别跳过冒烟直接录脚本,LoadRunner 回放一堆 500 的时候,你根本分不清是脚本错了还是系统本来就是坏的。
4. 核心代码拆解:登录链路、分页查询、JUnit 与 LoadRunner 脚本
这一章是整份工程最值得读的部分。测试做得好不好,很大程度上取决于你对被测代码的理解深度——你连它哪一步查库、哪一步写 Session 都不知道,用例设计就只能靠拍脑袋。
4.1 登录请求的完整链路:一次 POST 从 LoginServlet 到 Dao
先看控制层,这段代码几乎是所有 JavaWeb 课程设计的标准骨架:
@WebServlet("/login") public class LoginServlet extends HttpServlet { private final UserService userService = new UserServiceImpl(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 必须在第一次 getParameter 之前设置,否则中文参数已按 ISO-8859-1 解码 req.setCharacterEncoding("UTF-8"); String username = req.getParameter("username"); String password = req.getParameter("password"); // Service 内部完成:空值校验 -> MD5 -> 查库 -> 比对 User user = userService.login(username, password); if (user == null) { req.setAttribute("msg", "用户名或密码错误"); req.getRequestDispatcher("/jsp/login.jsp").forward(req, resp); return; } // 登录成功写 Session,压测时会成为共享资源竞争点 req.getSession().setAttribute("loginUser", user); resp.sendRedirect(req.getContextPath() + "/student?action=list"); } }这段的每个细节都能对应一条测试用例。setCharacterEncoding必须在getParameter之前调用,写反了中文用户名就是乱码,这是功能测试里的字符集用例。user == null的分支只返回一句含糊的“用户名或密码错误”,安全上是对的,但测试上你要分别覆盖“账号不存在”和“密码错误”两种输入,确认提示语一致——如果某天有人改成了“该用户不存在”,那就是一个信息泄露缺陷。sendRedirect用的是重定向而不是转发,所以登录成功后浏览器地址栏会变,这一点在你用 LoadRunner 做脚本关联时非常关键:重定向会产生两个 HTTP 请求,脚本里必须把Location头或者JSESSIONID做关联,否则后续请求全部 302。
再往下是 Service 和 Dao,逻辑很短:
public User login(String username, String password) { if (StringUtils.isBlank(username) || StringUtils.isBlank(password)) { return null; // 空值直接短路,不打库 } String md5 = MD5Util.encode(password); // 与库里存的 32 位 MD5 对齐 return userDao.findByUsernameAndPwd(username, md5); }短,但它决定了你的用例边界:空字符串、纯空格、超长字符串(比如 500 个字符)、SQL 注入串(' or '1'='1)、大小写混写。因为用了PreparedStatement,注入串会被当普通字符串处理,查不到人,返回 null——这条用例的期望结果就是“登录失败且系统不报 500”,而不是“登录成功”。
4.2 分页查询:手写 limit 的 JDBC 版本
分页是这套系统里唯一有点技术含量的地方,也是最容易出 bug 的地方:
public PageResult<Student> findByPage(String keyword, int pageNum, int pageSize) { StringBuilder where = new StringBuilder(" FROM t_student WHERE 1=1 "); List<Object> params = new ArrayList<>(); // 关键字条件:姓名或学号模糊匹配,两个占位符共用一个值 if (StringUtils.isNotBlank(keyword)) { where.append(" AND (name LIKE ? OR sno LIKE ?) "); params.add("%" + keyword + "%"); params.add("%" + keyword + "%"); } // 先查总数,注意这里复用同一套 where 条件 int total = studentDao.count(where.toString(), params.toArray()); // 再查当页数据,LIMIT 的两个参数追加在最后 String sql = "SELECT id,sno,name,gender,class_id,phone,create_time " + where + " ORDER BY id DESC LIMIT ?, ?"; params.add((pageNum - 1) * pageSize); // offset params.add(pageSize); // size List<Student> rows = studentDao.query(sql, params.toArray()); return new PageResult<>(total, pageNum, pageSize, rows); }关键在于两点。第一,count和query必须复用同一套where条件,否则会出现“总数 100,翻到第 5 页却是空的”这种玄学现象,本质是 count 少了过滤条件。第二,LIMIT ?, ?的两个参数是追加在params末尾的,顺序不能反——第一个是偏移量(pageNum-1)*pageSize,第二个是页大小。这里如果传成LIMIT pageSize, offset,第一页看着正常,翻到第二页就开始错位,属于典型的“测一页测不出来”的 bug。
测试这条链路时,我一般设计这么几组:pageNum=0(非法值)、pageNum=1(边界)、pageNum=总页数(最后一页)、pageNum=总页数+1(越界)、pageSize=0(会查出全表,性能隐患)。后端如果没做pageNum < 1的兜底,offset会变成负数,MySQL 直接抛语法错误,前端就是一片白。
4.3 给 Service 层补 JUnit 单元测试
这份工程的src/test/java下有 JUnit 用例,模式很朴素,但该有的都有:
public class UserServiceTest { private UserService userService; @Before // 每个 @Test 之前都执行,保证用例互不干扰 public void setUp() { userService = new UserServiceImpl(); } @Test public void 登录_正确账号密码_返回用户对象() { User user = userService.login("admin", "123456"); assertNotNull("正确凭证应能登录成功", user); assertEquals("admin", user.getUsername()); } @Test public void 登录_密码错误_返回null() { assertNull(userService.login("admin", "wrong-pwd")); } @Test public void 登录_用户名为空_返回null() { assertNull(userService.login("", "123456")); assertNull(userService.login(null, "123456")); } }用中文方法名在 JUnit 4 里完全合法,好处是测试报告一跑出来,一眼就知道哪条挂了,不用回头翻代码。@Before每次都重建 Service 实例,避免用例之间共享状态——这一点在涉及数据库的测试里尤其重要,因为 DAO 是有副作用的。注意这类测试是连真实数据库的,不是 Mock,所以跑之前数据库必须是通的,而且测试数据要可重复(比如固定用admin这个预置账号,别用测试过程中新增的数据,否则第二次跑就失败了)。
想再进一步,就把 Dao 抽成接口,用 Mockito 打桩,让单测彻底脱离数据库。但对课程设计来说,连库的测试反而更贴近真实,能顺带验证 SQL 写没写错。
4.4 测试用例设计:等价类、边界值落到具体字段
docs/里的用例表不是拿来看的,是要能对着执行的。我把登录和学生新增两个模块的用例抽出来,格式是这样:
| 用例编号 | 模块 | 测试点 | 输入数据 | 预期结果 | 优先级 |
|---|---|---|---|---|---|
| TC-LOGIN-01 | 登录 | 正确凭证 | admin / 123456 | 跳转列表页,Session 有值 | 高 |
| TC-LOGIN-02 | 登录 | 密码错误 | admin / 111111 | 停留登录页,提示错误 | 高 |
| TC-LOGIN-03 | 登录 | 用户名为空 | 空 / 123456 | 提示“用户名不能为空” | 中 |
| TC-LOGIN-04 | 登录 | 超长用户名 | 300 个字符 | 不报 500,提示错误 | 中 |
| TC-STU-01 | 新增学生 | 学号重复 | 已存在的 sno | 提交失败,提示学号已存在 | 高 |
| TC-STU-02 | 新增学生 | 手机号 11 位边界 | 13800000000 | 保存成功 | 中 |
| TC-STU-03 | 新增学生 | 手机号 12 位 | 138000000001 | 校验失败 | 中 |
设计方法是老三样:等价类划分把输入分成有效类和无效类,边界值盯住 0、1、最大值、最大值+1,场景法串起“登录→查询→新增→退出”的完整流程。真正拉开差距的是优先级那一列——高优先级的用例是每次回归都要跑的,中低优先级的可以只在版本发布前跑一遍。课程设计答辩的时候,老师最爱问的就是“你为什么这么排优先级”,答案应该是“按业务影响面和出错概率”,不是“按我写的顺序”。
4.5 LoadRunner 脚本录制、参数化与场景编排
lr/目录下是性能测试的部分,用的是 Web(HTTP/HTML) 协议。录制之后的脚本要改,改完大概是这样:
Login() { /* 集合点:让 N 个虚拟用户同时发起登录,制造真实并发 */ lr_rendezvous("login_point"); lr_start_transaction("login"); web_submit_data("login", "Action=http://{host}/stms/login", "Method=POST", "RecContentType=text/html", ITEMDATA, "Name=username", "Value={p_username}", ENDITEM, "Name=password", "Value={p_password}", ENDITEM, LAST); lr_end_transaction("login", LR_AUTO); /* 登录成功后必须关联 JSESSIONID,否则后续请求全 302 */ web_reg_save_param_ex( "ParamName=jsession", "LB=Set-Cookie: JSESSIONID=", "RB=;", SEARCH_FILTERS, LAST); return 0; }几个参数得说清楚。{p_username}和{p_password}是参数化变量,对应一个.dat参数文件,里面放 100 行不同的账号,Parameter type选File,Select next row选Unique,Update value on选Each iteration——这三项配错,压出来全是同一个账号登录,缓存命中率虚高,数据没意义。lr_rendezvous是集合点,只有在场景里设置了集合策略(比如 100 个用户到齐 80% 才放行)才生效,不加集合点,所谓“并发”其实是排队。lr_start_transaction/lr_end_transaction圈定事务边界,LoadRunner 的响应时间统计就是按这个区间算的,你不圈,事务响应时间那一列全是空的。
场景设计上,课程设计常用的梯度是 10 / 30 / 50 并发,每档跑 5 分钟,观察 TPS 和平均响应时间。因为没有连接池,你大概率会看到 30 并发时 TPS 见顶、响应时间开始爬坡,50 并发直接出现Too many connections。这不是脚本的问题,这是被测系统的真实瓶颈——性能测试报告里最有价值的一句话,就是“瓶颈定位在数据库连接创建,建议引入连接池”。
5. 常见问题排查:五个能让课程设计卡一晚上的坑
下面这五条,基本覆盖了从导入到压测的全流程。每条按“现象 → 原因 → 解决”写,对号入座就行。
5.1 现象:启动就报 ClassNotFoundException: com.mysql.cj.jdbc.Driver
现象:Tomcat 启动没报错,一访问登录页就抛异常,栈顶是ClassNotFoundException,指向驱动类。
原因:两种可能。一是pom.xml里的mysql-connector-java版本是 5.1.x,但jdbc.properties写的是 8.0 的com.mysql.cj.jdbc.Driver;二是依赖在 Maven 里是provided或test作用域,打包进WEB-INF/lib时被排除了。
解决:先看pom.xml的版本号,5.1.x 配com.mysql.jdbc.Driver,8.0.x 配com.mysql.cj.jdbc.Driver,一一对应。再看 IDEA 的Project Structure → Artifacts → WEB-INF/lib里有没有那个 jar,没有就把依赖作用域改成compile,重新Build Artifacts。我一般还会在DBUtil的静态块里加一行Class.forName(driver),显式加载,报错信息更直白。
5.2 现象:列表页中文全是问号,数据库里查却是正常的
现象:MySQL 客户端里SELECT出来的姓名正常,页面上显示???,或者一堆菱形方块。
原因:链路上有三处编码,任何一处不对都会这样——数据库/表的字符集、JDBC URL 的characterEncoding、HTTP 请求响应的编码。工程里最容易漏的是第三处:web.xml没配CharacterEncodingFilter,JSP 页面也没写<%@ page contentType="text/html;charset=UTF-8" %>。
解决:按顺序查。SHOW CREATE TABLE t_student看是不是utf8mb4;JDBC URL 里确认useUnicode=true&characterEncoding=utf8都在;web.xml或 Servlet 里补req.setCharacterEncoding("UTF-8")和resp.setContentType("text/html;charset=UTF-8")。三处对齐还不行,就看 Tomcat 的server.xml里 Connector 有没有URIEncoding="UTF-8",老版本 Tomcat 默认不是 UTF-8,GET 请求的中文参数会挂在这一步。
5.3 现象:LoadRunner 回放全是 302,或者提示“未找到关联值”
现象:录制的脚本直接回放,登录事务失败,web_submit_data之后跟着一串 302,业务请求全部跳回登录页。
原因:LoadRunner 录制的是“一次会话”的完整轨迹,回放时是新的会话。服务端的JSESSIONID变了,你却没把响应里的新 Cookie 取出来传给后续请求,服务端认为你没登录,自然全部重定向。
解决:在登录请求之前加web_reg_save_param_ex抓Set-Cookie里的JSESSIONID,后续每个请求用web_add_cookie("JSESSIONID={jsession};DOMAIN=127.0.0.1")带上。判断关联做没做对的方法很简单:回放时打开Runtime Settings → Log → Extended log,看每次请求的 Header 里 Cookie 是不是同一个值。另外重定向本身也会产生额外请求,web_submit_data后面加一个web_url显式跟随,比让 LoadRunner 自动跳转更可控。
5.4 现象:并发上到 50 就开始大量超时,MySQL 报 Too many connections
现象:10 并发、30 并发都正常,50 并发时错误率飙到 40%,日志里全是Too many connections或者连接超时。
原因:工程用的是DriverManager.getConnection()直连,每个请求创建一次物理连接、用完关掉。并发一高,连接创建速度和 MySQL 的max_connections(默认 151)顶上了,再加上 TCP 三次握手和认证开销,响应时间被拉长,连接释放更慢,雪崩。
解决:短期把max_connections调大只能缓解,治标。正确做法是引入连接池,Druid 或 HikariCP 都行,initialSize=5、maxActive=20、maxWait=3000,改完重跑同一组并发,对比 TPS 曲线。这一步做完,你的性能测试报告就有了“优化前 vs 优化后”的对比数据,比单纯贴一张响应时间图表有说服力得多。
5.5 现象:JUnit 本地跑全绿,Maven 打包时测试全挂
现象:在 IDEA 里点绿色三角跑测试,全过;执行mvn package时Tests run: 3, Failures: 3。
原因:IDEA 跑测试用的是工程的工作目录和编码配置,Maven 用的是surefire插件的默认配置。最常见的是编码不一致——测试类和方法名是中文,surefire默认平台编码在 Windows 上是 GBK,读源码就乱码了;其次是工作目录不同,测试里读相对路径的配置文件读不到。
解决:在pom.xml的surefire插件里显式指定<argLine>-Dfile.encoding=UTF-8</argLine>,同时给maven-compiler-plugin配上<encoding>UTF-8</encoding>。如果确实只是本地想快速打包,用mvn package -DskipTests跳过,但别养成习惯——跳过测试打包,等于把单元测试这道防线自己拆了。
6. 二次开发的改造思路:从课程设计到能拿得出手的项目
这份工程的上限不在代码,在于你有没有把它往“工程化”方向推一步。我给你三条改造线,从易到难,都能在原有结构上动刀,不用推倒重来。
第一条是补连接池和事务。DAO 层现在是每个方法自己getConnection、自己close,改成ThreadLocal持有连接,配合 Filter 在请求结束时统一提交或回滚。这一步做完,你会发现“新增学生 + 新增用户”这种跨表操作的测试用例才有意义——现在没有事务,中间失败就是脏数据,你连缺陷都不好提。
| 改造项 | 动哪几个文件 | 验证方式 | 收益 |
|---|---|---|---|
| 连接池 | DBUtil、pom.xml | 50 并发错误率对比 | 性能报告有优化前后数据 |
| 事务控制 | BaseDao、新增TransactionFilter | 制造异常看是否回滚 | 能提“数据一致性”类缺陷 |
| 参数校验 | Service层入口 | 边界值用例全部通过 | 减少 500 错误 |
第二条是把测试物料变成可执行的。docs/里的用例表现在是 Excel,手工执行。你可以用 JUnit 5 的@ParameterizedTest把等价类用例直接写成参数化测试,把“用例表”和“自动化脚本”合成一份。这样每次改完代码,跑一遍mvn test就是一次回归,比对着 Excel 一条条点强太多。我一般会先从登录模块下手,因为它的输入输出最干净,改造代价最小,跑通之后再往业务模块铺。
第三条是把 LoadRunner 的结论落回代码。压测报告里写“响应时间随并发线性增长”,这只是现象。你要做的是回到代码里找到那一行——比如LoginServlet里req.getSession().setAttribute在 50 并发下的锁竞争,或者findByPage里count和query两次往返。找到之后改一版,用同一套脚本再压一次,两次数据摆在一起,这才叫性能测试。只跑一次压测就交报告的,老师一问“为什么慢”就答不上来。
最后说个习惯。我早期做这类课程设计,最常犯的错是“改完代码不重跑基线”,压测数据一会儿好一会儿坏,最后自己都不知道哪个数是准的。从那以后我每次动完代码,都强制先把冒烟那五步走一遍,再跑 1 并发基线,确认功能没坏、单请求响应时间没有异常漂移,才敢往上加并发。这个动作花不了五分钟,但能省掉你一晚上的自我怀疑。希望帮到你。
本文还有配套的精品资源,点击获取