个人测试项目:一个可跑、可扩、可复用的 Selenium UI 自动化博客系统全链路测试拆解

个人测试项目:一个可跑、可扩、可复用的 Selenium UI 自动化博客系统全链路测试拆解

驱动初始化 → 登录获取会话 → 列表页校验与跳转 → 详情页编辑/删除 → 写博客发布并回查。这个自动化测试脚本用 Selenium + WebDriverManager 把上面这条链路打通,并用"页面类"把不同页面的操作封装起来,整体结构清晰,适合在此基础上继续扩展。


1. 工程结构:用页面类把 UI 操作"模块化"

来看目录层级(按包划分):

  • common/Utils.java:公共能力(Driver 创建、等待器、截图、公共 URL)
  • tests/LoginPage.java:登录页相关操作
  • tests/ListPage.java:博客列表页校验与跳转
  • tests/DetailPage.java:博客详情页校验、编辑、删除
  • tests/EditPage.java:写博客页发布(重点处理 CodeMirror 编辑器)
  • tests/RunBlogTests.java:串联执行入口(main 方式跑一条端到端流程)

这种结构本质上就是 Page Object(页面对象)思想:一个页面 = 一个类;页面的元素定位 + 操作行为都收敛到这个类里,避免测试脚本到处散落定位语句。


2. 公共基座 Utils:Driver 单例 + 隐式等待 + 显式等待器 + 截图

java 复制代码
package common;
import io.github.bonigarcia.wdm.WebDriverManager;
import org.apache.commons.io.FileUtils;
import org.openqa.selenium.OutputType;
import org.openqa.selenium.TakesScreenshot;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.support.ui.WebDriverWait;

import java.io.File;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.time.Duration;

public class Utils {
    public static WebDriver driver = null;
    public WebDriverWait wait = null;
    public static String detailURL = "http://47.108.157.13:8090/blog_detail.html?blogId=58486";
    public Utils(String url){
        //调用driver对象
        driver = createDriver();
        System.out.println("请求Url" + url);
        driver.get(url);
        wait = new WebDriverWait(driver,Duration.ofSeconds(3));
    }
    //获取驱动对象
    public static WebDriver createDriver(){
        if(driver == null)
        {
            //下载驱动
            WebDriverManager.chromedriver().setup();
            //添加配置允许访问所有的链接
            ChromeOptions options = new ChromeOptions();
            options.addArguments("--remote-allow-origins=*");
            //创建驱动对象
            driver = new ChromeDriver(options);

            //隐式等待(不等待Alert)
            driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(3));
        }
        return driver;
    }
    /**
     * 屏幕截图
     * 文件目录
     * ./src/test/java/images/2026-01-28/test01-20123010.png
     */
    public void ScreenShot(String str) throws IOException {
        //年月日
        SimpleDateFormat sim1 = new SimpleDateFormat("yyyy-MM-dd");
        //时分秒
        SimpleDateFormat sim2 = new SimpleDateFormat("HHmmssSS");

        String dirtime = sim1.format(System.currentTimeMillis());
        String filetime = sim2.format(System.currentTimeMillis());

        String filename = "./src/test/java/images/" + dirtime + "/" + str + "-" + filetime + ".png";

        File srcFile = ((TakesScreenshot)driver).getScreenshotAs(OutputType.FILE);
        FileUtils.copyFile(srcFile,new File(filename));
    }
}

来看 Utils.java 的定位:它把"驱动只创建一次、页面跳转、等待器、截图"做成公共能力,让页面类继承即可使用。

2.1 Driver 创建:WebDriverManager + ChromeOptions

来看 createDriver()

  • WebDriverManager.chromedriver().setup() 自动处理驱动下载与版本管理
  • ChromeOptions 加了 --remote-allow-origins=*,解决新版本 Chrome 的常见连接限制问题
  • driver 做成 static,并且只在 driver == null 时创建,实现"单例复用"

这个设计带来的直接好处是:同一个浏览器会话可以跨页面复用,登录态(cookie/session)也就自然保留下来了。

2.2 等待策略:隐式等待 + 显式等待器(并存)

