个人测试项目:一个可跑、可扩、可复用的 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() 的逻辑:
-
读取列表第一条博客的:
- 标题
title - 发布时间
pushTime - 摘要内容
content - 按钮文本
button
- 标题
-
断言:
- 标题/时间/摘要非空
- 按钮文本等于
"查看全文>>"
-
点击"查看全文>>"进入详情页
-
在详情页再取一次标题
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():编辑标题并验证确实变更
它做的动作链是:
- 读取编辑前标题
titleBefore - 点击"编辑"
- 清空标题输入框
- 用时间戳生成一个新标题(避免冲突)
- 点击保存提交
- 再读取标题
titleAfter - 断言
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() 的解决策略:
-
动态生成标题(时间戳),输入到
#title -
定位 CodeMirror 内部的
pre节点 -
用
Actions(driver).doubleClick(ele).sendKeys(Keys.DELETE).perform()清空 -
重新定位一次元素 (关键!)拿到
ele2 -
用
Actions(driver).click(ele2).sendKeys("...").perform()输入内容 -
点击提交发布
-
发布后会跳转列表页:
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() 的执行顺序:
LoginPage:检查登录页 → 成功登录ListPage:校验列表并跳转详情DetailPage:校验详情页 → 编辑标题 → 删除测试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. 最小运行方式
来看一个最小运行思路:
-
准备 Java(建议 8+)与 Maven/Gradle
-
引入依赖(至少需要):
selenium-javawebdrivermanagercommons-io(截图用)slf4j-api+ 一个实现(如slf4j-simple或 logback)
-
直接运行
RunBlogTests.main() -
若使用 Java 原生断言,运行参数加
-ea
10. 总结
- 用
Utils把 Driver、等待、截图做成统一基座,降低重复劳动 - 用页面类封装行为,链路清晰(登录 → 列表 → 详情 → 编辑发布)
- 针对 CodeMirror 的输入与 stale 问题给出了靠谱的工程解法(Actions + 重新定位)