目录
测试用例设计

在本篇文章中,主要进行功能测试、界面测试和性能测试
功能测试
注册功能测试

正常注册
我们以 用户名:李四 密码:123456为例进行注册
输入用户名和密码,点击注册按钮后,页面成功跳转至登录页面


异常注册
我们首先来看 用户名为空的情况:
仅输入密码点击注册:

我们可以发现,虽然弹出了提示信息,但是提示的异常信息过多,其中大多数信息并不是客户端所需要的,因此,我们需要对其进行修改
为什么会打印上述错误信息呢?
由于我们使用**@Validated** 注解来校验参数,而这些参数未通过验证时,就抛出了 MethodArgumentNotValidException 异常,而 MethodArgumentNotValidException被作为未知异常进行捕获:

在处理未知异常时直接将错误信息返回给了前端
因此,我们可以对 MethodArgumentNotValidException 异常进行处理,当捕获到 MethodArgumentNotValidException 异常时,表明传递的参数出现异常,此时我们仅返回我们之前指定的 message信息即可
在 GlobalErrorCodeConstants中添加全局错误码:
java
ErrorCode BAD_REQUEST = new ErrorCode(400, "客户端请求错误");
在 GlobalExceptionHandler中添加:
java
/**
* 捕获 @Valid / @Validated 注解校验异常
* @param e
* @return
*/
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public CommonResult<?> validationException(MethodArgumentNotValidException e) {
// 打印错误日志
log.info("MethodArgumentNotValidException: ", e);
// 获取所有字段验证错误
String errors = e.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> error.getDefaultMessage())
.collect(Collectors.joining(", "));
// 构造异常情况下的返回结果
return CommonResult.fail(GlobalErrorCodeConstants.BAD_REQUEST.getCode(), errors);
}
调用e.getBindingResult().getFieldErrors() 可以获取所有错误列表 ,再遍历这些错误,获取每个错误的错误消息 ,最后,再使用Collectors.joining(", "),将所有的错误消息连接成一个字符串,以 , 作为分隔符
此时的提示信息就简洁清晰了许多:
此外,我们也可以在客户端发送请求之前对用户名和密码进行校验, 从而提升用户体验并减少不必要的服务器请求:
javascript
<script>
let btn = document.querySelector('#submit');
btn.onclick = function() {
let name = $("#name").val();
let password = $("#password").val();
if (name == "") {
alert("用户名不能为空");
return;
}
if (null == "") {
alert("用户密码不能为空");
return;
}
$.ajax({
url: "/register",
type: "POST",
contentType: 'application/json',
data: JSON.stringify({
name: name,
password: password,
}),
success: function(result) {
if(result.code == 200) {
// 注册成功,跳转至登录页面
location.assign("login.html");
}else {
alert(result.errorMessage);
}
}
});
}
</script>
我们继续看注册已存在用户的情况:
再次注册 用户名:李四 密码:123456:

此时直接抛出了 SQLIntegrityConstraintViolationException异常,提示插入一条新记录时,发生了违反唯一性约束的错误
这是因为我们在数据入库之前并未对用户名进行校验:

添加用户名校验:
java
@Override
public UserRegisterResultDTO register(UserRegisterParam param) {
// 参数校验
if (userMapper.selectByUserName(param.getName()) != null) {
throw new ServiceException(ServiceErrorCodeConstants.USER_INFO_EXISTS);
}
if (!checkPassword(param.getPassword())) {
throw new ServiceException(ServiceErrorCodeConstants.PASSWORD_CHECK_ERROR);
}
// 数据入库
UserDO userDO = new UserDO();
userDO.setUserName(param.getName());
userDO.setPassword(SecurityUtil.encipherPassword(param.getPassword()));
userMapper.insert(userDO);
// 构造响应并返回
UserRegisterResultDTO registerResultDTO = new UserRegisterResultDTO();
registerResultDTO.setUserId(userDO.getId());
return registerResultDTO;
}
添加 service 层错误码:
java
ErrorCode USER_INFO_EXISTS = new ErrorCode(102, "用户已存在");
此时再次尝试注册:

接着,我们继续看用户密码为空的情况:

提示信息正确
密码长度过长或过短:

此时的提示信息并不准确:
在密码校验失败时,抛出自定义的PASSWORD_CHECK_ERROR 异常:

因此,我们对异常信息进行修改,分别定义注册和登录密码校验异常信息:

此外,在对密码进行校验时,我们仅对密码长度进行了校验:

但除了对长度进行校验,我们还应该对其中的字符进行校验,防止其中出现中文或其他非 ASCII 字符的情况,从而出现编码问题,影响用户体验或导致登录失败
因此,我们校验密码为 6 - 12 位,且仅能使用数字或字母:
java
private boolean checkPassword(String password) {
if (!StringUtils.hasText(password)) {
return false;
}
// 使用正则表达式校验密码长度应为 6-12 位,且只包含数字和字母
String regex= "^[0-9A-Za-z]{6,12}$";
return Pattern.matches(regex, password);
}

登录功能测试

同样的,我们在客户端发送请求之前对用户名和密码进行校验:

正常登录
使用注册的 用户名和密码进行登录:


输入用户名和密码,点击登录按钮后,页面成功跳转至游戏大厅页面
异常登录
用户名为空或用户名错误:

密码为空或密码错误:

用户多开:
再次登录 李四 账号:

点击确定后跳转至登录页面:

匹配功能测试

两个天梯分数相近的玩家进行匹配:

匹配成功,并显示对手信息
天梯分数较高玩家匹配天梯分数较低玩家:


此时,两名玩家分别加入了不同的匹配队列,因此,并不能进行匹配
玩家在匹配过程中取消匹配:


点击取消匹配后,将玩家从对应队列中移除
异常情况:
玩家在匹配过程中退出游戏房间:

将玩家从对应匹配队列中移除
对战功能测试

玩家1进入游戏房间后,等待对手进入房间:

两名玩家均进入游戏房间后,开始游戏:

玩家轮流落子:

胜负判定:

异常情况:
一方玩家中途退出游戏房间:

匹配成功后,玩家1在等待玩家2过程中退出游戏房间:

可以看到,玩家2成功进入游戏房间,但此时玩家1已退出游戏房间
因此,我们需要对对应逻辑进行修改:

在将玩家2加入游戏房间时,我们并未对玩家1的在线状态进行判断,此时,就导致了玩家1已经离开游戏房间,但玩家2仍进入游戏房间,且开始了游戏
修改后端对应逻辑:

修改删除逻辑:

添加错误码:
java
ErrorCode GET_RIVAL_ERROR = new ErrorCode(302, "获取对手信息失败");


匹配成功后,玩家1和玩家2均未加入游戏房间:
若匹配成功后,玩家1 和 玩家2 均为加入游戏房间,此时,就会存在空房间:
在两个玩家匹配成功时,我们为其创建了游戏房间:

若两个玩家都不加入当前游戏房间,此时空的游戏房间就会一直存在,但这些空房间并不会再次被使用,因此,我们需要对这些空房间进行处理
我们使用定时器定期对这些空房间进行处理:
启用定时任务:

为了保证不误删刚创建的新房间 ,我们还需要对游戏房间的创建时间进行记录,若一个空房间存活时间超过 1h,则表明当前空房间已不会再使用
我们可以将创建时间添加至房间 ID 中:

在 RoomManager中添加定时任务:

