一、框架设计思路与核心目标
1. 设计需求
需要一套适配双人对战五子棋全流程功能(登录、注册、匹配玩家、落子、胜负判定)的自动化测试框架
2.设计目标
| 目标 | 设计方案(例) |
|---|---|
| 可重用性 | 页面操作基类------>封装所有页面通用操作(点击、输入、等待等),页面类继承后直接复用 |
| 可维护性 | 页面操作基类------>定义了控件管理机制,让每个页面子类必须定义所属于自己页面的控件 |
| 灵活性 | 定位器类型采用枚举,方便扩展 |
| 易用性 | controls():对外提供直观的控件访问方式. |
| 全流程覆盖 | 覆盖五子棋核心场景:登录→注册→匹配→落子→胜负判定→返回匹配页 |
3. 框架分层设计
调用关系:自上而下触发,自下而上提供能力
java
测试用例层(Test)→ 页面对象层(Page)→ 控件模型层(Model)→ 基础支持层(Base)→ 工具层(Util)

- 测试用例层:编写业务流程测试(如 "正确账号登录""落子获胜")
- 页面对象层:每个页面一个类,集中管理控件定位器和业务方法
- 控件模型层:封装单个元素的操作(点击、输入等),解耦元素操作与页面逻辑
- 基础支持层:界面操作基类,提供通用操作和控件管理机制
- 工具层:辅助工具(等待、断言、驱动管理),支撑框架核心功能