来看等待相关代码:

  • Driver 初始化时设置了隐式等待:implicitlyWait(3s)
  • Utils 构造器里创建了显式等待器:new WebDriverWait(driver, 3s)

隐式等待的作用范围是全局的(影响每一次 findElement);显式等待更适合等"某个条件成立",例如等弹窗 alertIsPresent()

⚠️ 这里有一个工程级提醒:隐式等待与显式等待混用时,某些场景会让总等待时间变得不那么可预测(显式等待内部会反复轮询,而轮询里可能触发隐式等待)。这个工程里等待时间都设得比较短,体感问题不一定明显,但规模变大后建议统一策略(后文会给改造建议)。

2.3 截图:按日期归档 + 方法名命名

来看 ScreenShot(String str)

  • 目录:./src/test/java/images/yyyy-MM-dd/
  • 文件名:<方法名>-HHmmssSS.png(时间戳避免覆盖)
  • 通过 TakesScreenshot 拿到 FILE,再用 FileUtils.copyFile 写盘

这个功能非常实用:一旦自动化在 CI 上跑挂了,你至少能拿到失败现场截图。

⚠️ 一个小坑:目录若不存在,直接 copy 可能失败。更稳的做法是截图前 new File(dir).mkdirs()


3. 登录页 LoginPage:页面元素健康检查 + 成功/失败登录(含 alert)

java 复制代码
package tests;

import common.Utils;
import org.openqa.selenium.Alert;
import org.openqa.selenium.By;
import org.openqa.selenium.support.ui.ExpectedConditions;

import java.io.IOException;

public class LoginPage extends Utils {
    public static String url = "http://47.108.157.13:8090/blog_login.html";
    public LoginPage(){
        super(url);
    }
    /**
     * 检查登录页面是否正常打开
     */
    public void checkPageRight() throws IOException {
        //检查菜单
        driver.findElement(By.cssSelector("body > div.nav > a:nth-child(4)"));//主页按钮
        driver.findElement(By.cssSelector("body > div.nav > a:nth-child(5)"));//写博客按钮

        //检查登录框
        driver.findElement(By.cssSelector("#username"));
        driver.findElement(By.cssSelector("#password"));
        driver.findElement(By.cssSelector("#submit"));

        ScreenShot(Thread.currentThread().getStackTrace()[1].getMethodName());
    }

    /**
     * 成功登陆后测试账号密码
     */
    public void LoginSuc(){
        //先清空
        driver.findElement(By.cssSelector("#username")).clear();
        driver.findElement(By.cssSelector("#password")).clear();
        //输入正确的账号密码
        driver.findElement(By.cssSelector("#username")).sendKeys("zhangsan");
        driver.findElement(By.cssSelector("#password")).sendKeys("123456");
        driver.findElement(By.cssSelector("#submit")).click();

        //处理错误弹窗------chrome登录安全警告弹窗
        //wait.until(ExpectedConditions.alertIsPresent());
        //Alert alert = driver.switchTo().alert();
        //alert.accept();

        //登录成功后会进入列表页--注销按钮显示
        driver.findElement(By.cssSelector("body > div.nav > a:nth-child(6)"));
    }

    /**
     * 异常登录:
     * 用户名密码为空
     * 用户名空,密码不空
     * 用户名不空,密码空
     * 正确的用户名错误的密码
     * 错误的用户名正确的密码
     * 均错误的用户名密码
     * 输入框过长
     */
    public void LoginFail(){
        //先清空
        driver.findElement(By.cssSelector("#username")).clear();
        driver.findElement(By.cssSelector("#password")).clear();

        driver.findElement(By.cssSelector("#username")).sendKeys("zhangsan");
        driver.findElement(By.cssSelector("#password")).sendKeys("233333");
        driver.findElement(By.cssSelector("#submit")).click();

        //处理错误弹窗------警告弹窗
        wait.until(ExpectedConditions.alertIsPresent());
        Alert alert = driver.switchTo().alert();
        alert.accept();
    }


}

来看 LoginPage 做了三件事:

3.1 checkPageRight():登录页"健康检查"

它用 findElement 验证关键元素是否存在:

  • 导航栏按钮(主页、写博客)
  • 登录表单输入框(用户名、密码、提交按钮)

