做软件测试这行,几乎每天都在跟 Jenkins 打交道,跑接口、跑 UI、出报告、发通知。但用得越久越发现,Pipeline 脚本里全是 sh 'curl ...' 和 sh 'python run_test.py' 这种拼命令的写法,测试用例多了以后维护成本直线上升,新来的同事看着一坨脚本根本不敢动。后来我带着团队决定直接写 Jenkins 插件,把测试执行、报告解析、结果通知全部封装成可复用的插件步骤,这才算把测试全流程真正跑顺了。这篇内容适合正在用 Jenkins 做持续测试、又对现成插件各种不满意、想自己动手扩展的测试开发同学,我会从环境搭建讲到完整实战,最后附上我踩过的坑。
1. 测试团队为什么值得投入人力自研 Jenkins 插件
1.1 流水线里的"最后一公里"问题
先说个很典型的场景。测试平台的接口用例已经准备好了,Jenkins 流水线里要做的就是:拉代码、部署被测服务、跑接口测试、解析结果、通知群聊。前面几步有现成插件,唯独"跑接口测试"和"解析结果"这两步,绕不开手工拼命令。
很多人会想,sh 'python -m pytest tests/smoke --env=staging --report=report.json' 这样写不是挺好吗?单个项目确实没问题,但当一个流水线要跑不同类型的测试,比如接口测试、性能测试、兼容性测试,每条命令的参数都不一样,脚本就开始变得不可控了。更麻烦的是输出格式,pytest 有 JUnit 报告,JMeter 有自己的一套结果,要是内部自研的测试平台返回的又是自定义 JSON,那 Jenkins 自带的 junit 插件根本没法直接对接到 UI 上。
这就是我所谓的"最后一公里"问题:测试工具本身很强,执行引擎也没问题,但 Jenkins 和测试工具之间那层胶水始终是临时拼凑的。拼凑带来的直接后果是——换个人维护就崩,换个环境就跑不通,换一套测试工具就全得重写。
1.2 自研与选现成插件的判断标准
我见过不少团队一上来就喊"我们要自研 Jenkins 插件",但说实话,并不是所有场景都值得自研。我的判断标准很简单,就三条:
| 判断维度 | 适合自研 | 用现成插件 |
|---|---|---|
| 测试命令复杂度 | 命令多、参数多、依赖内部平台 | 只是跑一条固定命令 |
| 报告格式 | 内部自定义格式、需要深度整合到构建页 | JUnit、Allure 等标准格式 |
| 通知渠道 | 钉钉/企微等需要定制模板 | 自带邮件通知够用 |
如果只是把 JUnit 报告展示到构建页,junit 插件已经做得足够好,没必要重造轮子。但如果你们团队里有一堆自研测试平台、自定义报告格式、定制化的通知需求,那自己写插件就是长期收益更高的事情。另外还要看清楚定位:插件是给全公司用,还是只给自己团队用;只自己用,功能设计可以做得特别贴合内部流程,但代码质量和兼容性要求反而没那么高,这能省下不少工作量。
我的建议是:从一个小切口开始,比如先做一个"测试执行器",把最痛苦的那条命令封装掉,跑通了再继续加报告解析和通知能力。一上来就想做全能插件,大概率撑不到上线。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 开发环境与插件工程骨架:从零到能跑
2.1 为什么选 Java + Maven 这套技术栈
Jenkins 本身就是 Java 写的,插件开发最通用的方式就是 Java + Maven。你可能会问,能不能用 Python 写 Jenkins 插件?严格讲也行,但 Jenkins 官方扩展点 API 全是 Java 接口,用 Python 需要通过 Jython 这类桥接,调试和维护都很痛苦,团队内部用可以,做正规工程化插件还是算了。
Maven 的作用不只是管理依赖,关键是它有一整套 Jenkins 插件构建工具链。官方提供了 hpi 插件,mvn package 之后直接生成 .hpi 文件,这个文件可以直接上传进 Jenkins 插件管理页,非常省事。Jenkins 会按约定扫描 META-INF/services 里的扩展点注册信息,自动发现插件里的 @Extension 注解。
搭建环境前要确认一件事:JDK 版本要跟目标 Jenkins 版本匹配。老的 Jenkins 2.375 以下用 Java 8 或 Java 11,新版更多是 Java 11 或 17。建议做插件之前先去目标 Jenkins 实例的 /systemInfo 页面看一眼 Java 版本,别等到上传插件时报 UnsupportedClassVersionError 才回来改。
2.2 用官方 Archetype 生成工程骨架
我习惯用官方插件 Archetype 生成骨架,比手写 pom.xml 稳得多。执行命令的方式如下:
bash复制mvn archetype:generate \
-DgroupId=com.example.testflow \
-DartifactId=testflow-plugin \
-Dversion=1.0.0-SNAPSHOT \
-Dpackage=com.example.testflow \
-DarchetypeGroupId=io.jenkins.archetypes \
-DarchetypeArtifactId=jenkins-plugin-archetype \
-DarchetypeVersion=1.7
执行完之后看一下目录结构,典型的骨架大概长这样:
code复制testflow-plugin/
├── pom.xml
├── src/main/java/com/example/testflow/
│ ├── TestExecBuilder.java
│ ├── TestResultRecorder.java
│ └── ...
├── src/main/resources/com/example/testflow/
│ ├── TestExecBuilder/config.jelly
│ ├── TestResultRecorder/config.jelly
│ └── ...
└── src/test/java/com/example/testflow/
骨架里会自动带一个示例 Builder 和它的 Jelly 页面,还有基础单元测试。我第一次用的时候直接跑 mvn test,发现 JenkinsRule 会拉起一个内存版 Jenkins 来跑测试,速度有点慢,但能验证插件的基础功能,值得熟悉。
2.3 本地最小可跑环境
本地开发我一般不开完整 Jenkins 服务,而是在 pom.xml 里配置 jenkins.version,然后靠 hpi:run 启动一个临时 Jenkins 实例。
xml复制<properties>
<jenkins.version>2.375.1</jenkins.version>
<maven-hpi-plugin.version>3.41</maven-hpi-plugin.version>
</properties>
然后在项目根目录执行:
bash复制mvn hpi:run
第一次启动会下载对应版本的 Jenkins,我这边在国内网络环境下有时候会慢,耐心等一会儿就好。启动完成后浏览器访问 http://localhost:8080/jenkins/,默认账号 admin、密码 admin,进去后你的插件已经自动装好了,可以直接在新建任务里看到。
hpi:run 最大的好处是改了代码不用重新打包,重启一下临时实例就能看到效果。但要注意它跟真实的插件管理页上传安装还是有点区别,有些权限、升级场景没法覆盖。
2.4 第一次打包与安装验证
第一版跑通后,执行:
bash复制mvn clean package
产物在 target/testflow-plugin.hpi。去目标 Jenkins 的"系统管理" -> "插件管理" -> "高级设置",上传这个 .hpi 文件。
装完之后怎么确认插件生效了?两个办法:一是新建一个自由风格任务,在"构建步骤"里下拉,看有没有你插件对应的步骤名称;二是看 Jenkins 系统日志,插件加载成功时会有对应的 Initiating a plugin deployment 之类的日志。我第一次装完就是没看日志,找半天为什么页面上没出现,结果是上传了老版本没有刷新。
3. 必须吃透的核心扩展点:Builder、Recorder 与全局配置
3.1 Builder:在构建中执行测试动作
插件开发最常用的扩展点就是 jenkins.tasks.SimpleBuildStep。它的核心方法是 perform,签名有四个关键对象,理解了它们基本上就理解了一半的 Jenkins 插件开发。
java复制public class TestExecBuilder extends AbstractStepImpl implements SimpleBuildStep {
private final String environment;
private final String suite;
@DataBoundConstructor
public TestExecBuilder(String environment, String suite) {
this.environment = environment;
this.suite = suite;
}
@Override
public void perform(Run<?, ?> run, FilePath workspace, EnvVars env,
Launcher launcher, TaskListener listener) throws InterruptedException, IOException {
PrintStream logger = listener.getLogger();
logger.println("开始执行测试环境: " + environment + ", 测试集: " + suite);
EnvVars jobEnv = run.getEnvironment(listener);
String token = jobEnv.get("TEST_TOKEN", "");
int result = launcher.launch()
.cmdAsSingleString("python run_test.py --env " + environment + " --suite " + suite)
.env("TEST_TOKEN", token)
.stdout(logger)
.join();
if (result != 0) {
run.setResult(Result.FAILURE);
}
}
}
这个类要注意几个点:
@DataBoundConstructor是 Jenkins 用来从表单页面构造对象的注解,参数名必须跟config.jelly里的field属性一致,否则配置保存后回读会丢参数。run代表当前这次构建,通过它拿环境变量、设置构建结果、记录动作。launcher.launch()是 Jenkins 执行命令的标准方式,它能在 master 或 agent 节点上正确启动进程并返回退出码。listener.getLogger()返回的PrintStream会直接写到构建日志里,这是排查插件问题的重要输出渠道。
只有 @DataBoundConstructor 而不写 @DataBoundSetter,意味着所有属性都是必填的。如果有些参数是可选的,在 config.jelly 里用可选的文本框,再通过 @DataBoundSetter 给默认值,灵活度高很多。
3.2 Recorder:解析测试报告并挂到构建页
接下来是关键中的关键:测试跑完了,结果怎么展示?传统的做法是用 publisher 步骤去发 JUnit 报告,但对接自定义报告格式就需要自己写 Recorder。
Recorder 本质上还是 BuildStep,但它是在构建主逻辑完成后执行的"记录器",专门用来收集产物、解析结果、归档数据。实现它之后,除了在构建步骤里手动添加,还能被 post { always { recordTestResult() } } 这样的声明式流水线语法识别。
我实现的自定义报告解析器大概思路是这样:
java复制@Extension
public class TestResultRecorder extends Recorder implements SimpleBuildStep.Publisher {
private final String reportPath;
@DataBoundConstructor
public TestResultRecorder(String reportPath) {
this.reportPath = reportPath;
}
@Override
public void perform(Run<?, ?> run, FilePath workspace, EnvVars env,
Launcher launcher, TaskListener listener) throws InterruptedException, IOException {
FilePath reportFile = workspace.child(reportPath);
if (!reportFile.exists()) {
listener.error("报告文件不存在: " + reportPath);
run.setResult(Result.FAILURE);
return;
}
String json = reportFile.readToString();
TestReportModel model = TestReportParser.parse(json);
run.addAction(new TestReportAction(run, model));
}
}
这里 run.addAction(...) 是关键,Action 可以被 Jenkins 的页面模型读取,在构建状态页展示自定义内容。TestReportAction 里可以存储用例数、失败数、耗时等数据,然后在 summary.jelly 里渲染摘要信息,在 build.jelly 里画出更详细的报告。
解析报告这个动作比看起来复杂,因为它把测试结果从"临时文件的字符串"提升成了"Jenkins 可理解的数据结构",后续的趋势图、指标聚合都是基于这个数据模型做的。
3.3 GlobalConfiguration:管理全局配置
测试执行常常需要一个测试平台地址、一个默认超时时间、或者一个 Webhook Token。这些信息不该每个 Job 填一遍,用全局配置统一管理会舒服很多。
实现方式就是继承 GlobalConfiguration:
java复制@Extension
public class TestFlowGlobalConfig extends GlobalConfiguration {
private String platformUrl;
private String defaultTimeout;
private String webhookUrl;
public TestFlowGlobalConfig() {
load();
}
public static TestFlowGlobalConfig get() {
return ExtensionList.lookupSingleton(TestFlowGlobalConfig.class);
}
public String getPlatformUrl() {
return platformUrl;
}
@DataBoundSetter
public void setPlatformUrl(String platformUrl) {
this.platformUrl = platformUrl;
save();
}
// 其他 getter/setter 省略
}
对应的配置文件在 src/main/resources/com/example/testflow/TestFlowGlobalConfig/global.jelly 里。这样在"系统管理" -> "系统配置"里,就能看到你新增的那一段配置区域。
实际使用时要非常小心 load() 和 save() 的调用时机。load() 建议放在无参构造里,确保插件加载时能读取老的配置;save() 放在每个 @DataBoundSetter 方法里,确保页面保存时持久化。配置文件实际存储在 $JENKINS_HOME/com.example.testflow.TestFlowGlobalConfig.xml 里,这个文件格式是稳定可靠的,迁移 Jenkins 实例时也会跟着走。
3.4 Workflow 兼容:让 Pipeline 里能用你的插件
现在大部分测试团队都已经切到声明式 Pipeline 了,如果开发出的插件只能在自由风格任务里用,那推广价值会大打折扣。要让插件在 Pipeline 里能用,核心操作是给 DescriptorImpl 加上 @Symbol 注解。
java复制@Extension
public static class DescriptorImpl extends BuildStepDescriptor<Builder> {
@Override
public String getDisplayName() {
return "执行接口测试";
}
@Symbol("testFlow")
public String getFunctionName() {
return "testFlow";
}
}
加上 @Symbol 之后,Pipeline 里就可以这样写:
groovy复制stage('执行接口测试') {
steps {
testFlow(environment: 'staging', suite: 'smoke')
}
}
注意 @Symbol 的值就是 Pipeline 里的步骤名,命名要足够简洁,不能有特殊字符。而且 @Symbol 只允许在 DescriptorImpl 或者 Descriptor 上用,写错位置会直接编译报错。拿到这个能力之后,插件就不再是"自由风格专属"了,在多分支流水线里也能发挥同样作用。
4. 实战:一个"接口测试执行 + 报告解析 + 结果通知"插件
4.1 插件功能清单与总体设计
为了把前面这些扩展点串起来,我这里拆一个完整的实战案例。插件名字就叫 testflow-plugin,职责是覆盖一条接口测试的完整闭环。功能清单:
- 执行阶段:执行一张"测试配置",包含环境、用例集、超时设置。
- 报告阶段:读取测试平台返回的 JSON 报告,解析并挂到构建页。
- 通知阶段:构建结束后,把结果推送到钉钉/企业微信的 Webhook。
- 静态趋势:在 Job 页面上展示近 20 次构建的测试通过率趋势。
模块划分也比较简单,不搞花活:
code复制com.example.testflow
├── TestExecBuilder.java // 执行器
├── TestResultRecorder.java // 报告解析
├── TestNotifier.java // 通知
├── TestFlowGlobalConfig.java // 全局配置
├── model/
│ ├── TestReportModel.java // 报告模型
│ ├── TestCaseResult.java // 单条用例
│ └── TestTrendData.java // 趋势数据
├── parser/
│ └── TestReportParser.java // JSON 解析
└── action/
└── TestReportAction.java // 挂到构建页的 Action
设计的底线是:执行器只解决"命令怎么跑",报告解析只解决"结果怎么读",通知只解决"消息怎么发",三个模块之间不互相依赖。很多人写插件容易把逻辑揉在一起,最后改一个模块就得重新测三个,维护成本很高。
4.2 测试执行器实现
执行器的核心逻辑是要拿到三个信息:测试脚本怎么调、测试环境是哪个、测试集是哪个。在我的场景里,测试平台会暴露一个统一入口 run_test.py,插件只需要拼好参数再执行就够了。
java复制public class TestExecBuilder extends AbstractStepImpl implements SimpleBuildStep {
private final String environment;
private final String suite;
private final int timeoutSeconds;
@DataBoundConstructor
public TestExecBuilder(String environment, String suite, int timeoutSeconds) {
this.environment = environment;
this.suite = suite;
this.timeoutSeconds = timeoutSeconds;
}
@Override
public void perform(Run<?, ?> run, FilePath workspace, EnvVars env,
Launcher launcher, TaskListener listener) throws InterruptedException, IOException {
PrintStream logger = listener.getLogger();
long startTime = System.currentTimeMillis();
EnvVars jobEnv = run.getEnvironment(listener);
EnvVars mergedEnv = new EnvVars(jobEnv);
mergedEnv.override("TEST_ENV", environment);
mergedEnv.override("TEST_SUITE", suite);
String endpoint = TestFlowGlobalConfig.get().getPlatformUrl();
if (endpoint != null && !endpoint.isEmpty()) {
mergedEnv.override("PLATFORM_URL", endpoint);
}
int result = launcher.launch()
.cmdAsSingleString("python run_test.py")
.env(mergedEnv)
.stdout(logger)
.join();
long cost = System.currentTimeMillis() - startTime;
logger.println("测试执行结束,耗时 " + cost + " ms,退出码 " + result);
if (result != 0) {
run.setResult(Result.FAILURE);
}
}
}
这里面有个细节值得单独说:我把测试环境、测试集这些参数用环境变量传进去,而不是拼在命令行字符串里。原因很简单——测试脚本那边用 os.getenv("TEST_ENV") 读参数,比在命令行里 --env 解析省事;同时避免了命令行长度限制和不必要的 shell 转义问题。如果你通过 sh 'xxx' 方式拼参数,一旦环境名里出现空格或特殊字符,很容易翻车。
cmdAsSingleString 是怎么工作的?它把整个字符串交给目标节点的 shell 去执行。如果想避免 shell 的额外解析,可以改用 cmds(Arrays.asList(...)) 直接传参数数组,但这样无法用管线和通配符。实际经验是,测试命令确实需要 shell 能力的场景比较多,所以我保留 cmdAsSingleString。
4.3 测试报告解析与趋势展示
报告解析器面对的是一份约定好的 JSON。测试平台会把每次执行的用例结果汇总成一个 JSON 文件,格式大概是:
json复制{
"total": 120,
"passed": 112,
"failed": 6,
"skipped": 2,
"duration_ms": 85421,
"cases": [
{
"name": "test_buy_order",
"status": "failed",
"error": "assert status_code == 200, got 502",
"duration_ms": 231
}
]
}
解析器要做的事情就是把这份 JSON 转成 TestReportModel,再把 TestReportAction 挂到构建上。
java复制public class TestReportParser {
public static TestReportModel parse(String json) {
try {
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(json);
TestReportModel model = new TestReportModel();
model.setTotal(root.path("total").asInt());
model.setPassed(root.path("passed").asInt());
model.setFailed(root.path("failed").asInt());
model.setSkipped(root.path("skipped").asInt());
model.setDurationMs(root.path("duration_ms").asLong());
List<TestCaseResult> cases = new ArrayList<>();
JsonNode caseArray = root.path("cases");
if (caseArray.isArray()) {
for (JsonNode node : caseArray) {
TestCaseResult c = new TestCaseResult();
c.setName(node.path("name").asText());
c.setStatus(node.path("status").asText());
c.setError(node.path("error").asText());
c.setDurationMs(node.path("duration_ms").asLong());
cases.add(c);
}
}
model.setCases(cases);
return model;
} catch (Exception e) {
return TestReportModel.broken(e.getMessage());
}
}
}
解析完成后的关键在于 TestReportAction 的设计。我一般在这个 Action 里放一个 getModel() 方法,然后在对应的 Jelly 文件里把摘要渲染到构建页顶部。
code复制src/main/resources/com/example/testflow/action/TestReportAction/summary.jelly
summary.jelly 里可以写这样的代码片段:
xml复制<j:jelly xmlns:j="jelly:core" xmlns:d="jelly:define" xmlns:l="/lib/layout" xmlns:st="jelly:stapler" xmlns:t="/lib/hudson">
<t:summary icon="/plugin/testflow-plugin/icons/test-result.png">
通过率: ${model.passed}/${model.total}
<j:if test="${model.failed > 0}">
<span style="color:red">失败 ${model.failed} 条</span>
</j:if>
</t:summary>
</j:jelly>
很多初学者对 Jelly 感到陌生,其实它就是 Jenkins 自己的模板语言,类似于 JSP 的轻量版。最关键的是掌握三件事:${model.xxx} 取属性、<j:if> 做条件判断、<j:forEach> 做循环。只要理解了这三个,大多数页面场景都能覆盖。
至于趋势图,我的做法是把手动绘制图表的工作交给现成的 Plot 插件。插件里把每次构建的数据追加到 $JENKINS_HOME/userContent/testflow-trend.csv 里,然后 Plot 插件读取这个 CSV 绘图。比起自己在 Action 里写前端图表,这种方式成本低、稳定,而且复用性极高。
4.4 自定义结果通知(钉钉/企微)
测试跑完,结果要主动通知到人。现在很多团队的现状是:要么用 jenkins 自带的邮件通知,模板僵硬;要么在 Pipeline 里写 httpRequest 去调 Webhook,每次都要写很长的脚本。插件可以把这一步收进来。
实现一个通知器,核心是监听构建结束事件。一个简洁的做法是让通知器也作为 SimpleBuildStep.Publisher,在 post 阶段执行:
java复制public class TestNotifier extends Recorder implements SimpleBuildStep.Publisher {
private final String notifyKeyword;
@DataBoundConstructor
public TestNotifier(String notifyKeyword) {
this.notifyKeyword = notifyKeyword;
}
@Override
public void perform(Run<?, ?> run, FilePath workspace, EnvVars env,
Launcher launcher, TaskListener listener) {
PrintStream logger = listener.getLogger();
try {
Result result = run.getResult();
if (result == null) {
result = Result.SUCCESS;
}
TestReportAction action = run.getAction(TestReportAction.class);
String title;
if (action != null) {
String rate = String.format("%.1f%%", action.getModel().getPassed() * 100.0 / action.getModel().getTotal());
title = String.format("测试完成: %s 通过率 %s", result, rate);
} else {
title = "测试完成: " + result;
}
String webhook = TestFlowGlobalConfig.get().getWebhookUrl();
if (webhook == null || webhook.isEmpty()) {
logger.println("未配置 Webhook URL,跳过通知");
return;
}
sendMarkdownMessage(webhook, title, buildMessage(run));
} catch (Exception e) {
logger.println("发送通知失败: " + e.getMessage());
}
}
}
发送消息我推荐用 Java 11 之后的 java.net.http.HttpClient,不需要额外引第三方依赖。但这里有个性能隐患:perform 方法运行在执行器线程上,直接同步发 HTTP 请求会把构建停留时间拉长。我的做法是丢给一个线程池异步发送,尽量不阻塞构建收尾:
java复制private void sendMarkdownMessage(String webhook, String title, String text) {
ExecutorService pool = Executors.newSingleThreadExecutor();
pool.submit(() -> {
try {
HttpClient client = HttpClient.newHttpClient();
String payload = "{\"msgtype\":\"markdown\",\"markdown\":{\"title\":\"" + title + "\",\"text\":" + quote(text) + "}}";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(webhook))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(payload, StandardCharsets.UTF_8))
.build();
client.send(request, HttpResponse.BodyHandlers.ofString());
} catch (Exception e) {
e.printStackTrace();
}
});
pool.shutdown();
}
我建议你直接把 Webhook 地址放在全局配置里,而不是 Job 参数。因为 Webhook 属于团队级信息,放全局配置可以避免每个 Job 都填一遍,也杜绝了敏感 URL 扩散到 Jenkinsfile 里。如果插件要支持多环境的 Webhook 差异,可以做成 Job 参数覆盖全局配置的优先级,但这是后话。
4.5 完整 Pipeline 示例
插件写完了,最终在流水线里的效果是这样的:
groovy复制pipeline {
agent any
options {
timestamps()
timeout(time: 1, unit: 'HOURS')
}
environment {
// 这些环境变量可以在配置里统一管理,插件通过 EnvVars 读取
TEST_ENV = 'staging'
TEST_SUITE = 'smoke'
}
stages {
stage('准备测试环境') {
steps {
sh 'echo "准备测试环境,拉取最新容器镜像"'
}
}
stage('执行接口测试') {
steps {
// 调用我们自研的插件步骤
testFlow(environment: "staging", suite: "smoke", timeoutSeconds: 1800)
}
}
}
post {
always {
// 解析测试平台生成的 JSON 报告,挂到构建页展示
recordTestReport(reportPath: 'report/test_result.json')
}
success {
testNotifier(notifyKeyword: 'SUCCESS')
}
failure {
testNotifier(notifyKeyword: 'FAILURE')
}
}
}
我把 recordTestReport 放在 always 里,是因为即使测试本身返回非零退出码,构建被判为失败,报告仍然需要解析和展示。如果放在 success 里,一旦测试用例有失败,报告解析就不会执行,这个设计是有实际教训的。
5. 集成到测试全流程:从单体工具到全链路
5.1 与现有测试框架的对接方式
写插件不是要重新发明测试框架,而是做"适配层"。我用的适配策略是:不管底下是 pytest、JUnit、Postman/Newman 还是 JMeter,我们在执行器里统一生成一份标准 JSON 报告。这样测试平台换掉了,插件只需要改测试命令和报告生成的那一层,上层的数据模型、通知、趋势图完全不用动。
具体做法是在执行器里加一个 framework 参数,根据参数调用不同的命令。比如 framework=pytest 时执行 pytest ... --json-report=...,framework=newman 时执行 newman run ... --reporters json --reporter-json-export=...。每种框架的测试命令和参数我都维护在一个映射表里,新增框架只是加一行配置和一个转换函数的事情。
5.2 环境变量与参数传递
插件执行时经常要读取 Jenkins 内置的环境变量,比如 JOB_NAME、BUILD_URL、BUILD_NUMBER。这些值可以在 perform 里通过 run.getEnvironment(listener) 获取。我用到最多的几个:
| 环境变量 | 用途 |
|---|---|
BUILD_URL |
生成测试报告里的链接,方便点回 Jenkins 页面 |
JOB_NAME |
区分不同项目的测试配置 |
BUILD_NUMBER |
定位是哪一次构建跑的测试 |
GIT_COMMIT / GIT_BRANCH |
测试结果跟代码版本关联 |
CHANGE_ID / CHANGE_TARGET |
多分支流水线里判断是否 MR 触发的构建 |
有一种常见需求是:测试通知里要带"本次构建对应的代码提交信息"。这个 GIT_COMMIT 能拿到,但可能不是当前分支的 HEAD,在多分支流水线场景下要看 CHANGE_ID 相关的提交。我在插件里统一提供一个 buildInfoMap(run, listener) 方法,把这些常用信息封装好,尽量不让每个功能模块自己到处取环境变量,减少重复代码。
5.3 多分支流水线中的测试插件
现在很多团队用 Jenkins 的多分支流水线,每个 PR 都要跑测试。这种场景下插件要处理一个经典问题:PR 分支上跑全量测试太慢,但只跑冒烟又怕漏。
我的做法是给执行器加两个模式:full 和 changed。changed 模式通过 GIT_COMMIT 和 CHANGE_TARGET 拿到本次改动的文件列表,然后用 git diff --name-only 过滤出受影响的测试用例集合,只跑跟改动相关的用例。这个逻辑放到测试脚本里也行,但放到插件里,所有项目都能复用,不需要每个项目的 Jenkinsfile 里都写一遍。
多分支流水线还有一个很实际的坑:分支多了以后,太多并发构建会把测试资源占满。我建议在插件里做一个简单的并发控制——通过 Semaphore 或者 Jenkins 自带的 throttleConcurrentBuilds 插件来控制测试执行 stage 的最大并发数。自己实现并发控制时要非常小心,保证全局单例监牢,不要让同一个节点的并发数被重复计算。
5.4 权限与安全
插件如果只是自己团队用,安全问题容易被忽略,但一旦推广到全公司就绕不过去了。最重要的安全规范有这几条。
第一,不要在全局配置或 Job 配置里存明文密码。访问内部测试平台如果要做认证,应该使用 Jenkins 的 Credentials 插件,插件代码里通过 CredentialsProvider.lookupCredentials(...) 拿到 UsernamePasswordCredentials,密码以密文形式存在 Jenkins 凭据库中。存明文的后果就是,所有能看到 /manage 页面的人都能看到你的平台密码,这基本等于裸奔。
第二,Webhook URL 不要在 Jelly 页里直接回显。Jelly 默认会把 textarea 里的内容原样渲染,如果配置里存了带 Token 的 URL,凡是能看到配置页的人都能看到。我的做法是,在 Jelly 里只显示一个带 password 属性的输入框,或者干脆不回显完整 URL,只显示"已配置 / 未配置"。
第三,注意 Groovy 沙箱里的 @Symbol 参数校验。Pipeline 脚本里用户可能会传恶意参数,插件执行时最好对 environment、suite 这类可枚举参数做白名单校验,非法值直接报错并中止。虽然 Jenkins 本身有 Groovy 沙箱做一定保护,但你的插件一旦实现 @Symbol,就相当于在沙箱内开了一个口子,参数校验不能省。
6. 调试、发布与踩坑实录
6.1 本地调试:JenkinsRule 与远程调试
插件开发最常见的痛点是"编译能过,但运行时行为不对"。要解决这个问题,先说单元测试。官方骨架推荐用 JenkinsRule 写集成测试,它会在测试 JVM 里启动一个完整的内存版 Jenkins,然后用真实 API 跑你的插件逻辑。我第一次跑 mvn test 的时候惊讶于它会下载 Jenkins 依赖,但等一会儿跑通了就发现这东西太香了。
测试代码的典型写法:
java复制public class TestExecBuilderTest {
@Rule
public JenkinsRule j = new JenkinsRule();
@Test
public void testBuild() throws Exception {
FreeStyleProject project = j.createFreeStyleProject("test");
project.getBuildersList().add(new TestExecBuilder("staging", "smoke", 300));
FreeStyleBuild build = j.buildAndAssertSuccess(project);
System.out.println(build.getLog());
TestReportAction action = build.getAction(TestReportAction.class);
assertNotNull(action);
}
}
还有一类调试是看页面渲染,这时候 JenkinsRule 也能启动 UI,但有时候需要 mvn hpi:run 手动跑起来,人工访问页面验证。hpi:run 实例的日志级别我建议调低一点,把插件自己的 java.util.logging 或 Jenkins 的 Logger 级别设成 FINE,这样 listener.getLogger() 里的输出和框架内部日志都能在控制台看到。
如果排查比较深,比如 Jelly 页面取值异常、Stapler 绑定报错,我直接给 hpi:run 开远程调试端口:
bash复制mvn hpi:run -Djpda.port=8000
然后用 IntelliJ IDEA 连上 8000 端口断点调试。这个方式比看日志高效得多,可以直接停在 perform 方法里看对象的属性值。
6.2 打包、安装与版本升级
mvn clean package 打包时有一个坑:如果你本机 JDK 版本比 Jenkins 实例的 Java 版本新,打出来的 class 文件版本太高,上传后 Jenkins 直接报 UnsupportedClassVersionError。解决方式是 pom.xml 里配置 maven.compiler.source 和 target,让编译输出向后兼容:
xml复制<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
</properties>
还有一点必须注意:版本升级时,插件本身也要考虑升级路径。比如你把全局配置从单个 Webhook 改成支持多个 Webhook,老的 XML 配置文件里没有新字段,读取时需能优雅降级。我做法的规则是:所有字段用包装类型而非基本类型,读取时判空赋默认值,永远不要假设配置里一定存在你想要的 key。
6.3 几个常见的插件兼容性坑
第一,老版本的 Jenkins API 在升级后可能直接废弃。我有一次升级 Jenkins 之后,插件里用的 Result.getResult() 被标记废弃,虽然编译还能过,但运行时代码走到了新逻辑,行为完全变了。现在我每次升级 Jenkins 版本都会跑一遍 mvn test,重点看那些废弃 warn 日志。
第二,类冲突。如果插件里引用了跟 Jenkins 内核或其他插件重复的第三方库,比如 commons-httpclient,可能导致加载到一个意想不到的版本。我的经验是:能依赖 Jenkins 本身提供的库就尽量不额外引入版本;必须引入时最好用 provided scope,把版本交给 Jenkins 运行时管理。
第三,Jelly 和 Stapler 的取值命名。config.jelly 里的属性名必须跟 @DataBoundConstructor 的参数名严格一致,差一个字母配置就会静默丢失。这里我吃过一次亏,属性名写成了 timeOut 而 Java 字段是 timeout,页面保存后 getTimeout() 返回 null,排查了半天才发现是大小写不一致的问题。
6.4 插件维护建议
插件开发完不是结束,而是要持续维护。我现在的做法是建立一个 CHANGELOG.md,每次发布新版本都记录三个内容:新增功能、行为变更、Bug 修复。这个文件不只是给别人看的,也是给自己几个月后回顾时的索引。
版本号规范我参考语义化版本:主版本.次版本.补丁版本。主版本变更代表 API 不兼容;次版本代表新增功能;补丁代表修 bug。插件管理页对版本识别并不是自动的,需要你在上传时手动指定,版本号写错会覆盖掉线上正常运行的旧版本,这个操作要格外谨慎。
维护期最容易忽略的是对老配置的兼容性测试。每改一次字段结构,我都会拉一个旧版配置导出文件在本地跑一遍,确保升级后老 Job 还能正常读取配置。很多"升级完构建失败"的案例,都是因为新代码读取旧配置时抛了异常,而不是功能逻辑本身写错了。
最后分享一个我个人的体会:写 Jenkins 插件最容易被低估的工作量不是写 Java 代码,而是写 Jelly 页面和调试 Jenkins 内部的协程调度。如果你也是第一次做,给自己预留至少三分之一的时间专门处理页面和调试,不要按纯 Java 开发来估算排期。插件一旦跑通第一条流水线,后面维护和扩展的收益会越来越大。