由于匹配成功后,双方玩家均未进入房间这种异常情况出现概率较低,因此,我们仅需要一天执行一次定时任务即可
自动化测试
使用 selenium对五子棋的 注册、登录和匹配功能进行自动化测试,由于对战模块需要模拟真人对战,且落子下标不好定位,因此,就不进行自动化测试了
引入依赖
XML
<!-- selenium -->
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>4.0.0</version>
</dependency>
<!-- 驱动管理-->
<dependency>
<groupId>io.github.bonigarcia</groupId>
<artifactId>webdrivermanager</artifactId>
<version>5.5.3</version>
</dependency>
<!-- 屏幕截图-->
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.6</version>
</dependency>
Utils
接着,我们创建 Utils 类,用于存放自动化代码中的通用方法
java
public class Utils {
public static WebDriver webDriver;
/**
* 创建 webDriver 并 访问指定 url
* @param url
*/
public Utils(String url) {
if (null == webDriver) {
WebDriverManager.edgedriver().setup();
EdgeOptions options = new EdgeOptions();
// 允许访问所有连接
options.addArguments("--remote-allow-origins=*");
this.webDriver = new EdgeDriver(options);
// 隐式等待 3 秒
this.webDriver.manage().timeouts().implicitlyWait(java.time.Duration.ofSeconds(3));
}
webDriver.get(url);
}
/**
* 关闭 WebDriver 和浏览器
*/
public void closeBrowser() {
if (webDriver != null) {
webDriver.quit();
}
}
/**
* 进行屏幕截图并将其保存到自定路径
* 路径: ./src/tests/image/2025-2-24/LoginPage-17548130.png
* @param str
*/
public void getScreenShot(String str) {
try {
DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("HHmmssSS");
String dirTime = dateFormatter.format(LocalDateTime.now());
String fileTime = timeFormatter.format(LocalDateTime.now());
// 使用 File.separator 来适应不同操作系统上的路径分隔符
String filename = "src" + File.separator + "test" + File.separator + "image" + File.separator + dirTime + File.separator + str + "-" + fileTime + ".png";
// 创建目录
Path path = Paths.get(filename).getParent();
if (path != null && !Files.exists(path)) {
Files.createDirectories(path);
}
// 将截图存放到指定位置
File srcFile = ((TakesScreenshot)webDriver).getScreenshotAs(OutputType.FILE);
File destFile = new File(filename);
FileUtils.copyFile(srcFile, destFile);
} catch (IOException e) {
// 更好的错误处理或日志记录
System.err.println("截图失败: " + e.getMessage());
e.printStackTrace();
}
}
}
注册测试
对注册功能进行测试:
java
public class RegisterPage extends Utils {
private static String url = "http://49.108.48.236:8081/register.html";
public RegisterPage() {
super(url);
}
/**
* 检查页面是否加载成功
*/
public void registerPageRight() {
// 查看页面元素是否存在
webDriver.findElement(By.cssSelector("body > div.register-container > div > div:nth-child(3) > span")); // 密码提示信息
webDriver.findElement(By.cssSelector("body > div.nav")); // 导航栏
webDriver.findElement(By.cssSelector("body > div.register-container > div > h3")); // 注册标题
}
/**
* 注册成功测试
*/
public void registerSuc() {
// 清除输入框内文本
webDriver.findElement(By.cssSelector("#name")).clear();
webDriver.findElement(By.cssSelector("#password")).clear();
// 输入用户名和密码
webDriver.findElement(By.cssSelector("#name")).sendKeys("zhaoliu");
webDriver.findElement(By.cssSelector("#password")).sendKeys("123456");
// 点击登录按钮
webDriver.findElement(By.cssSelector("#submit")).click();
// 通过页面标题检查是否登录成功
String expect = webDriver.getTitle();
assert expect.equals("登录");
// 退回到注册页面
webDriver.navigate().back();
}
/**
* 注册失败 ------ 账号错误 测试
* 1. 账号为空
* 2. 用户名已存在
*/
public void registerNameFail() {
// 1. 未输入账号
// 清除输入框内文本
webDriver.findElement(By.cssSelector("#name")).clear();
webDriver.findElement(By.cssSelector("#password")).clear();
webDriver.findElement(By.cssSelector("#password")).sendKeys("123456");
webDriver.findElement(By.cssSelector("#submit")).click();
// 处理弹窗
WebDriverWait wait = new WebDriverWait(webDriver, Duration.ofSeconds(5));
wait.until(ExpectedConditions.alertIsPresent());// 等待弹窗出现
Alert alert = webDriver.switchTo().alert();
alert.accept();
// 2. 账号已存在
// 清除输入框内文本
webDriver.findElement(By.cssSelector("#name")).clear();
webDriver.findElement(By.cssSelector("#password")).clear();
webDriver.findElement(By.cssSelector("#name")).sendKeys("zhangsan");
webDriver.findElement(By.cssSelector("#password")).sendKeys("123456");
webDriver.findElement(By.cssSelector("#submit")).click();
// 处理弹窗
wait = new WebDriverWait(webDriver, Duration.ofSeconds(5));
wait.until(ExpectedConditions.alertIsPresent());// 等待弹窗出现
alert = webDriver.switchTo().alert();
alert.accept();
}
/**
* 注册失败 ------ 密码错误 测试
* 1. 密码为空
* 2. 密码过长或过短
* 3. 密码中包含特殊字符
*/
public void registerPasswordFail() {
// 1. 未输入密码
// 清除输入框内文本
webDriver.findElement(By.cssSelector("#name")).clear();
webDriver.findElement(By.cssSelector("#password")).clear();
webDriver.findElement(By.cssSelector("#name")).sendKeys("admin");
webDriver.findElement(By.cssSelector("#submit")).click();
// 处理弹窗
WebDriverWait wait = new WebDriverWait(webDriver, Duration.ofSeconds(5));
wait.until(ExpectedConditions.alertIsPresent());// 等待弹窗出现
Alert alert = webDriver.switchTo().alert();
alert.accept();
// 2. 密码过长
// 清除输入框内文本
webDriver.findElement(By.cssSelector("#name")).clear();
webDriver.findElement(By.cssSelector("#password")).clear();
webDriver.findElement(By.cssSelector("#name")).sendKeys("admin");
webDriver.findElement(By.cssSelector("#password")).sendKeys("1234591111111");
webDriver.findElement(By.cssSelector("#submit")).click();
// 处理弹窗
wait = new WebDriverWait(webDriver, Duration.ofSeconds(5));
wait.until(ExpectedConditions.alertIsPresent());// 等待弹窗出现
alert = webDriver.switchTo().alert();
alert.accept();
// 3. 密码过短
// 清除输入框内文本
webDriver.findElement(By.cssSelector("#name")).clear();
webDriver.findElement(By.cssSelector("#password")).clear();
webDriver.findElement(By.cssSelector("#name")).sendKeys("admin");
webDriver.findElement(By.cssSelector("#password")).sendKeys("12345");
webDriver.findElement(By.cssSelector("#submit")).click();
// 处理弹窗
wait = new WebDriverWait(webDriver, Duration.ofSeconds(5));
wait.until(ExpectedConditions.alertIsPresent());// 等待弹窗出现
alert = webDriver.switchTo().alert();
alert.accept();
// 4. 密码中包含特殊字符
// 清除输入框内文本
webDriver.findElement(By.cssSelector("#name")).clear();
webDriver.findElement(By.cssSelector("#password")).clear();
webDriver.findElement(By.cssSelector("#name")).sendKeys("admin");
webDriver.findElement(By.cssSelector("#password")).sendKeys("12345@");
webDriver.findElement(By.cssSelector("#submit")).click();
// 处理弹窗
wait = new WebDriverWait(webDriver, Duration.ofSeconds(5));
wait.until(ExpectedConditions.alertIsPresent());// 等待弹窗出现
alert = webDriver.switchTo().alert();
alert.accept();
}
}
登录测试
java
public class LoginPage extends Utils {
private static String url = "http://49.108.48.236:8081/login.html";
public LoginPage() {
super(url);
}
/**
* 检查页面是否加载成功
*/
public void loginPageRight() {
// 查看页面元素是否存在
webDriver.findElement(By.cssSelector("body > div.login-container > div > div:nth-child(2) > span")); // 用户名提示信息
webDriver.findElement(By.cssSelector("body > div.nav")); // 导航栏
webDriver.findElement(By.cssSelector("body > div.login-container > div > div.register > a")); // 注册链接
}
/**
* 登录成功测试
*/
public void loginSuc() {
// 清除输入框内文本
webDriver.findElement(By.cssSelector("#name")).clear();
webDriver.findElement(By.cssSelector("#password")).clear();
// 输入用户名和密码
webDriver.findElement(By.cssSelector("#name")).sendKeys("zhangsan");
webDriver.findElement(By.cssSelector("#password")).sendKeys("123456");
// 点击登录按钮
webDriver.findElement(By.cssSelector("#submit")).click();
// 通过页面标题检查是否登录成功
String expect = webDriver.getTitle();
assert expect.equals("游戏大厅");
getScreenShot(getClass().getName());
// 退回到登录页面
webDriver.navigate().back();
}
/**
* 登录失败 ------ 账号错误 测试
* 1. 账号为空
* 2. 用户名错误
*/
public void loginNameFail() {
// 1. 未输入账号
// 清除输入框内文本
webDriver.findElement(By.cssSelector("#name")).clear();
webDriver.findElement(By.cssSelector("#password")).clear();
webDriver.findElement(By.cssSelector("#password")).sendKeys("123456");
webDriver.findElement(By.cssSelector("#submit")).click();
// 处理弹窗
WebDriverWait wait = new WebDriverWait(webDriver, Duration.ofSeconds(5));
wait.until(ExpectedConditions.alertIsPresent());// 等待弹窗出现
Alert alert = webDriver.switchTo().alert();
alert.accept();
// 2. 账号错误
// 清除输入框内文本
webDriver.findElement(By.cssSelector("#name")).clear();
webDriver.findElement(By.cssSelector("#password")).clear();
webDriver.findElement(By.cssSelector("#name")).sendKeys("admin");
webDriver.findElement(By.cssSelector("#password")).sendKeys("123456");
webDriver.findElement(By.cssSelector("#submit")).click();
// 处理弹窗
wait = new WebDriverWait(webDriver, Duration.ofSeconds(5));
wait.until(ExpectedConditions.alertIsPresent());// 等待弹窗出现
alert = webDriver.switchTo().alert();
alert.accept();
}
/**
* 登录失败 ------ 密码错误 测试
* 1. 密码为空
* 2. 用户名正确,密码错误
*/
public void loginPasswordFail() {
// 1. 未输入密码
// 清除输入框内文本
webDriver.findElement(By.cssSelector("#name")).clear();
webDriver.findElement(By.cssSelector("#password")).clear();
webDriver.findElement(By.cssSelector("#name")).sendKeys("zhangsan");
webDriver.findElement(By.cssSelector("#submit")).click();
// 处理弹窗
WebDriverWait wait = new WebDriverWait(webDriver, Duration.ofSeconds(5));
wait.until(ExpectedConditions.alertIsPresent());// 等待弹窗出现
Alert alert = webDriver.switchTo().alert();
alert.accept();
// 2. 密码错误
// 清除输入框内文本
webDriver.findElement(By.cssSelector("#name")).clear();
webDriver.findElement(By.cssSelector("#password")).clear();
webDriver.findElement(By.cssSelector("#name")).sendKeys("zhangsan");
webDriver.findElement(By.cssSelector("#password")).sendKeys("123459");
webDriver.findElement(By.cssSelector("#submit")).click();
// 处理弹窗
wait = new WebDriverWait(webDriver, Duration.ofSeconds(5));
wait.until(ExpectedConditions.alertIsPresent());// 等待弹窗出现
alert = webDriver.switchTo().alert();
alert.accept();
}
}
匹配测试
java
public class MatchPage extends Utils {
private static String url = "http://49.108.48.236:8081/game_hall.html";
public MatchPage() {
super(url);
}
/**
* 未登录状态下进入游戏大厅
*/
public void noLoginToMatch() {
getScreenShot(getClass().getName());
// 通过页面标题检查是否跳转到登录页面
String expect = webDriver.getTitle();
assert expect.equals("登录");
}
public void match() {
// 清除输入框内文本
webDriver.findElement(By.cssSelector("#name")).clear();
webDriver.findElement(By.cssSelector("#password")).clear();
// 1. 进行登录
webDriver.findElement(By.cssSelector("#name")).sendKeys("zhangsan");
webDriver.findElement(By.cssSelector("#password")).sendKeys("123456");
webDriver.findElement(By.cssSelector("#submit")).click();
// 2. 等待页面加载
WebDriverWait wait = new WebDriverWait(webDriver, Duration.ofSeconds(5));
wait.until(ExpectedConditions.elementToBeClickable(By.cssSelector("#match-button")));
// 3. 开始匹配
webDriver.findElement(By.cssSelector("#match-button")).click();
wait.until(ExpectedConditions.textToBe(By.cssSelector("#match-button"), "匹配中...(点击停止)"));
WebElement element = webDriver.findElement(By.cssSelector("#match-button"));
String content = element.getText();
assert content.equals("匹配中...(点击停止)");
// 4. 停止匹配
webDriver.findElement(By.cssSelector("#match-button")).click();
wait.until(ExpectedConditions.textToBe(By.cssSelector("#match-button"), "开始匹配"));
element = webDriver.findElement(By.cssSelector("#match-button"));
content = element.getText();
assert content.equals("开始匹配");
}
}
RunTest
运行上述接口:
java
public class RunTest {
public static void main(String[] args) {
// 未登录状态下访问游戏大厅页面
MatchPage matchPage = new MatchPage();
matchPage.noLoginToMatch();
// 注册测试
RegisterPage registerPage = new RegisterPage();
registerPage.registerPageRight();
registerPage.registerNameFail();
registerPage.registerPasswordFail();
registerPage.registerSuc();
// 登录测试
LoginPage loginPage = new LoginPage();
loginPage.loginPageRight();
loginPage.loginNameFail();
loginPage.loginPasswordFail();
loginPage.loginSuc();
// 进行匹配测试
matchPage.match();
matchPage.closeBrowser();
}
}
测试通过:

界面测试

注册登录页面正确显示:

游戏大厅页面正确显示:

对战页面:

可以看到,其中棋盘下方和右侧边缘并不能包含最后一个格子,因此,我们对棋盘进行修改:

此时就能保证正确棋盘的边框被绘制出来:

绘制棋子:

游戏结束:

返回大厅,更新对应信息:

性能测试

使用 JMeter对五子棋的登录接口进行性能测试:
创建梯度压测线程组(Stepping Thread Group),慢慢增大我们对接口的并发请求量:

创建 csv 文件,存放用户名和密码:

导入 csv 文件:

设置请求头:

添加请求:

查看结果:
聚合报告:

测试过程中并未发生异常情况,且 99% 的请求响应时间在 220ms 及以内
最大响应时间达到了1156 ms,这表明在某些时刻系统处理请求的速度显著下降
每秒处理事务数:

事务数在测试开始时逐渐增加,并在一段时间内保持相对稳定
响应时间:

响应时间在测试过程中有较大的波动,尤其是在某些时间段内出现了较高的峰值。这可能表明系统在高负载下存在性能瓶颈
总结
功能测试:
-
五子棋游戏的基本功能正常运行,正常流程能够正确执行
-
异常情况处理具有缺陷,对异常注册、异常登录 以及 异常进入游戏房间缺陷进行了修改
界面测试:
-
所有按钮点击响应及时,页面显示良好,无遮挡或显示错误情况
-
棋盘边框显示不完整,对棋盘进行了修改
性能测试:
-
响应时间在测试过程中有较大的波动,尤其是在某些时间段内出现了较高的峰值
-
可调整测试配置,增加线程数或调整循环次数,以测试更高并发用户数下性能
后续改进:
-
针对响应时间的波动,进一步分析系统日志和监控数据 ,找出导致性能瓶颈的具体原因
-
增加禁手规则更好地平衡游戏,减少先手玩家的优势
-
限制玩家思考时间,从而增加游戏的挑战性和紧张感