然后调用截图,把这一步的页面状态落盘。

这一步属于"冒烟检查":页面起码要能正常打开、关键元素要在。

3.2 LoginSuc():成功登录并验证登录态

它先清空输入框,再输入固定账号密码:

  • username:zhangsan
  • password:123456

点击登录后,通过检查"注销按钮"是否存在来断言登录成功。

这种断言方式的优点是简单直接:不用读接口返回,也不用依赖 URL,一看页面关键控件是否出现即可

代码里还留了处理 Chrome 安全警告弹窗的注释(alert 相关)。在一些环境下确实可能遇到弹窗,这类非 DOM 的弹窗要用显式等待 alertIsPresent() 才能稳定处理。

3.3 LoginFail():失败登录用 alert 验证

它构造了一个失败用例(正确用户名 + 错误密码),提交后:

  • wait.until(ExpectedConditions.alertIsPresent())
  • driver.switchTo().alert().accept()

这一段非常标准:alert 不在 DOM 里,隐式等待是等不到的;必须显式等待 alert 出现

代码注释里列了多种异常登录场景(空值、错值、过长等),目前实现的是其中一种。扩展这些用例很适合用"参数化"方式批量生成(后文给建议)。


4. 列表页 ListPage:校验第一条博客 + 点击跳转详情并对齐标题

java 复制代码
package tests;

import common.Utils;
import org.openqa.selenium.By;

public class ListPage extends Utils {
    public static String url = "http://47.108.157.13:8090/blog_list.html";
    public ListPage(){
        super(url);
    }

    /**
     * 检查博客列表页------菜单模块 个人信息模块 博客列表模块
     * 演示------博客列表模块
     */

    public void checkBlogList() throws InterruptedException{
        //博客标题
        String title = driver.findElement(By.cssSelector("body > div.container > div.right > div:nth-child(1) > div.title")).getText();
        //博客发布时间
        String pushTime = driver.findElement(By.cssSelector("body > div.container > div.right > div:nth-child(1) > div.date")).getText();
        //博客内容
        String content = driver.findElement(By.cssSelector("body > div.container > div.right > div:nth-child(1) > div.desc")).getText();
        //查看全文按钮
        String button = driver.findElement(By.cssSelector("body > div.container > div.right > div:nth-child(1) > a")).getText();

        //校验文本
        assert !title.isEmpty();
        assert !pushTime.isEmpty();
        assert !content.isEmpty();

        assert button.equals("查看全文>>");

        //点击查看全文按钮,检查跳转是否正确
        Thread.sleep(2000);
        driver.findElement(By.cssSelector("body > div.container > div.right > div:nth-child(1) > a")).click();
        String jump_title = driver.findElement(By.cssSelector("body > div.container > div.right > div > div.title")).getText();
        assert title.equals(jump_title);
    }
}

来看 checkBlogList() 的逻辑:

  1. 读取列表第一条博客的:

    • 标题 title
    • 发布时间 pushTime
    • 摘要内容 content
    • 按钮文本 button
  2. 断言:

    • 标题/时间/摘要非空
    • 按钮文本等于 "查看全文>>"
  3. 点击"查看全文>>"进入详情页

  4. 在详情页再取一次标题 jump_title,断言与列表页标题一致

这是一个很典型的 UI 一致性检查:列表展示的内容应该与详情页一致,同时也验证了跳转链路是正确的。

⚠️ 这里用了 Thread.sleep(2000) 做等待。短工程可以接受,但要跑得稳,建议改成显式等待(例如等详情页标题元素可见)。


5. 详情页 DetailPage:页面加载检查 + 编辑改标题 + 删除(先取消再确认)

java 复制代码
package tests;

import common.Utils;
import org.openqa.selenium.Alert;
import org.openqa.selenium.By;
import org.openqa.selenium.support.ui.ExpectedConditions;

import java.text.SimpleDateFormat;

public class DetailPage extends Utils {
    public static String url = detailURL;
    public DetailPage(){
        super(url);
    }

