一、项目概述
本项目是一个基于 Spring Boot 3.5、Playwright 和 Groovy 的浏览器自动化测试框架。项目实现了动态脚本执行机制,支持通过 Groovy 脚本灵活定义和执行浏览器自动化测试流程。
1、Playwright是什么,有什么用
Playwright 是由 Microsoft 开发的一个强大的浏览器自动化测试工具,支持 Chromium、Firefox 和 WebKit 三大浏览器引擎。
核心特性:
- 跨浏览器支持:统一 API 操作 Chrome、Firefox、Safari 等主流浏览器
- 无头模式:支持无界面运行,适合 CI/CD 环境
- 自动等待:智能等待元素就绪,无需手动添加 sleep
- 多语言支持:JavaScript/TypeScript、Python、C#、Java
- 强大选择器:支持 CSS、XPath、文本选择器等多种定位方式
- 网络拦截:可以拦截、修改网络请求和响应
- 模拟用户行为:支持鼠标、键盘、触摸等操作
主要用途:
- Web 自动化测试:自动化执行功能测试、回归测试
- 端到端测试:模拟真实用户场景进行测试
- 网页爬虫:抓取动态渲染的网页内容
- 性能测试:分析页面加载性能
- 浏览器自动化:自动完成重复性任务
2、Groovy是什么,有什么用
Groovy 是一种基于 JVM 的动态编程语言,兼具 Python、Ruby 和 Smalltalk 的特性,同时保持与 Java 的完全兼容。
核心特性:
- 语法简洁:比 Java 更简洁的语法,支持闭包、动态类型
- 完全兼容 Java:可以直接调用 Java 类和库
- 脚本语言特性:支持脚本式编程,无需编译即可运行
- 元编程能力:支持运行时修改类行为
- DSL 支持:易于构建领域特定语言
主要用途:
- 脚本编写:快速编写自动化脚本和工具
- 测试框架:常用作测试脚本语言(如 Spock 测试框架)
- 动态配置:运行时动态加载和执行代码
- 构建工具:Gradle 构建工具使用 Groovy 作为脚本语言
- 快速原型开发:快速验证想法和原型
在本项目中的应用:
- 浏览器自动化测试脚本可以动态编写和修改
- 无需重新编译 Java 代码即可更新测试逻辑
- 支持热更新,脚本修改后立即生效
3、Playwright + Groovy 解决了什么痛点
传统 Playwright 测试的痛点:
- 代码固化:测试逻辑硬编码在 Java 代码中,每次新增测试场景都需要修改代码并重新部署
- 代码膨胀:多个需求的测试代码堆积导致项目体积庞大,维护困难
- 灵活性差:测试脚本无法动态调整,难以快速响应业务变化
Playwright + Groovy 的解决方案:
- 脚本解耦:将测试逻辑抽取为独立的 Groovy 脚本,与主应用代码分离
- 动态加载:测试脚本可从文件、数据库等多种来源动态加载,无需重启服务
- 热更新支持:脚本修改后立即生效,无需重新编译部署
- 扩展性强:新增测试场景只需编写新脚本,不影响现有代码结构
二、快速开始
2.1 技术栈
- JDK 版本: JDK17+
- Spring Boot: 3.5.14
- Playwright: 1.59.0
- Groovy: 4.0.31
2.2 项目依赖
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| <properties> <java.version>17</java.version> <playwright.version>1.59.0</playwright.version> <groovy.version>4.0.31</groovy.version> </properties> <dependencies>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency>
<dependency> <groupId>com.microsoft.playwright</groupId> <artifactId>playwright</artifactId> <version>${playwright.version}</version> </dependency>
<dependency> <groupId>org.apache.groovy</groupId> <artifactId>groovy-all</artifactId> <version>${groovy.version}</version> <type>pom</type> </dependency>
</dependencies>
|
2.3 脚本管理核心模块
2.3.1 ScriptProvider脚本提供者接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
|
@AllArgsConstructor @NoArgsConstructor @Data public class ScriptContent {
private String content; private long lastModified; private SourceType sourceType;
public boolean isNewerThan(ScriptContent other) { return this.lastModified > other.lastModified; } }
public interface ScriptProvider { ScriptContent getScript(String scriptName); boolean supports(String scriptName); default void refresh(String scriptName) {} }
|
- 目前只实现了
FileScriptProvider文件提供者,可以保留一下默认的脚本文件
- 可以实现这个接口扩展多种脚本来源,比如从数据库查询等等
2.3.2 扩展文件来源脚本FileScriptProvider
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| @Slf4j @Component public class FileScriptProvider implements ScriptProvider { private final String scriptBasePath;
private final ResourcePatternResolver resourceResolver = new PathMatchingResourcePatternResolver();
public FileScriptProvider(@Value("${script.file.path:classpath:scripts/}") String path) { this.scriptBasePath = path; }
@Override public ScriptContent getScript(String scriptName) { if (!supports(scriptName)) return null; String location = scriptBasePath + scriptName + ".groovy"; Resource resource = resourceResolver.getResource(location); if (!resource.exists()) { log.warn("文件脚本不存在: {}", location); return null; } try { String content = StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8); long lastModified = resource.getFile().lastModified(); return new ScriptContent(content, lastModified, SourceType.FILE); } catch (IOException e) { throw new RuntimeException("读取文件脚本失败", e); } }
@Override public boolean supports(String scriptName) { return !scriptName.startsWith("db:"); } }
|
文件脚本提供者,从 classpath:scripts/ 目录加载 .groovy 脚本文件。
2.3.3 脚本管理器 GroovyScriptManager
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78
| @Component public class GroovyScriptManager {
private final GroovyClassLoader classLoader = new GroovyClassLoader(); private final Map<String, Class<?>> scriptCache = new ConcurrentHashMap<>(); private final Map<String, ScriptContent> contentCache = new ConcurrentHashMap<>(); private final List<ScriptProvider> providers;
public GroovyScriptManager(List<ScriptProvider> providers) { this.providers = providers; }
public Object execute(String scriptName, String methodName, Object... args) { Class<?> clazz = getScriptClass(scriptName); Object instance; try { instance = clazz.getDeclaredConstructor().newInstance(); Method method = findMethod(clazz, methodName, args); return method.invoke(instance, args); } catch (Exception e) { throw new RuntimeException("执行脚本失败: " + scriptName, e); } }
private Class<?> getScriptClass(String scriptName) { ScriptContent latest = fetchLatestContent(scriptName); ScriptContent cached = contentCache.get(scriptName); if (cached == null || !cached.isNewerThan(latest)) { Class<?> clazz = compile(scriptName, latest.getContent()); scriptCache.put(scriptName, clazz); contentCache.put(scriptName, latest); } return scriptCache.get(scriptName); }
private ScriptContent fetchLatestContent(String scriptName) { for (ScriptProvider provider : providers) { if (provider.supports(scriptName)) { ScriptContent content = provider.getScript(scriptName); if (content != null) return content; } } throw new IllegalArgumentException("未找到脚本: " + scriptName); }
private Class<?> compile(String scriptName, String content) { try { return classLoader.parseClass(content, scriptName); } catch (GroovyRuntimeException e) { throw new RuntimeException("Groovy 编译错误: " + scriptName + " - " + e.getMessage(), e); } }
private Method findMethod(Class<?> clazz, String methodName, Object[] args) throws NoSuchMethodException { for (Method m : clazz.getMethods()) { if (m.getName().equals(methodName) && m.getParameterCount() == args.length) { return m; } } throw new NoSuchMethodException(methodName); }
public void refresh(String scriptName) { scriptCache.remove(scriptName); contentCache.remove(scriptName); } }
|
核心功能:
- 支持多脚本源(文件、数据库等)
- 脚本内容自动检测更新,无需重启
- 基于
ConcurrentHashMap 的线程安全缓存
关键方法:
| 方法 |
功能 |
execute(scriptName, methodName, args) |
执行指定脚本的指定方法 |
refresh(scriptName) |
手动刷新指定脚本缓存 |
2.4 实战示例
脚本规范
- 脚本文件放置在
src/main/resources/scripts/ 目录
- 文件名即为脚本名(不含
.groovy 后缀)
- 脚本类名需与文件名一致
示例脚本:TestLoginScript.groovy
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| class TestLoginScript { private static final String LOGIN_URL = "http://example.com/login" boolean testLogin(String username, String password) { try (Playwright playwright = Playwright.create(); Browser browser = playwright.chromium().launch(new BrowserType.LaunchOptions() .setHeadless(false) .setSlowMo(50))) { Page page = browser.newPage() page.navigate(LOGIN_URL) page.fill("input[placeholder='用户名']", username) page.fill("input[placeholder='密码']", password) page.click(".login-button") return true; } } }
|
执行脚本示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| @Slf4j @Service @RequiredArgsConstructor public class TestServiceImpl implements ITestService {
private final GroovyScriptManager scriptManager; private static final String LOGIN_SCRIPT_NAME = "TestLoginScript";
public boolean testGroovyLogin(TestLoginDto dto) { try { log.info("开始执行 Groovy 登录脚本,用户名: {}, 脚本: {}", dto.getUsername(), LOGIN_SCRIPT_NAME); Object result = scriptManager.execute(LOGIN_SCRIPT_NAME, "testLogin", dto.getUsername(), dto.getPassword()); boolean success = (Boolean) result; log.info("Groovy 脚本执行完成,结果: {}", success); return success; } catch (ServiceException e) { log.error(e.getMessage(), e); throw e; } catch (Exception e) { log.error("执行 Groovy 登录脚本失败", e); throw new ServiceException("执行 Groovy 登录脚本失败"); } } }
|
- 注意:首次运行时 Playwright 会自动下载浏览器驱动
三、扩展说明
添加新的脚本来源
实现 ScriptProvider 接口即可扩展新的脚本来源,例如从数据库加载脚本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @Component public class DatabaseScriptProvider implements ScriptProvider { @Override public ScriptContent getScript(String scriptName) { if (!supports(scriptName)) return null; String content = scriptRepository.findByKey(scriptName.substring(3)); return new ScriptContent(content, System.currentTimeMillis(), SourceType.DB); } @Override public boolean supports(String scriptName) { return scriptName.startsWith("db:"); } }
|
使用时脚本名前缀为 db:,如 db:loginScript。
四、总结
本项目成功实现了 Spring Boot + Playwright + Groovy 的技术整合,构建了一个灵活、高效的浏览器自动化测试框架。
核心价值
- 动态脚本执行:通过 Groovy 脚本实现测试逻辑的动态加载和执行,无需修改 Java 代码即可扩展测试场景。
- 自动化测试能力:基于 Playwright 实现跨浏览器自动化测试,支持无头模式运行,适合 CI/CD 集成。
技术亮点
- 脚本热更新:脚本内容变更后自动检测并重新编译,无需重启服务。
- 多脚本源支持:通过
ScriptProvider 接口可扩展多种脚本来源(文件、数据库等)。
- 线程安全设计:使用
ConcurrentHashMap 保证脚本缓存的线程安全性。
- 优雅的异常处理:统一的异常处理机制,提供清晰的错误信息。
适用场景
- Web 自动化测试:适用于需要频繁更新测试用例的场景。
- 端到端测试:模拟真实用户操作流程进行完整业务链路测试。
- 自动化爬虫:动态加载爬虫脚本,灵活应对目标网站变化。
- 回归测试:快速执行回归测试用例,确保代码变更不影响现有功能。