二、核心模块设计与代码详解
模块 1:基础支持层(Base)
框架基石
1.1 定位器类型枚举类(LocatorType.java)
目的 :统一管理定位器类型(避免硬编码字符串导致的错误,支持扩展)
java
package com.gomoku.base.enums;
/**
* 定位器类型枚举(对应Selenium的By类型,支持扩展)
* 核心作用:规范定位器类型,避免拼写错误(如"xpath"写成"XPath")
*/
public enum LocatorType {
ID, // By.id()
XPATH, // By.xpath()
CSS_SELECTOR,// By.cssSelector()
LINK_TEXT, // By.linkText()
CLASS_NAME // By.className()
}
使用场景:
1.2 驱动管理类(DriverManager.java)
目的 :采用单例模式,确保全局只有一个 WebDriver 实例(避免资源浪费;自动管理驱动版本(无需手动下载 chromedriver)
java
package com.gomoku.base;
import io.github.bonigarcia.wdm.WebDriverManager;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
/**
* 驱动管理类(单例模式)
* 核心职责:1. 提供全局唯一的WebDriver实例 2. 自动配置驱动 3. 统一打开/关闭页面
*/
public class DriverManager {
// 单例实例(volatile保证线程安全)
private static volatile WebDriver driver;
// 项目基础URL(可配置在properties文件中,此处简化)
public static final String BASE_URL = "http://127.0.0.1:8080/gomoku"; // 替换为实际项目URL
// 私有构造方法:禁止外部实例化
private DriverManager() {}
/**
* 获取驱动实例(懒加载:用到时才创建)
*/
public static WebDriver getDriver() {
if (driver == null) {
synchronized (DriverManager.class) { // 双重检查锁,避免多线程冲突
if (driver == null) {
// WebDriverManager自动下载并配置对应版本的chromedriver
WebDriverManager.chromedriver().setup();
// 配置Chrome浏览器选项(最大化、无头模式等)
ChromeOptions options = new ChromeOptions();
options.addArguments("--start-maximized"); // 最大化窗口
// options.addArguments("--headless"); // 无头模式(CI/CD时使用)
driver = new ChromeDriver(options);
}
}
}
return driver;
}
/**
* 打开项目基础URL
*/
public static void openBaseUrl() {
getDriver().get(BASE_URL);
}
/**
* 关闭驱动(释放资源)
*/
public static void quitDriver() {
if (driver != null) {
driver.quit();
driver = null; // 重置实例,下次使用重新创建
}
}
}
1.2.1 什么是单例模式?
单例模式是 创建型设计模式 的一种
核心目标: 确保一个类**(驱动管理类)在整个应用程序中 仅有一个实例对象(驱动实例)**,并且提供一个 全局统一的访问点**(静态的getDriver()方法)**来获取这个实例。
1.2.2 你是怎么去保证单例模式?
1.私有构造方法
禁止外部()实例化
javaprivate DriverManager() {}2.静态私有实例
确保实例唯一
javaprivate static volatile WebDriver driver;3.双重检查锁
高效且线程安全的懒加载(用到时才创建实例),解决了 "线程安全" 和 "效率" 的平衡:
javapublic static WebDriver getDriver() { if (driver == null) { // 第一次检查:避免每次加锁(提高效率) synchronized (DriverManager.class) { // 类锁:同一时间只有一个线程进入 if (driver == null) { // 第二次检查:避免多线程等待锁时重复创建 // 初始化实例... driver = new ChromeDriver(options); } } } return driver; }双重检查/类锁:
1.3 页面操作基类(BaseActivity.java)
框架核心
目的 :封装所有页面的通用操作 (点击、输入、滑动等),定义控件管理机制,让页面类继承后直接复用,无需重复编码。
java
package com.gomoku.base;
import com.gomoku.base.enums.LocatorType;
import com.gomoku.model.Control;
import com.gomoku.util.WaitUtil;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.JavascriptExecutor;
import java.util.HashMap;
import java.util.Map;
/**
* 页面操作基类(对应参考Python的SRActivity)
* 核心能力:1. 通用操作封装 2. 控件集中管理 3. 页面加载等待 4. 直观的控件调用
*/
public abstract class BaseActivity {
// 页面标识(Web项目用URL后缀,APP项目用ActivityName)
protected String Activity;
// 驱动实例(从DriverManager获取)
protected WebDriver driver;
// 控件定位器字典:key=控件名(如"登录按钮"),value=定位器信息(type+value)
protected Map<String, Map<String, String>> locators = new HashMap<>();
// 控件缓存:避免重复创建Control实例,提升性能
private Map<String, Control> controlCache = new HashMap<>();
/**
* 构造方法:初始化驱动+等待页面加载+初始化控件
*/
public BaseActivity() {
this.driver = DriverManager.getDriver(); // 获取全局驱动
this.initPage(); // 等待页面加载完成
this.initLocators(); // 初始化当前页面控件(子类必须实现)
}
/**
* 初始化页面:等待Activity对应的页面加载(Web项目检测URL)
*/
private void initPage() {
// 校验页面标识是否定义
if (Activity == null || Activity.isEmpty()) {
throw new RuntimeException("页面Activity未定义!请在子类中设置Activity属性");
}
// 等待页面URL包含Activity(超时30秒,轮询1秒)
boolean isPageLoaded = WaitUtil.waitForCondition(
() -> driver.getCurrentUrl().contains(Activity), // 条件:URL包含页面标识
30, // 超时时间(秒)
1 // 轮询间隔(秒)
);
// 页面加载失败抛出异常
if (!isPageLoaded) {
throw new RuntimeException(String.format(
"进入页面【%s】失败!当前URL:%s,预期包含:%s",
this.getClass().getSimpleName(), driver.getCurrentUrl(), Activity
));
}
}
/**
* 初始化控件定位器(抽象方法:子类必须实现,集中定义当前页面的控件)
*/
protected abstract void initLocators();
/**
* 直观获取控件:类似Python的controls["控件名"]
* 用法示例:controls().get("登录按钮").click();
*/
public Map<String, Control> controls() {
// 懒加载控件:第一次获取时创建Control实例,之后从缓存获取
locators.forEach((controlName, locatorInfo) -> {
if (!controlCache.containsKey(controlName)) {
Control control = createControl(locatorInfo); // 创建控件实例
controlCache.put(controlName, control); // 存入缓存
}
});
return controlCache;
}
/**
* 根据定位器信息创建Control实例(将定位器转换为Selenium的By对象)
*/
private Control createControl(Map<String, String> locatorInfo) {
String typeStr = locatorInfo.get("type"); // 定位器类型(如"XPATH")
String value = locatorInfo.get("value"); // 定位器值(如"//button[text()='登录']")
// 转换为LocatorType枚举(避免大小写错误)
LocatorType locatorType = LocatorType.valueOf(typeStr.toUpperCase());
// 转换为Selenium原生By定位器
By by = convertToBy(locatorType, value);
// 创建Control实例(封装元素操作)
return new Control(driver, by);
}
/**
* 转换定位器类型为Selenium的By对象
*/
private By convertToBy(LocatorType type, String value) {
switch (type) {
case ID: return By.id(value);
case XPATH: return By.xpath(value);
case CSS_SELECTOR: return By.cssSelector(value);
case LINK_TEXT: return By.linkText(value);
case CLASS_NAME: return By.className(value);
default: throw new RuntimeException("不支持的定位器类型:" + type);
}
}
/**
* 更新控件定位器(支持动态添加/修改控件)
* 对应Python的update_locator方法
*/
protected void updateLocators(Map<String, Map<String, String>> newLocators) {
locators.putAll(newLocators); // 合并新控件
controlCache.clear(); // 清空缓存,重新创建控件实例
}
/**
* 检查控件是否存在(对应Python的has_control_key)
*/
public boolean hasControl(String controlName) {
return locators.containsKey(controlName);
}
/**
* 等待控件消失(如loading动画、弹窗)
*/
public void waitForControlDisappear(String controlName, int timeout) {
if (!hasControl(controlName)) {
throw new RuntimeException("控件【" + controlName + "】未在当前页面定义");
}
// 等待控件不存在或不可见
WaitUtil.waitForCondition(
() -> !controls().get(controlName).exists(),
timeout,
0.5
);
}
/**
* 屏幕滑动(从下往上滚动半屏,支持Web/APP)
*/
public void scrollOnScreen() {
int width = driver.manage().window().getSize().getWidth();
int height = driver.manage().window().getSize().getHeight();
// Web端:使用JS滑动;APP端可改为TouchAction
JavascriptExecutor js = (JavascriptExecutor) driver;
js.executeScript(String.format("window.scrollTo(0, %d)", height / 2));
}
/**
* 关闭当前页面(返回上一页)
*/
public void close() {
driver.navigate().back();
// 等待页面跳转完成(当前URL不再包含Activity)
WaitUtil.waitForCondition(
() -> !driver.getCurrentUrl().contains(Activity),
3,
0.5
);
}
/**
* 检查页面是否包含指定文本(对应Python的check_ui)
*/
public boolean checkUI(String text) {
// 动态添加临时控件(定位包含指定文本的元素)
String xpath = String.format("//*[contains(text(), '%s')]", text);
updateLocators(Map.of(
"临时文本控件", Map.of(
"type", "XPATH",
"value", xpath
)
));
// 返回临时控件是否可见
return controls().get("临时文本控件").isDisplayed();
}
}
1.3.1 属性
java
// 页面标识(Web项目用URL后缀,APP项目用ActivityName)
protected String Activity;
// 驱动实例(从DriverManager获取)
protected WebDriver driver;
// 控件定位器字典:key=控件名(如"登录按钮"),value=定位器信息(type+value)
protected Map<String, Map<String, String>> locators = new HashMap<>();
// 控件缓存:避免重复创建Control实例,提升性能
private Map<String, Control> controlCache = new HashMap<>();
1.页面标识:
2.浏览器驱动:
3.控件定位器字典:嵌套Map<控件名,Map<控件类型,定位值>>
4.控件实例缓存:Map<控件名,控件>
示例:
java// 子类中 initLocators() 的实现(直观理解 locators 如何存储控件) @Override protected void initLocators() { // 添加「用户名输入框」控件 locators.put("用户名输入框", Map.of( "type", "ID", // 定位类型:ID "value", "username" // 定位值:元素的 id 属性 )); // 添加「登录按钮」控件 locators.put("登录按钮", Map.of( "type", "XPATH", "value", "//button[contains(text(), '登录')]" )); }
1.3.1.1 问答
1.为什么用
Map存储控件定位器,而不是自定义实体类?答:灵活、简洁。无需定义
Locator类(含type和value字段),子类添加控件时直接 用Map.of()语法,代码更简洁2.为什么需要
controlCache缓存?答:避免重复创建
Control实例保证控件实例一致性:同一控件多次获取时,返回同一个实例,避免状态不一致。
3.为什么
Activity设计为protected而非构造方法参数?答:强化「页面 - 标识」的强绑定
让子类去定义所属于自己页面的标识(和控件一样都为核心配置),配合模板规范
1.3.2 构造方法 BaseActivity
构造方法是「对象创建时自动执行的方法」
定义了 页面初始化的固定流程 (模板方法思想),确保每个页面实例化时都完成「驱动绑定→页面加载→控件初始化」三步,避免子类遗漏关键步骤
java
public BaseActivity() {
this.driver = DriverManager.getDriver(); // 获取全局驱动
this.initPage(); // 等待页面加载完成
this.initLocators(); // 初始化当前页面控件(子类必须实现)
}
1.3.3 页面加载等待/校验 initPage()
java
private void initPage() {
// 校验页面标识是否定义
if (Activity == null || Activity.isEmpty()) {
throw new RuntimeException("页面Activity未定义!请在子类中设置Activity属性");
}
// 等待URL包含Activity(超时30秒,轮询1秒)
boolean isPageLoaded = WaitUtil.waitForCondition(
() -> driver.getCurrentUrl().contains(Activity), // 核心条件
30, 1
);
// 加载失败抛异常
if (!isPageLoaded) {
throw new RuntimeException(String.format(
"进入页面【%s】失败!当前URL:%s,预期包含:%s",
this.getClass().getSimpleName(), driver.getCurrentUrl(), Activity
));
}
}
私有方法(仅父类内部调用),负责**「确认页面是否成功加载」**
步骤:
1.检验activity是否定义
2.使用显示等待轮询判断URL是否包含activiry
3.加载失败抛出异常
细节:
1.3.4 控件定位器初始化 initLocators()
java
protected abstract void initLocators();
作用 :强制子类重写这个方法,集中定义所属于自己页面的控件定位器;
示例:
java
public class LoginPage extends BaseActivity {
// 子类构造方法(可选,需调用super())
public LoginPage() {
super(); // 必须调用父类构造方法,触发初始化
this.Activity = "/login"; // 设置当前页面的URL后缀
}
@Override
protected void initLocators() {
// 往locators中添加当前页面的控件
locators.put("用户名输入框", Map.of("type", "ID", "value", "username"));
locators.put("登录按钮", Map.of("type", "XPATH", "value", "//button[text()='登录']"));
}
}
1.3.5 控件操作方法
(1)获取控件 controls()
java
public Map<String, Control> controls() {
// 懒加载:遍历locators,未缓存的控件创建实例并缓存
locators.forEach((controlName, locatorInfo) -> {
if (!controlCache.containsKey(controlName)) {
Control control = createControl(locatorInfo); // 创建控件
controlCache.put(controlName, control); // 存入缓存
}
});
return controlCache;
}
作用 :对外提供直观的控件访问方式(类似 Python 的 controls["控件名"]);
细节:
java// 登录操作(子类中调用控件) public void login(String username, String password) { // 语义化调用:获取控件→执行操作 controls().get("用户名输入框").sendKeys(username); controls().get("密码输入框").sendKeys(password); controls().get("登录按钮").click(); }
(2)创建控件实例 createControl()
java
private Control createControl(Map<String, String> locatorInfo) {
String typeStr = locatorInfo.get("type"); // 定位器类型(如"XPATH")
String value = locatorInfo.get("value"); // 定位器值(如"//button[text()='登录']")
// 转换为LocatorType枚举(避免大小写错误)
LocatorType locatorType = LocatorType.valueOf(typeStr.toUpperCase());
// 转换为Selenium原生By定位器
By by = convertToBy(locatorType, value);
// 创建Control实例(封装元素操作)
return new Control(driver, by);
}
作用 :将 locators 中的定位器信息(type+value)转换为可操作的 Control 实例
步骤:
(3)定位器类型转换 convertToBy()
java
private By convertToBy(LocatorType type, String value) {
switch (type) {
case ID: return By.id(value);
case XPATH: return By.xpath(value);
case CSS_SELECTOR: return By.cssSelector(value);
case LINK_TEXT: return By.linkText(value);
case CLASS_NAME: return By.className(value);
default: throw new RuntimeException("不支持的定位器类型:" + type);
}
分支选择结构:用法:枚举类型
作用: 将自定义的 LocatorType 枚举转换为 Selenium 原生的 By 对象(Selenium 仅识别 By 对象,不识别自定义枚举)
细节:
(4)动态更新控件 updateLocators()
java
protected void updateLocators(Map<String, Map<String, String>> newLocators) {
locators.putAll(newLocators); // 合并新控件(覆盖同名控件)
controlCache.clear(); // 清空缓存,下次获取时重新创建
}
讲解:
java// 动态添加「第2页按钮」控件 Map<String, Map<String, String>> newControls = new HashMap<>(); newControls.put("第2页按钮", Map.of("type", "XPATH", "value", "//a[text()='2']")); updateLocators(newControls);
1.3.6 通用页面操作
这些方法封装了「所有页面都可能用到的通用操作」,避免子类重复编码
(1)检查页面控件 hasControl()
java
public boolean hasControl(String controlName) {
return locators.containsKey(controlName);
}
javaif (loginPage.hasControl("忘记密码按钮")) { loginPage.controls().get("忘记密码按钮").click(); }
(2)等待控件消失 waitForControlDisappear()
java
public void waitForControlDisappear(String controlName, int timeout) {
if (!hasControl(controlName)) {
throw new RuntimeException("控件【" + controlName + "】未在当前页面定义");
}
WaitUtil.waitForCondition(
() -> !controls().get(controlName).exists(), // 条件:控件不存在或不可见
timeout, 0.5
);
}
步骤:
java// 点击登录后,等待loading动画消失(超时10秒) controls().get("登录按钮").click(); waitForControlDisappear("加载动画", 10);
(3)屏幕滑动 scrollOnScreen()
java
public void scrollOnScreen() {
int width = driver.manage().window().getSize().getWidth();
int height = driver.manage().window().getSize().getHeight();
// Web端用JS滑动;APP端可扩展为TouchAction
JavascriptExecutor js = (JavascriptExecutor) driver;
js.executeScript(String.format("window.scrollTo(0, %d)", height / 2));
}
作用:从下往上滚动半屏(Web 场景)
3.1 JavaScriptExecutor(JS):
是selenium提供的【执行JavaScript脚本】的接口
1.获取浏览器当前页面的宽和高
2.将驱动实例转化为JS类型
3.使用JS提供的方法
(4)关闭当前页面 close()
java
public void close() {
driver.navigate().back(); // 返回上一页
// 等待页面跳转完成(当前URL不再包含Activity)
WaitUtil.waitForCondition(
() -> !driver.getCurrentUrl().contains(Activity),
3, 0.5
);
}
作用:关闭当前页面(返回上一页)
细节: 
(5)检查页面文本 checkUI()
java
public boolean checkUI(String text) {
// 动态添加临时控件(定位包含指定文本的元素)
String xpath = String.format("//*[contains(text(), '%s')]", text);
updateLocators(Map.of(
"临时文本控件", Map.of(
"type", "XPATH",
"value", xpath
)
));
// 返回临时控件是否可见
return controls().get("临时文本控件").isDisplayed();
}
作用:
java// 登录成功后,校验页面是否包含"欢迎"文本 boolean isLoginSuccess = checkUI("欢迎"); Assert.assertTrue("登录失败,页面未显示欢迎文本", isLoginSuccess);
1.3.6.1 String.format() :
是 Java 中 字符串格式化工具方法
javaString 最终字符串 = String.format("字符串模板", 变量1, 变量2, ...);%s %d
模块 2:控件模型层(Model)
解耦元素操作
2.1 控件模型类(Control.java)
目的 :封装单个元素的操作(点击、输入、判断可见等),解耦元素操作与页面逻辑,让页面类专注于业务流程。
java
package com.gomoku.model;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.ui.WebDriverWait;
import java.time.Duration;
/**
* 控件模型类(封装单个元素的所有操作)
* 核心职责:屏蔽Selenium原生API,提供更简洁的元素操作方法
*/
public class Control {
private final WebDriver driver;
private final By locator; // Selenium原生定位器
private WebElement element; // 缓存找到的元素(避免重复查找)
/**
* 构造方法:传入驱动和定位器
*/
public Control(WebDriver driver, By locator) {
this.driver = driver;
this.locator = locator;
}
/**
* 查找元素(懒加载:使用时才查找,查找后缓存)
*/
private WebElement findElement() {
if (element != null && element.isDisplayed()) {
return element;
}
// 直接调用 WaitUtil 的专用方法
this.element = WaitUtil.waitForElement(driver, locator, 10, 0.5);
return element;
}
/**
* 点击操作
*/
public void click() {
findElement().click();
}
/**
* 输入文本(先清空再输入)
*/
public void sendKeys(String text) {
WebElement el = findElement();
el.clear(); // 清空输入框
el.sendKeys(text); // 输入文本
}
/**
* 获取元素文本
*/
public String getText() {
return findElement().getText();
}
/**
* 判断元素是否可见
*/
public boolean isDisplayed() {
try {
return findElement().isDisplayed();
} catch (Exception e) { // 元素未找到或不可见时返回false
return false;
}
}
/**
* 判断元素是否存在(不管是否可见)
*/
public boolean exists() {
try {
findElement(); // 尝试查找元素
return true;
} catch (Exception e) { // 元素未找到返回false
return false;
}
}
}
2.2.1 简洁方法
1.isDisplayed() 存在+可见
2.exists() 只存在,不管是否可见
3.clik()
4.clear() ------>sendKeys() ------>getText()
2.2 为什么要这样做?
selenium原生API太繁琐,每次操作元素都要写"查找元素+等待+操作"的重复逻辑
比如:
java// 每次点击"登录按钮"都要重复写查找、等待逻辑 WebDriver driver = DriverManager.getDriver(); // 查找元素(无等待,容易报错) WebElement loginBtn = driver.findElement(By.xpath("//button[text()='登录']")); // 点击操作 loginBtn.click(); // 下次操作"密码输入框",又要重复写 WebElement pwdInput = new WebDriverWait(driver, Duration.ofSeconds(10)) .until(d -> d.findElement(By.id("password-input"))); // 手动加等待 pwdInput.clear(); pwdInput.sendKeys("123456");优化后:
把 "查找元素 + 显式等待 + 缓存复用" 的逻辑封装到
findElement()私有方法中,后续所有操作(click()、sendKeys())都复用这个方法:
javaprivate WebElement findElement() { if (element != null && element.isDisplayed()) { return element; } // 直接调用 WaitUtil 的专用方法 this.element = WaitUtil.waitForElement(driver, locator, 10, 0.5); return element; }好处:
1. 解耦元素操作与页面业务
2.统一操作规范
3.简化代码,提升可读性和易用性
4.便于扩展和维护
模块 3:工具层(Util)
辅助支撑
3.1 等待工具类(WaitUtil.java)
目的 :封装条件等待逻辑,替代Thread.sleep()
java
package com.gomoku.util;
import java.util.function.Supplier;
/**
* 等待工具类(核心:条件等待)
* 优势:比Thread.sleep()更灵活,条件满足立即执行,不浪费时间
*/
public class WaitUtil {
/**
* 等待条件满足
* @param condition 等待条件(返回boolean,true表示条件满足)
* @param timeout 超时时间(秒)
* @param interval 轮询间隔(秒)
* @return 条件满足返回true,超时返回false
*/
public static boolean waitForCondition(Supplier<Boolean> condition, int timeout, double interval) {
long startTimestamp = System.currentTimeMillis(); // 开始时间戳
// 循环检查条件,直到超时
while (System.currentTimeMillis() - startTimestamp < timeout * 1000) {
if (condition.get()) { // 条件满足,返回true
return true;
}
try {
Thread.sleep((long) (interval * 1000)); // 等待轮询间隔
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 恢复线程中断状态
return false;
}
}
return false; // 超时返回false
}
// WaitUtil.java 中新增:查找元素的专用等待方法
public static WebElement waitForElement(WebDriver driver, By locator, int timeout, double interval) {
AtomicReference<WebElement> elementRef = new AtomicReference<>();
boolean isFound = waitForCondition(
() -> {
try {
WebElement el = driver.findElement(locator);
elementRef.set(el);
return true;
} catch (Exception e) {
return false;
}
},
timeout,
interval
);
if (!isFound) {
throw new RuntimeException(String.format(
"查找控件超时!超时时间:%d秒,定位器:%s",
timeout, locator.toString()
));
}
return elementRef.get();
}
}
使用场景:
等待元素加载完成或元素消失

3.2 断言工具类(AssertUtil.java)
目的:封装 TestNG 断言,增强断言的可读性和复用性,统一错误提示格式。
java
package com.gomoku.util;
import org.testng.Assert;
/**
* 断言工具类(封装常用断言,提升测试用例可读性)
*/
public class AssertUtil {
/**
* 断言元素文本相等
*/
public static void assertTextEquals(String actual, String expected, String message) {
Assert.assertEquals(actual, expected, String.format("【%s】失败:实际值=%s,预期值=%s", message, actual, expected));
}
/**
* 断言元素可见
*/
public static void assertElementVisible(boolean isDisplayed, String controlName) {
Assert.assertTrue(isDisplayed, String.format("控件【%s】未显示", controlName));
}
/**
* 断言页面URL包含指定字符串
*/
public static void assertUrlContains(String actualUrl, String expectedPart, String message) {
Assert.assertTrue(actualUrl.contains(expectedPart), String.format("【%s】失败:URL=%s,未包含=%s", message, actualUrl, expectedPart));
}
/**
* 断言数值变化(如分数增减)
*/
public static void assertNumberIncreased(int before, int after, String message) {
Assert.assertTrue(after > before, String.format("【%s】失败:变化前=%d,变化后=%d,未增加", message, before, after));
}
}
3.3 字符串工具类(StringUtil.java)
目的 :封装字符串处理逻辑(如从文本中提取分数、场次等),避免测试用例中重复编写字符串处理代码。
java
package com.gomoku.util;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 字符串工具类(提取数字、验证格式等)
*/
public class StringUtil {
/**
* 从文本中提取数字(如从"分数:1200"提取1200)
*/
public static int extractNumber(String text) {
Pattern pattern = Pattern.compile("\\d+"); // 匹配数字
Matcher matcher = pattern.matcher(text);
if (matcher.find()) {
return Integer.parseInt(matcher.group());
}
throw new RuntimeException("文本【" + text + "】中未找到数字");
}
/**
* 验证用户名格式(字母+数字,长度3-10位)
*/
public static boolean isValidUsername(String username) {
Pattern pattern = Pattern.compile("^[a-zA-Z0-9]{3,10}$");
return pattern.matcher(username).matches();
}
}
Pattern和Matcher:
正则表达式规则模板 和 规则的执行者

模块 4:页面对象层(Page)
PO 模式核心
基于框架中 基础支持层、控件模型层、工具层 提供的简洁接口,标准化封装每个页面的业务方法

4.1 登录页面(LoginPage.java)
java
package com.gomoku.page;
import com.gomoku.base.BaseActivity;
import java.util.Map;
/**
* 登录页面PO类(封装登录页面的控件和业务逻辑)
* 核心:控件集中定义,业务方法链式调用
*/
public class LoginPage extends BaseActivity {
/**
* 页面标识(Web项目:URL后缀)
* 假设登录页面URL为 http://xxx/gomoku/login,则Activity设为"/login"
*/
@Override
protected void initPage() {
this.Activity = "/login"; // 页面标识必须在initPage前设置
super.initPage();
}
/**
* 初始化控件定位器(核心:集中管理当前页面所有控件)
* 格式:key=控件名(中文,直观),value={type:定位器类型, value:定位器值}
*/
@Override
protected void initLocators() {
Map<String, Map<String, String>> pageLocators = Map.of(
"用户名输入框", Map.of(
"type", "ID",
"value", "username-input" // 实际页面的id属性,需替换为真实值
),
"密码输入框", Map.of(
"type", "ID",
"value", "password-input"
),
"登录按钮", Map.of(
"type", "XPATH",
"value", "//button[contains(@class, 'login-btn') and text()='登录']"
),
"注册链接", Map.of(
"type", "LINK_TEXT",
"value", "还没有账号?注册"
),
"错误提示", Map.of(
"type", "CSS_SELECTOR",
"value", ".error-message"
),
"忘记密码链接", Map.of(
"type", "LINK_TEXT",
"value", "忘记密码?"
)
);
// 调用父类方法,将控件添加到locators字典
super.updateLocators(pageLocators);
}
// ------------------------------ 业务方法 ------------------------------
/**
* 输入用户名(链式调用:返回当前页面实例,支持连续调用)
*/
public LoginPage inputUsername(String username) {
controls().get("用户名输入框").sendKeys(username);
return this; // 链式调用关键:返回this
}
/**
* 输入密码(链式调用)
*/
public LoginPage inputPassword(String password) {
controls().get("密码输入框").sendKeys(password);
return this;
}
/**
* 点击登录按钮(登录成功后跳转到匹配页面,返回MatchingPage实例)
*/
public MatchingPage clickLoginButton() {
controls().get("登录按钮").click();
return new MatchingPage(); // 页面跳转:返回目标页面PO实例
}
/**
* 点击注册链接(跳转到注册页面,返回RegisterPage实例)
*/
public RegisterPage clickRegisterLink() {
controls().get("注册链接").click();
return new RegisterPage();
}
/**
* 获取错误提示文本
*/
public String getErrorMsg() {
return controls().get("错误提示").getText();
}
/**
* 判断登录按钮是否可见
*/
public boolean isLoginButtonDisplayed() {
return controls().get("登录按钮").isDisplayed();
}
}
步骤:
1.继承页面操作基类
BaseActivity,
2.重写
页面加载等待initPage():设置``Activity页面标识
控件定位器初始化initLocators():定义所属于自己页面的控件3.封装登录相关业务方法(输入用户名、密码,点击登录,获取等)。
4.2 注册页面(RegisterPage.java)。
java
package com.gomoku.page;
import com.gomoku.base.BaseActivity;
import java.util.Map;
/**
* 注册页面PO类
*/
public class RegisterPage extends BaseActivity {
// 页面标识(URL后缀:/register)
@Override
protected void initPage() {
this.Activity = "/register";
super.initPage();
}
// 初始化控件定位器
@Override
protected void initLocators() {
Map<String, Map<String, String>> pageLocators = Map.of(
"用户名输入框", Map.of("type", "ID", "value", "reg-username-input"),
"密码输入框", Map.of("type", "ID", "value", "reg-password-input"),
"确认密码输入框", Map.of("type", "ID", "value", "reg-confirm-pwd-input"),
"注册按钮", Map.of("type", "XPATH", "value", "//button[text()='注册']"),
"登录链接", Map.of("type", "LINK_TEXT", "value", "已有账号?立即登录"),
"注册成功提示", Map.of("type", "CSS_SELECTOR", "value", ".success-message"),
"用户名格式提示", Map.of("type", "CSS_SELECTOR", "value", ".username-tip")
);
super.updateLocators(pageLocators);
}
// ------------------------------ 业务方法 ------------------------------
public RegisterPage inputUsername(String username) {
controls().get("用户名输入框").sendKeys(username);
return this;
}
public RegisterPage inputPassword(String password) {
controls().get("密码输入框").sendKeys(password);
return this;
}
public RegisterPage inputConfirmPassword(String confirmPassword) {
controls().get("确认密码输入框").sendKeys(confirmPassword);
return this;
}
/**
* 点击注册按钮(注册成功后跳转到登录页面)
*/
public LoginPage clickRegisterButton() {
controls().get("注册按钮").click();
// 等待注册成功提示消失(模拟注册接口请求时间)
waitForControlDisappear("注册成功提示", 5);
return new LoginPage();
}
/**
* 获取注册成功提示文本
*/
public String getSuccessMsg() {
return controls().get("注册成功提示").getText();
}
}
4.3 匹配页面(MatchingPage.java)
java
package com.gomoku.page;
import com.gomoku.base.BaseActivity;
import com.gomoku.util.StringUtil;
import com.gomoku.util.WaitUtil;
import java.util.Map;
/**
* 匹配页面PO类(封装匹配相关业务:开始匹配、取消匹配、查看用户信息等)
*/
public class MatchingPage extends BaseActivity {
// 页面标识(URL后缀:/matching)
@Override
protected void initPage() {
this.Activity = "/matching";
super.initPage();
}
// 初始化控件定位器:将棋盘元素纳入统一管理(核心优化)
@Override
protected void initLocators() {
Map<String, Map<String, String>> pageLocators = Map.of(
"开始匹配按钮", Map.of("type", "XPATH", "value", "//button[text()='开始匹配']"),
"取消匹配按钮", Map.of("type", "XPATH", "value", "//button[text()='匹配中...(点击取消)']"),
"用户信息区域", Map.of("type", "CSS_SELECTOR", "value", ".user-info"), // 格式:玩家:test123, 分数:1200
"比赛统计区域", Map.of("type", "CSS_SELECTOR", "value", ".match-stats"), // 格式:比赛场次:10, 获胜:6
"匹配超时提示", Map.of("type", "CSS_SELECTOR", "value", ".timeout-message"),
"返回登录按钮", Map.of("type", "LINK_TEXT", "value", "退出登录"),
// 核心优化:棋盘元素纳入定位器初始化,避免硬编码
"游戏棋盘", Map.of("type", "CSS_SELECTOR", "value", ".gomoku-board")
);
super.updateLocators(pageLocators);
}
// ------------------------------ 业务方法 ------------------------------
/**
* 点击开始匹配按钮
*/
public MatchingPage clickStartMatch() {
controls().get("开始匹配按钮").click();
return this;
}
/**
* 点击取消匹配按钮
*/
public MatchingPage clickCancelMatch() {
controls().get("取消匹配按钮").click();
return this;
}
/**
* 等待匹配成功,跳转到游戏页面(完全优化:无硬编码+复用WaitUtil+复用Control)
*/
public GamePage waitForMatchSuccess() {
// 1. 从控件池获取棋盘元素(无需硬编码By定位器)
// 复用Control类的isDisplayed():内置元素查找、等待、异常捕获,比直接用driver更稳定
boolean isMatchSuccess = WaitUtil.waitForCondition(
() -> controls().get("游戏棋盘").isDisplayed(), // 直接调用控件的方法
60, // 超时时间:60秒
1 // 轮询间隔:1秒
);
// 2. 业务化异常提示
if (!isMatchSuccess) {
throw new RuntimeException(String.format(
"等待匹配成功超时!超时时间:60秒,控件名称:游戏棋盘(定位器:CSS_SELECTOR=.gomoku-board)"
));
}
return new GamePage();
}
/**
* 获取用户分数(从用户信息区域提取)
*/
public int getUserScore() {
String userInfoText = controls().get("用户信息区域").getText();
if (userInfoText == null || !userInfoText.contains("分数:")) {
throw new RuntimeException(String.format("用户信息文本格式异常,无法提取分数!当前文本:%s", userInfoText));
}
return StringUtil.extractNumber(userInfoText.split("分数:")[1]);
}
/**
* 判断是否处于匹配中状态
*/
public boolean isMatchingInProgress() {
return controls().get("取消匹配按钮").isDisplayed();
}
/**
* 判断匹配是否超时
*/
public boolean isMatchTimeout() {
return controls().get("匹配超时提示").isDisplayed();
}
}
4.4 游戏对战页面(GamePage.java)
java
package com.gomoku.page;
import com.gomoku.base.BaseActivity;
import java.util.Map;
/**
* 游戏对战页面PO类(封装落子、胜负判定、返回匹配页等业务)
*/
public class GamePage extends BaseActivity {
// 页面标识(URL后缀:/game)
@Override
protected void initPage() {
this.Activity = "/game";
super.initPage();
}
// 初始化控件定位器
@Override
protected void initLocators() {
Map<String, Map<String, String>> pageLocators = Map.of(
"棋盘", Map.of("type", "CSS_SELECTOR", "value", ".gomoku-board"),
"回合提示", Map.of("type", "ID", "value", "turn-indicator"), // 格式:轮到你落子(黑子)
"胜利提示", Map.of("type", "XPATH", "value", "//div[text()='恭喜!你赢了!']"),
"失败提示", Map.of("type", "XPATH", "value", "//div[text()='很遗憾,你输了!']"),
"平局提示", Map.of("type", "XPATH", "value", "//div[text()='游戏平局!']"),
"返回匹配页面按钮", Map.of("type", "XPATH", "value", "//button[text()='返回匹配页']")
);
super.updateLocators(pageLocators);
}
// ------------------------------ 业务方法 ------------------------------
/**
* 在棋盘指定坐标落子(假设棋盘是15x15网格,row=行索引,col=列索引)
* 棋盘单元格定位器:.cell[data-row="row"][data-col="col"]
*/
public GamePage placePiece(int row, int col) {
// 动态生成当前落子位置的控件定位器
String cellCss = String.format(".cell[data-row='%d'][data-col='%d']", row, col);
updateLocators(Map.of(
"当前落子单元格", Map.of(
"type", "CSS_SELECTOR",
"value", cellCss
)
));
// 点击落子
controls().get("当前落子单元格").click();
return this;
}
/**
* 等待对方落子(复用工具层WaitUtil,优化后)
* 逻辑:记录初始回合文本 → 等待文本变为"轮到你落子"且与初始不同 → 超时抛业务异常
*/
public GamePage waitForOpponentMove() {
// 1. 记录初始回合文本(避免初始就是"轮到你落子"导致误判)
String initialTurnText = getTurnText();
// 2. 复用WaitUtil.waitForCondition(),等待条件满足
boolean isOpponentMoveCompleted = WaitUtil.waitForCondition(
() -> {
// 等待条件:回合提示包含"轮到你落子",且与初始文本不同
String currentTurnText = getTurnText();
return currentTurnText.contains("轮到你落子") && !currentTurnText.equals(initialTurnText);
},
30, // 超时时间:30秒(与原逻辑一致)
1 // 轮询间隔:1秒(减少浏览器查询压力)
);
// 3. 超时处理:复用框架异常风格,抛出业务化异常
if (!isOpponentMoveCompleted) {
throw new RuntimeException(String.format(
"等待对手落子超时!30秒内未检测到回合切换(初始文本:%s,目标文本:轮到你落子)",
initialTurnText
));
}
return this; // 链式调用
}
/**
* 判断是否获胜
*/
public boolean isWin() {
return controls().get("胜利提示").isDisplayed();
}
/**
* 判断是否失败
*/
public boolean isLose() {
return controls().get("失败提示").isDisplayed();
}
/**
* 点击返回匹配页面按钮
*/
public MatchingPage clickBackToMatching() {
controls().get("返回匹配页面按钮").click();
return new MatchingPage();
}
/**
* 获取当前回合提示文本
*/
public String getTurnText() {
return controls().get("回合提示").getText();
}
}
细节:
1.根据坐标动态生成单元格定位器
2.动态添加控件到定位器字典中
javapublic GamePage placePiece(int row, int col) { // 动态生成当前落子位置的控件定位器(15x15网格,通过data-row/data-col属性定位单元格) String cellCss = String.format(".cell[data-row='%d'][data-col='%d']", row, col); // 动态添加控件到locators(复用BaseActivity的updateLocators方法) updateLocators(Map.of( "当前落子单元格", Map.of( "type", "CSS_SELECTOR", "value", cellCss ) )); // 点击落子(通过控件名获取Control实例,调用click()方法) controls().get("当前落子单元格").click(); return this; // 链式调用关键:返回当前GamePage实例,支持连续操作 }为什么这样设计?
函数式接口supplier:
**作用:****封装一段获取数据的逻辑,而不是直接传递数据本身。**相当于是装载 判断逻辑的容器。判断逻辑要返回什么样的结果类型,由泛形定义

lambda:
使用细节:
javapublic GamePage waitForOpponentMove() { // 1. 记录初始回合文本(避免初始就是"轮到你落子"导致误判) String initialTurnText = getTurnText(); // 2. 复用WaitUtil.waitForCondition(),等待条件满足 boolean isOpponentMoveCompleted = WaitUtil.waitForCondition( () -> { // 等待条件:回合提示包含"轮到你落子",且与初始文本不同 String currentTurnText = getTurnText(); return currentTurnText.contains("轮到你落子") && !currentTurnText.equals(initialTurnText); }, 30, // 超时时间:30秒(与原逻辑一致) 1 // 轮询间隔:1秒(减少浏览器查询压力) );**将 [等待对方落子] 的判断逻辑封装在supplier中,**复用工具方法(等待),传入判断逻辑、超时时间、轮询间隔。
业务逻辑(做什么)与通用工具逻辑(怎么做)分离
java
public class WaitUtil {
/**
* 等待条件满足
* @param condition 等待条件(返回boolean,true表示条件满足)
* @param timeout 超时时间(秒)
* @param interval 轮询间隔(秒)
* @return 条件满足返回true,超时返回false
*/
public static boolean waitForCondition(Supplier<Boolean> condition, int timeout, double interval) {
long startTimestamp = System.currentTimeMillis(); // 开始时间戳
// 循环检查条件,直到超时
while (System.currentTimeMillis() - startTimestamp < timeout * 1000) {
if (condition.get()) { // 条件满足,返回true
return true;
}
try {
Thread.sleep((long) (interval * 1000)); // 等待轮询间隔
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 恢复线程中断状态
return false;
}
}
return false; // 超时返回false
}
}
4.5 结果展示页面(ResultPage.java)
java
package com.gomoku.page;
import com.gomoku.base.BaseActivity;
import java.util.Map;
/**
* 结果展示页面PO类(封装胜负结果展示、返回匹配页等业务)
* 对应场景:游戏结束后显示"你赢了!""你输了!"等结果的页面
*/
public class ResultPage extends BaseActivity {
// 页面标识(URL后缀:/result)
@Override
protected void initPage() {
this.Activity = "/result";
super.initPage();
}
// 初始化控件定位器(基于图片中结果页元素)
@Override
protected void initLocators() {
Map<String, Map<String, String>> pageLocators = Map.of(
"胜利提示文本", Map.of(
"type", "XPATH",
"value", "//div[text()='你赢了!']" // 对应图片中"你赢了!"提示
),
"失败提示文本", Map.of(
"type", "XPATH",
"value", "//div[text()='你输了!']" // 对应图片中"你输了!"提示
),
"返回匹配页按钮", Map.of(
"type", "LINK_TEXT",
"value", "回到春日部" // 对应图片中"回到春日部"按钮
),
"本局得分提示", Map.of(
"type", "CSS_SELECTOR",
"value", ".score-indicator" // 假设存在得分提示元素
)
);
super.updateLocators(pageLocators);
}
// ------------------------------ 业务方法 ------------------------------
/**
* 判断是否显示胜利提示
*/
public boolean isWinDisplayed() {
return controls().get("胜利提示文本").isDisplayed();
}
/**
* 判断是否显示失败提示
*/
public boolean isLoseDisplayed() {
return controls().get("失败提示文本").isDisplayed();
}
/**
* 点击"回到春日部"按钮,返回匹配页面
*/
public MatchingPage clickBackToMatching() {
controls().get("返回匹配页按钮").click();
// 等待页面跳转至匹配页
waitForControlDisappear("胜利提示文本", 3); // 无论胜负,等待结果提示消失
return new MatchingPage();
}
/**
* 获取本局得分(从得分提示中提取)
*/
public int getCurrentScore() {
String scoreText = controls().get("本局得分提示").getText();
// 假设文本格式为"本局得分:+50",提取数字
return Integer.parseInt(scoreText.replaceAll("[^0-9+-]", ""));
}
}
模块 5:测试用例层(Test)
1. 通用父类(BaseTest.java)
封装所有测试用例的通用前置 / 后置逻辑,避免重复代码:
java
package com.gomoku.test;
import com.gomoku.base.DriverManager;
import org.testng.annotations.AfterClass;
import org.testng.annotations.BeforeClass;
/**
* 测试用例父类(封装通用前置/后置逻辑)
*/
public abstract class BaseTest {
/**
* 前置:初始化驱动,打开项目基础URL
*/
@BeforeClass(alwaysRun = true)
public void globalSetup() {
DriverManager.openBaseUrl(); // 打开 http://127.0.0.1:8080/gomoku
}
/**
* 后置:关闭驱动,释放资源
*/
@AfterClass(alwaysRun = true)
public void globalTeardown() {
DriverManager.quitDriver();
}
}
2. 登录功能测试(LoginTest.java)
专注「登录流程」,覆盖成功登录、错误密码、跳转注册页等场景:
java
package com.gomoku.test;
import com.gomoku.page.LoginPage;
import com.gomoku.page.MatchingPage;
import com.gomoku.page.RegisterPage;
import com.gomoku.util.AssertUtil;
import org.testng.annotations.Test;
/**
* 登录功能测试(独立场景:只验证登录相关流程)
*/
public class LoginTest extends BaseTest {
/**
* 场景1:正确账号密码登录→跳转到匹配页
*/
@Test(description = "正向用例:正确用户名密码登录", groups = "login.success")
public void testLoginSuccess() {
// 1. 初始化登录页(自动校验URL包含/login)
LoginPage loginPage = new LoginPage();
// 2. 执行登录操作(链式调用)
MatchingPage matchingPage = loginPage
.inputUsername("validUser001")
.inputPassword("ValidPwd123!")
.clickLoginButton();
// 3. 断言:跳转匹配页成功
String currentUrl = DriverManager.getDriver().getCurrentUrl();
AssertUtil.assertUrlContains(currentUrl, "/matching", "登录后跳转匹配页验证");
AssertUtil.assertElementVisible(
matchingPage.controls().get("开始匹配按钮").isDisplayed(),
"匹配页开始匹配按钮"
);
}
/**
* 场景2:错误密码登录→显示错误提示
*/
@Test(description = "异常用例:错误密码登录", groups = "login.fail")
public void testLoginWithWrongPassword() {
// 1. 初始化登录页
LoginPage loginPage = new LoginPage();
// 2. 输入正确用户名+错误密码
loginPage
.inputUsername("validUser001")
.inputPassword("WrongPwd456!")
.clickLoginButton();
// 3. 断言:错误提示正确显示
AssertUtil.assertElementVisible(
loginPage.controls().get("错误提示").isDisplayed(),
"登录错误提示框"
);
AssertUtil.assertTextEquals(
loginPage.getErrorMsg(),
"用户名或密码错误",
"错误提示文本验证"
);
}
/**
* 场景3:点击注册链接→跳转到注册页
*/
@Test(description = "跳转用例:登录页点击注册链接", groups = "login.jump")
public void testJumpToRegisterPage() {
// 1. 初始化登录页
LoginPage loginPage = new LoginPage();
// 2. 点击注册链接
RegisterPage registerPage = loginPage.clickRegisterLink();
// 3. 断言:跳转注册页成功
String currentUrl = DriverManager.getDriver().getCurrentUrl();
AssertUtil.assertUrlContains(currentUrl, "/register", "登录页跳转注册页验证");
AssertUtil.assertElementVisible(
registerPage.controls().get("注册按钮").isDisplayed(),
"注册页注册按钮"
);
}
}
3. 注册功能测试(RegisterTest.java)
专注「注册流程」,覆盖成功注册、重复注册、格式错误等场景:
java
package com.gomoku.test;
import com.gomoku.page.LoginPage;
import com.gomoku.page.RegisterPage;
import com.gomoku.util.AssertUtil;
import com.gomoku.util.StringUtil;
import org.testng.annotations.Test;
/**
* 注册功能测试(独立场景:只验证注册相关流程)
*/
public class RegisterTest extends BaseTest {
/**
* 场景1:正确信息注册→跳转到登录页
*/
@Test(description = "正向用例:正确信息注册", groups = "register.success")
public void testRegisterSuccess() {
// 1. 生成随机用户名(避免重复注册)
String randomUsername = "regUser" + System.currentTimeMillis() / 1000;
String password = "RegPwd123!";
// 2. 从登录页跳转到注册页
LoginPage loginPage = new LoginPage();
RegisterPage registerPage = loginPage.clickRegisterLink();
// 3. 执行注册操作
LoginPage redirectLoginPage = registerPage
.inputUsername(randomUsername)
.inputPassword(password)
.inputConfirmPassword(password)
.clickRegisterButton();
// 4. 断言:注册成功并跳转登录页
String currentUrl = DriverManager.getDriver().getCurrentUrl();
AssertUtil.assertUrlContains(currentUrl, "/login", "注册后跳转登录页验证");
AssertUtil.assertElementVisible(
redirectLoginPage.controls().get("登录按钮").isDisplayed(),
"登录页登录按钮"
);
}
/**
* 场景2:重复用户名注册→显示格式错误提示
*/
@Test(description = "异常用例:重复用户名注册", groups = "register.fail")
public void testRegisterWithDuplicateUsername() {
// 1. 从登录页跳转到注册页
LoginPage loginPage = new LoginPage();
RegisterPage registerPage = loginPage.clickRegisterLink();
// 2. 输入已存在的用户名
String duplicateUsername = "validUser001"; // 已注册的用户名
registerPage
.inputUsername(duplicateUsername)
.inputPassword("RegPwd123!")
.inputConfirmPassword("RegPwd123!")
.clickRegisterButton();
// 3. 断言:显示重复注册提示
AssertUtil.assertElementVisible(
registerPage.controls().get("用户名格式提示").isDisplayed(),
"用户名重复提示"
);
AssertUtil.assertTextEquals(
registerPage.controls().get("用户名格式提示").getText(),
"用户名已存在",
"重复注册提示文本验证"
);
}
/**
* 场景3:用户名格式错误(长度<3)→显示格式提示
*/
@Test(description = "边界用例:用户名格式错误", groups = "register.fail")
public void testRegisterWithInvalidUsername() {
// 1. 从登录页跳转到注册页
LoginPage loginPage = new LoginPage();
RegisterPage registerPage = loginPage.clickRegisterLink();
// 2. 输入无效用户名(长度2位)
String invalidUsername = "ab";
registerPage.inputUsername(invalidUsername);
// 3. 断言:格式验证失败
AssertUtil.assertFalse(
StringUtil.isValidUsername(invalidUsername),
"用户名格式验证(长度<3)"
);
AssertUtil.assertElementVisible(
registerPage.controls().get("用户名格式提示").isDisplayed(),
"用户名格式错误提示"
);
}
}
4. 匹配功能测试(MatchingTest.java)
专注「匹配流程」,覆盖开始匹配、取消匹配、匹配超时等场景:
java
package com.gomoku.test;
import com.gomoku.page.LoginPage;
import com.gomoku.page.MatchingPage;
import com.gomoku.util.AssertUtil;
import org.testng.annotations.Test;
/**
* 匹配功能测试(独立场景:只验证匹配相关流程,依赖登录成功)
*/
public class MatchingTest extends BaseTest {
/**
* 场景1:登录后开始匹配→进入匹配中状态
*/
@Test(description = "正向用例:开始匹配进入匹配中状态", groups = "matching.start", dependsOnGroups = "login.success")
public void testStartMatching() {
// 1. 先登录成功
LoginPage loginPage = new LoginPage();
MatchingPage matchingPage = loginPage
.inputUsername("validUser001")
.inputPassword("ValidPwd123!")
.clickLoginButton();
// 2. 点击开始匹配
matchingPage.clickStartMatch();
// 3. 断言:进入匹配中状态(取消匹配按钮可见)
AssertUtil.assertElementVisible(
matchingPage.isMatchingInProgress(),
"匹配中状态(取消匹配按钮)"
);
}
/**
* 场景2:匹配中取消匹配→返回初始状态
*/
@Test(description = "正向用例:匹配中取消匹配", groups = "matching.cancel", dependsOnGroups = "matching.start")
public void testCancelMatching() {
// 1. 登录→开始匹配(复用前置流程)
LoginPage loginPage = new LoginPage();
MatchingPage matchingPage = loginPage
.inputUsername("validUser001")
.inputPassword("ValidPwd123!")
.clickLoginButton()
.clickStartMatch();
// 2. 点击取消匹配
matchingPage.clickCancelMatch();
// 3. 断言:返回初始状态(开始匹配按钮可见)
AssertUtil.assertElementVisible(
matchingPage.controls().get("开始匹配按钮").isDisplayed(),
"取消匹配后开始匹配按钮"
);
}
/**
* 场景3:匹配超时→显示超时提示
*/
@Test(description = "异常用例:匹配超时", groups = "matching.timeout", dependsOnGroups = "login.success")
public void testMatchTimeout() {
// 1. 登录→开始匹配
LoginPage loginPage = new LoginPage();
MatchingPage matchingPage = loginPage
.inputUsername("validUser001")
.inputPassword("ValidPwd123!")
.clickLoginButton()
.clickStartMatch();
// 2. 等待超时提示(超时时间60秒,预留5秒缓冲)
boolean isTimeout = com.gomoku.util.WaitUtil.waitForCondition(
() -> matchingPage.isMatchTimeout(),
65,
1
);
// 3. 断言:超时提示显示
AssertUtil.assertTrue(isTimeout, "匹配超时提示未显示");
}
}
5. 落子与胜负判定测试(GamePlayTest.java)
专注「游戏对战流程」,覆盖落子、等待对方落子、五子连线获胜 / 失败 / 平局等场景:
java
package com.gomoku.test;
import com.gomoku.page.GamePage;
import com.gomoku.page.LoginPage;
import com.gomoku.page.MatchingPage;
import com.gomoku.util.AssertUtil;
import org.testng.annotations.Test;
/**
* 落子与胜负判定测试(独立场景:只验证游戏对战核心流程,依赖匹配成功)
*/
public class GamePlayTest extends BaseTest {
/**
* 场景1:指定坐标落子→回合切换
*/
@Test(description = "基础用例:指定坐标落子并切换回合", groups = "game.place", dependsOnGroups = "matching.start")
public void testPlacePiece() {
// 1. 登录→匹配成功进入游戏页
GamePage gamePage = loginAndMatchSuccess();
// 2. 记录初始回合文本
String initialTurnText = gamePage.getTurnText();
// 3. 在(7,7)落子
gamePage.placePiece(7, 7);
// 4. 断言:回合文本变化(切换到对方回合)
String afterPlaceTurnText = gamePage.getTurnText();
AssertUtil.assertFalse(
afterPlaceTurnText.equals(initialTurnText),
"落子后回合未切换"
);
}
/**
* 场景2:等待对方落子→回合切换回自己
*/
@Test(description = "基础用例:等待对方落子后切换回合", groups = "game.wait", dependsOnGroups = "game.place")
public void testWaitForOpponentMove() {
// 1. 登录→匹配成功进入游戏页
GamePage gamePage = loginAndMatchSuccess();
// 2. 先落一子,触发对方回合
gamePage.placePiece(7, 7);
// 3. 等待对方落子(测试环境可模拟对方落子)
gamePage.waitForOpponentMove();
// 4. 断言:回合切换回自己(包含"轮到你落子")
AssertUtil.assertTrue(
gamePage.getTurnText().contains("轮到你落子"),
"对方落子后未切换到自己回合"
);
}
/**
* 场景3:横向五子连线→获胜
*/
@Test(description = "胜负用例:横向五子连线获胜", groups = "game.win", dependsOnGroups = "game.wait")
public void testHorizontalWin() {
// 1. 登录→匹配成功进入游戏页
GamePage gamePage = loginAndMatchSuccess();
// 2. 模拟横向五子连线(自己落子→等待对方落子)
gamePage
.placePiece(7, 7) // 第1子
.waitForOpponentMove()
.placePiece(7, 8) // 第2子
.waitForOpponentMove()
.placePiece(7, 9) // 第3子
.waitForOpponentMove()
.placePiece(7, 10) // 第4子
.waitForOpponentMove()
.placePiece(7, 11); // 第5子(五子连线)
// 3. 断言:显示胜利提示
AssertUtil.assertElementVisible(
gamePage.isWin(),
"横向五子连线胜利提示"
);
}
/**
* 场景4:被对方纵向五子连线→失败
*/
@Test(description = "胜负用例:被对方纵向五子连线失败", groups = "game.lose", dependsOnGroups = "game.wait")
public void testVerticalLose() {
// 1. 登录→匹配成功进入游戏页
GamePage gamePage = loginAndMatchSuccess();
// 2. 模拟对方先落4子(测试环境注入落子)
injectOpponentPiece(gamePage, 6, 7); // 对方落子(6,7)
gamePage.placePiece(7, 7).waitForOpponentMove();
injectOpponentPiece(gamePage, 5, 7); // 对方落子(5,7)
gamePage.placePiece(7, 8).waitForOpponentMove();
injectOpponentPiece(gamePage, 4, 7); // 对方落子(4,7)
gamePage.placePiece(7, 9).waitForOpponentMove();
injectOpponentPiece(gamePage, 3, 7); // 对方落子(3,7)(五子连线)
gamePage.waitForOpponentMove();
// 3. 断言:显示失败提示
AssertUtil.assertElementVisible(
gamePage.isLose(),
"被对方纵向五子连线失败提示"
);
}
/**
* 场景5:棋盘下满→平局
*/
@Test(description = "胜负用例:棋盘下满平局", groups = "game.draw", dependsOnGroups = "game.place")
public void testDraw() {
// 1. 登录→匹配成功进入游戏页
GamePage gamePage = loginAndMatchSuccess();
// 2. 模拟棋盘下满(简化:交替落子直到无空位,测试环境可加速)
for (int row = 0; row < 15; row++) {
for (int col = 0; col < 15; col++) {
if ((row + col) % 2 == 0) {
gamePage.placePiece(row, col); // 自己落子(偶数位)
if (row == 14 && col == 14) break; // 最后一子无需等待
gamePage.waitForOpponentMove(); // 等待对方落子(奇数位)
}
}
}
// 3. 断言:显示平局提示
AssertUtil.assertElementVisible(
gamePage.controls().get("平局提示").isDisplayed(),
"棋盘下满平局提示"
);
}
// ------------------------------ 辅助方法 ------------------------------
/**
* 辅助:登录→匹配成功→进入游戏页(复用流程)
*/
private GamePage loginAndMatchSuccess() {
return new LoginPage()
.inputUsername("validUser001")
.inputPassword("ValidPwd123!")
.clickLoginButton()
.clickStartMatch()
.waitForMatchSuccess();
}
/**
* 辅助:模拟对方落子(测试环境专用,实际可调用后端接口)
*/
private void injectOpponentPiece(GamePage gamePage, int row, int col) {
// 测试环境逻辑:调用后端落子接口或WebSocket推送落子事件
System.out.printf("模拟对方在坐标(%d,%d)落子%n", row, col);
// 实际项目可替换为:
// RestAssured.post("/api/game/place-piece?row=" + row + "&col=" + col + "&player=opponent");
}
}
6. 游戏结果处理测试(GameResultTest.java)
专注「游戏结束后流程」,覆盖返回匹配页、得分验证等场景:
java
package com.gomoku.test;
import com.gomoku.page.GamePage;
import com.gomoku.page.MatchingPage;
import com.gomoku.page.ResultPage;
import com.gomoku.util.AssertUtil;
import org.testng.annotations.Test;
/**
* 游戏结果处理测试(独立场景:只验证游戏结束后的操作,依赖胜负判定)
*/
public class GameResultTest extends BaseTest {
/**
* 场景1:获胜后返回匹配页
*/
@Test(description = "结果用例:获胜后返回匹配页", groups = "result.back", dependsOnGroups = "game.win")
public void testBackToMatchingAfterWin() {
// 1. 登录→匹配→获胜(复用流程)
GamePage gamePage = new LoginPage()
.inputUsername("validUser001")
.inputPassword("ValidPwd123!")
.clickLoginButton()
.clickStartMatch()
.waitForMatchSuccess()
.placePiece(7, 7)
.waitForOpponentMove()
.placePiece(7, 8)
.waitForOpponentMove()
.placePiece(7, 9)
.waitForOpponentMove()
.placePiece(7, 10)
.waitForOpponentMove()
.placePiece(7, 11);
// 2. 点击返回匹配页按钮
MatchingPage matchingPage = gamePage.clickBackToMatching();
// 3. 断言:跳转匹配页成功
String currentUrl = DriverManager.getDriver().getCurrentUrl();
AssertUtil.assertUrlContains(currentUrl, "/matching", "获胜后返回匹配页URL验证");
AssertUtil.assertElementVisible(
matchingPage.controls().get("开始匹配按钮").isDisplayed(),
"匹配页开始匹配按钮"
);
}
/**
* 场景2:结果页得分验证(获胜加分)
*/
@Test(description = "结果用例:获胜后得分验证", groups = "result.score", dependsOnGroups = "game.win")
public void testScoreAfterWin() {
// 1. 登录→匹配→获胜→进入结果页
GamePage gamePage = new LoginPage()
.inputUsername("validUser001")
.inputPassword("ValidPwd123!")
.clickLoginButton()
.clickStartMatch()
.waitForMatchSuccess()
.placePiece(7, 7)
.waitForOpponentMove()
.placePiece(7, 8)
.waitForOpponentMove()
.placePiece(7, 9)
.waitForOpponentMove()
.placePiece(7, 10)
.waitForOpponentMove()
.placePiece(7, 11);
// 2. 进入结果页(假设获胜后自动跳转)
ResultPage resultPage = new ResultPage();
// 3. 断言:得分大于0(获胜加分)
int currentScore = resultPage.getCurrentScore();
AssertUtil.assertTrue(
currentScore > 0,
"获胜后得分未增加,当前得分:" + currentScore
);
}
}
模块六:配置与数据层
模块七:执行与报告层
测试执行配置(testng.xml)
支持「单独执行某类场景」或「全流程执行」,配置如下:
实际项目中最常用的方式 ,通过配置文件指定要执行的测试类 、用例分组,TestNG 会按照配置驱动执行
java
<!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd">
<suite name="GomokuFullTestSuite" parallel="false">
<!-- 全流程执行:按登录→注册→匹配→落子→胜负→结果顺序 -->
<test name="FullFlowTest">
<groups>
<run>
<include name="login.*"/>
<include name="register.*"/>
<include name="matching.*"/>
<include name="game.*"/>
<include name="result.*"/>
</run>
</groups>
<classes>
<class name="com.gomoku.test.LoginTest"/>
<class name="com.gomoku.test.RegisterTest"/>
<class name="com.gomoku.test.MatchingTest"/>
<class name="com.gomoku.test.GamePlayTest"/>
<class name="com.gomoku.test.GameResultTest"/>
</classes>
</test>
<!-- 单独执行登录测试 -->
<test name="LoginOnlyTest">
<groups>
<run>
<include name="login.*"/>
</run>
</groups>
<classes>
<class name="com.gomoku.test.LoginTest"/>
</classes>
</test>
<!-- 单独执行胜负判定测试 -->
<test name="GameWinLoseTest">
<groups>
<run>
<include name="game.win"/>
<include name="game.lose"/>
<include name="game.draw"/>
</run>
</groups>
<classes>
<class name="com.gomoku.test.GamePlayTest"/>
</classes>
</test>
</suite>

三.关于TestNG和Selenium:
1.TestNG
核心执行引擎
| TestNG 特性 | 作用 |
|---|---|
@Test 注解 |
识别测试用例 :自动识别带有 @Test 注解的方法 |
@BeforeClass/@AfterClass |
管理测试生命周期:按固定顺序执行代码 |
groups 分组 |
分组执行 :通过 groups 标签给用例分类 |
dependsOnGroups |
控制用例依赖:通过 dependsOnGroups/dependsOnMethods 定义用例执行顺序 |
Assert 断言 |
处理异常与断言 :结合 Assert 类(或你框架的 AssertUtil)做结果校验 |
testng.xml 配置文件 |
生成测试报告:执行完成后自动生成 HTML 报告, |

2.Selenium
| 工具 | 核心定位 | 核心作用 | 关系总结 |
|---|---|---|---|
| Selenium | 开源 Web 自动化工具集 | 负责 "执行具体操作" :模拟浏览器交互(点击、输入、跳转等),是自动化的 "执行引擎" | 负责 "做什么"(实际操作) |
| TestNG | Java 语言的测试框架(独立工具) | 负责 "管理测试流程":用例分组 / 优先级、参数化、断言(判断结果对错)、生成测试报告,让测试更规范高效 | 负责 "怎么管、怎么判"(流程 + 验证) |
Java 体系下 Web 自动化测试的主流方案,selenium是自动化工具集,负责"执行具体操作",TestNG是测试框架,负责"管理测试流程"
四.总结:
(1)基础支持层中的BaseActivity类中
- 封装所有页面通用操作(点击、输入、等待等),页面类继承后直接复用。
- 定义了控件管理机制,让每个页面子类必须定义所属于自己页面的控件
- 提供简洁的控件访问方式:
controls()方法,通过 "控件名" 直接获取 Control 实例 ,用法为controls().get("控件名").操作方法();
(2)控件模型层的control类中
- 封装了单个元素的操作(查找元素+显示等待+执行操作这三个步骤都封装在),对外提供简洁的调用方式 ,且方便扩展和维护(如果后续需要扩展其他元素操作,只需要在control类中新增即可)
(3)工具层的WaitUtil类中
- 定义了跨场景的通用显示等待方法,(需要:一个工具搞定所有等待场景,将判定条件作为参数传入)
(4)页面对象层的PO类中
采用链式调用的语法去操作元素
