    /**
     * 校验页面加载成功
     */
    public void checkDetailpage(){
        //标题
        driver.findElement(By.cssSelector("body > div.container > div.right > div > div.title"));
        //发布时间
        driver.findElement(By.cssSelector("body > div.container > div.right > div > div.date"));
        //内容
        driver.findElement(By.cssSelector("#detail"));
        //编辑
        driver.findElement(By.cssSelector("body > div.container > div.right > div > div.operating > button:nth-child(1)"));
        //删除
        driver.findElement(By.cssSelector("body > div.container > div.right > div > div.operating > button:nth-child(2)"));
    }
    public void checkDetailEdit() throws InterruptedException{
        //获取更新前的标题
        String titleBefore = driver.findElement(By.cssSelector("body > div.container > div.right > div > div.title")).getText();

        //找到编辑按钮并单击
        driver.findElement(By.cssSelector("body > div.container > div.right > div > div.operating > button:nth-child(1)")).click();
        //Thread.sleep(2000);
        //清空标题
        driver.findElement(By.cssSelector("#title")).clear();
        //更新后的标题要动态生成
        //时分秒
        SimpleDateFormat sim = new SimpleDateFormat("HHmmssSS");
        String titleTime = sim.format(System.currentTimeMillis());
        driver.findElement(By.cssSelector("#title")).sendKeys("自动化测试更新#114514" + titleTime);
        //保存上传
        driver.findElement(By.cssSelector("#submit")).click();
        Thread.sleep(2000);
        String titleAfter = driver.findElement(By.cssSelector("body > div.container > div.right > div:nth-child(1) > div.title")).getText();
        assert !titleBefore.equals(titleAfter);
    }
    public void checkDetailDel() throws InterruptedException{
        driver.get(url);
        driver.findElement(By.cssSelector("body > div.container > div.right > div > div.operating > button:nth-child(2)")).click();
        wait.until(ExpectedConditions.alertIsPresent());
        Alert alert = driver.switchTo().alert();
        alert.dismiss();

        Thread.sleep(2000);
        driver.findElement(By.cssSelector("body > div.container > div.right > div > div.operating > button:nth-child(2)")).click();
        wait.until(ExpectedConditions.alertIsPresent());
        alert = driver.switchTo().alert();
        alert.accept();//这里是真的确认删除了
    }
}

来看 DetailPage 的 URL 使用了一个固定的 detailURL(带 blogId 参数)。它的测试设计非常"UI 自动化味儿":既测页面结构,也测对数据的真实修改。

5.1 checkDetailpage():详情页关键元素存在性

它验证以下元素存在:

  • 标题、发布时间、内容区域
  • 编辑按钮、删除按钮

这一步相当于"详情页冒烟"。

5.2 checkDetailEdit():编辑标题并验证确实变更

它做的动作链是:

  1. 读取编辑前标题 titleBefore
  2. 点击"编辑"
  3. 清空标题输入框
  4. 用时间戳生成一个新标题(避免冲突)
  5. 点击保存提交
  6. 再读取标题 titleAfter
  7. 断言 titleBefore != titleAfter

这里的关键点是"数据去重":用时间戳动态生成标题,避免和历史数据撞车导致误判。

5.3 checkDetailDel():删除弹窗的取消与确认两条路径

它把删除行为分两次跑:

  • 第一次点击删除 → 等 alert → dismiss()(取消)
  • 第二次点击删除 → 等 alert → accept()(确认删除)

这是一个很好的覆盖思路:同一个确认弹窗,两个按钮都要测,并且 alert 的等待方式正确。


6. 写博客页 EditPage:CodeMirror 输入是难点(Actions + 防 stale)

java 复制代码
package tests;

import common.Utils;
import org.openqa.selenium.By;
import org.openqa.selenium.Keys;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.interactions.Actions;

import java.text.SimpleDateFormat;
import java.util.List;

public class EditPage extends Utils {
    public static String url = "http://47.108.157.13:8090/blog_edit.html";

    public EditPage(){
        super(url);
    }

