【双人对战五子棋游戏】的自动化测试框架设计

一、框架设计思路与核心目标

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.私有构造方法

禁止外部()实例化

java 复制代码
private DriverManager() {}

2.静态私有实例

确保实例唯一

java 复制代码
private static volatile WebDriver driver;

3.双重检查锁

高效且线程安全的懒加载(用到时才创建实例),解决了 "线程安全" 和 "效率" 的平衡:

java 复制代码
public 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 类(含 typevalue 字段),子类添加控件时直接 用 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);
}
java 复制代码
if (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 中 字符串格式化工具方法

java 复制代码
String 最终字符串 = 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())都复用这个方法:

java 复制代码
private 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.动态添加控件到定位器字典中

java 复制代码
public 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:

使用细节:

java 复制代码
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秒(减少浏览器查询压力)
        );

**将 [等待对方落子] 的判断逻辑封装在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类中

  1. 封装所有页面通用操作(点击、输入、等待等),页面类继承后直接复用。
  2. 定义了控件管理机制,让每个页面子类必须定义所属于自己页面的控件
  3. 提供简洁的控件访问方式:controls() 方法,通过 "控件名" 直接获取 Control 实例 ,用法为 controls().get("控件名").操作方法()

(2)控件模型层的control类中

  1. 封装了单个元素的操作(查找元素+显示等待+执行操作这三个步骤都封装在),对外提供简洁的调用方式 ,且方便扩展和维护(如果后续需要扩展其他元素操作,只需要在control类中新增即可)

(3)工具层的WaitUtil类中

  1. 定义了跨场景的通用显示等待方法,(需要:一个工具搞定所有等待场景,将判定条件作为参数传入)

(4)页面对象层的PO类中

采用链式调用的语法去操作元素

相关推荐
weixin_4365250732 分钟前
jar包启动使用logs替换nohup日志文件
java·linux·数据库
D***776534 分钟前
【Redis】在Java中以及Spring环境下操作Redis
java·redis·spring
k***z1135 分钟前
Spring boot创建时常用的依赖
java·spring boot·后端
Sally_xy35 分钟前
安装 Java
java·开发语言
第二只羽毛40 分钟前
订餐系统的代码实现
java·大数据·开发语言
哦你看看41 分钟前
K8S-Pod资源对象
java·容器·kubernetes
i***132444 分钟前
java进阶1——JVM
java·开发语言·jvm
计算机毕设定制辅导-无忧学长1 小时前
基于Spring Boot的驾校管理系统
java·spring boot·后端