    /**
     * 正常发布博客
     */
    public void editSuc() throws InterruptedException{
        driver.get(url);
        Thread.sleep(2000);
        //更新后的标题要动态生成
        //时分秒
        SimpleDateFormat sim = new SimpleDateFormat("HHmmssSS");
        String titleTime = sim.format(System.currentTimeMillis());
        String titleBefore = "自动化创建博客" + titleTime;
        //找到标题区域输入标题(动态生成)
        driver.findElement(By.cssSelector("#title")).sendKeys("自动化创建博客" + titleTime);
        //找到内容区域输入内容
        /**
         * 注意:这里的编辑框不能通过sendKeys访问,需要用其他方式访问
         */

        //年月日
        SimpleDateFormat sim1 = new SimpleDateFormat("yyyy-MM-dd");
        String dirtime = sim1.format(System.currentTimeMillis());
        WebElement ele = driver.findElement(By.cssSelector("#editor > div.CodeMirror.cm-s-default.CodeMirror-wrap > div.CodeMirror-scroll > div.CodeMirror-sizer > div > div > div > div.CodeMirror-code > div > pre"));
        new Actions(driver).doubleClick(ele).sendKeys(Keys.DELETE).perform();//加perform是需要在页面上看到效果
        WebElement ele2 = driver.findElement(By.cssSelector("#editor > div.CodeMirror.cm-s-default.CodeMirror-wrap > div.CodeMirror-scroll > div.CodeMirror-sizer > div > div > div > div.CodeMirror-code > div > pre"));
        //ele2是为了防止StaleElementReferenceException 过时元素引用错误 这里很重要
        new Actions(driver).click(ele2).sendKeys("自动化输入内容:" + dirtime + titleTime).perform();//这里也得加perform
        Thread.sleep(2000);
        driver.findElement(By.cssSelector("#submit")).click();

        //发布完成之后跳转到列表页
        //获取一共有多少篇博客
        List<WebElement> blogs = driver.findElements(By.cssSelector("body > div.container > div.right > div"));
        //定位最后一篇博客
        String titleAfter = driver.findElement(By.cssSelector("body > div.container > div.right > div:nth-child(" + blogs.size() + ") > div.title")).getText();

        assert titleBefore.equals(titleAfter);
    }
}

写博客页最有技术含量的地方在于编辑器:它不是普通 <textarea>,而是 CodeMirror 这类富文本/代码编辑器组件。常见坑是:

  • 直接 sendKeys 可能没效果
  • 定位的 DOM 节点在渲染/输入后会刷新,导致 StaleElementReferenceException

来看 editSuc() 的解决策略:

  1. 动态生成标题(时间戳),输入到 #title

  2. 定位 CodeMirror 内部的 pre 节点

  3. Actions(driver).doubleClick(ele).sendKeys(Keys.DELETE).perform() 清空

  4. 重新定位一次元素 (关键!)拿到 ele2

  5. Actions(driver).click(ele2).sendKeys("...").perform() 输入内容

  6. 点击提交发布

  7. 发布后会跳转列表页:

    • findElements 拿到博客列表数量
    • 定位最后一条博客标题
    • 断言最后一条标题等于刚创建的标题

这里有两个非常实战的点:

  • Actions + perform():确保输入动作真实作用在页面上
  • 重新定位元素:规避过时元素引用(stale)

这块代码写得很"知道前端组件在搞什么"。


7. 测试入口 RunBlogTests:一条端到端主流程串起来

java 复制代码
package tests;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;

public class RunBlogTests {
    private static final Logger log = LoggerFactory.getLogger(RunBlogTests.class);

    public static void main(String[] args) throws IOException,InterruptedException {
        LoginPage login = new LoginPage();
        login.checkPageRight();
        //login.LoginFail();
        login.LoginSuc();

        //登录态
        ListPage list = new ListPage();
        list.checkBlogList();

        DetailPage detail = new DetailPage();
        detail.checkDetailpage();
        detail.checkDetailEdit();
        detail.checkDetailDel();
        EditPage edit = new EditPage();
        edit.editSuc();
    }
}

来看 RunBlogTests.main() 的执行顺序:

  1. LoginPage:检查登录页 → 成功登录
  2. ListPage:校验列表并跳转详情
  3. DetailPage:校验详情页 → 编辑标题 → 删除测试
  4. EditPage:发布一篇新博客并回查列表末尾

这是一个很清晰的 E2E(端到端)链路:登录态贯穿全程,并且覆盖了"查、改、删、增"四类核心行为。


8. 关键工程点总结(以及一眼能升级的地方)

下面这些点,会直接决定"脚本能不能长期稳定跑"。

8.1 assert 语句的风险:默认可能不生效

代码里使用了 Java 原生 assert。注意:JVM 默认不开启断言,除非运行时加 -ea。不开 -ea 时,所有断言会被忽略,脚本可能"看起来都通过"。

更稳的做法:

  • 用 JUnit/TestNG 的断言(Assertions.assertEquals/True),或
  • 引入 AssertJ/Hamcrest 增强可读性

8.2 清理与收尾:建议补 driver.quit()

当前工程是 main 串行执行,结束后没有显式关闭浏览器。建议在最后加 teardown(或者用测试框架的 @AfterAll)调用 driver.quit(),避免残留进程。

8.3 等待策略:少用 sleep,多用显式等待

Thread.sleep 是最常见的不稳定来源之一。建议把 sleep 替换成显式等待,例如:

  • 等页面关键元素可见
  • 等按钮可点击
  • 等标题文本变更完成

同时尽量避免隐式 + 显式混用带来的不确定性:要么关掉隐式等待(设为 0),要么统一以显式等待为主。

8.4 测试数据耦合:固定 blogId 会带来脆弱性

详情页用固定 blogId,如果该博客被删、或环境重置,会导致用例直接失效。更稳的策略是:

  • 先在 EditPage 创建博客
  • 从列表页进入详情页(或从 URL 拿到新 id)
  • 再做详情页的编辑/删除

这样测试数据自给自足,稳定性会高很多。

8.5 把"多个失败登录用例"参数化

LoginFail() 注释列了很多失败场景,但目前只实现一个。建议做成数据驱动:

  • 用二维数组/列表定义(username, password, expectedAlertText)
  • 循环执行,统一断言 alert 出现与提示文案

脚本量不会变多,但覆盖面会暴涨。


9. 最小运行方式

来看一个最小运行思路:

  1. 准备 Java(建议 8+)与 Maven/Gradle

  2. 引入依赖(至少需要):

    • selenium-java
    • webdrivermanager
    • commons-io(截图用)
    • slf4j-api + 一个实现(如 slf4j-simple 或 logback)
  3. 直接运行 RunBlogTests.main()

  4. 若使用 Java 原生断言,运行参数加 -ea


10. 总结

  • Utils 把 Driver、等待、截图做成统一基座,降低重复劳动
  • 用页面类封装行为,链路清晰(登录 → 列表 → 详情 → 编辑发布)
  • 针对 CodeMirror 的输入与 stale 问题给出了靠谱的工程解法(Actions + 重新定位)
相关推荐
缺点内向2 小时前
在 C# 中为 Word 段落添加制表位:使用 Spire.Doc for .NET 实现高效排版
开发语言·c#·自动化·word·.net
方芯半导体4 小时前
EtherCAT从站控制器芯片(FCE1353)与MCU(STM32H743)功能板解析!
xml·stm32·单片机·嵌入式硬件·物联网·自动化
Dola_Zou4 小时前
如何用一套加密狗方案打通 Windows、Linux 与 macOS等,零成本实现跨平台交付?
linux·安全·macos·自动化·软件工程·软件加密
晚霞的不甘4 小时前
Flutter for OpenHarmony《智慧字典》中的沉浸式学习:成语测试与填空练习等功能详解
学习·flutter·ui·信息可视化·前端框架·鸿蒙
宇钶宇夕4 小时前
CoDeSys入门实战一起学习(二十六):功能块(FBD)运算块与EN/ENO指令精讲及计数控制案例
运维·学习·自动化·软件工程
AiTEN_Robot5 小时前
AMR托盘搬运车:赋能仓库自动化托盘运输
机器人·自动化·制造
l1t5 小时前
在Windows的WSL中试用GizmoSQL UI连接GizmoSQL数据库服务器
数据库·windows·ui
天空属于哈夫克35 小时前
企微自动化控制台:跨语言调用与多进程管理的技术架构
架构·自动化·企业微信
CamilleZJ5 小时前
多端ui方案
前端·ui