本文首发于:Jenkins 中文社区
Jenkins 共享库是除了 Jenkins 插件外,另一种扩展 Jenkins 流水线的技术。通过它,可以轻松地自定义步骤,还可以对现有的流水线逻辑进行一定程度的抽象与封装。至于如何写及如何使用它,读者朋友可以移步附录中的官方文档。
但是如何对它进行单元测试呢?共享库越来越大时,你不得不考虑这个问题。因为如果你不在早期就开始单元测试,共享库后期可能就会发展成如下图所示的“艺术品”——能工作,但是脆弱到没有人敢动。
[图片来自网络,侵权必删]
这就是代码越写越慢的原因之一。后人要不断地填前人有意无意挖的坑。
(root) +- src # Groovy source files | +- org | +- foo | +- Bar.groovy # for org.foo.Bar class +- vars | +- foo.groovy # for global 'foo' variable | +- foo.txt # help for 'foo' variable +- resources # resource files (external libraries only) | +- org | +- foo | +- bar.json # static helper data for org.foo.Bar 复制代码
以上是共享库官方文档介绍的代码仓库结构。整个代码库可以分成两部分:src 目录部分和 vars 目录部分。它们的测试脚手架的搭建方式是不一样的。
src 目录中的代码与普通的 Java 类代码本质上没有太大的区别。只不过换成了 Groovy 类。
但是 vars 目录中代码本身是严重依赖于 Jenkins 运行时环境的脚本。
接下来,分别介绍如何搭建它们的测试脚手架。
在对 src 目录中的 Groovy 代码进行单元测试前,我们需要回答一个问题:使用何种构建工具进行构建?
我们有两种常规选择:Maven 和 Gradle。本文选择的是前者。
接下来的第二个问题是,共享库源代码结构并不是 Maven 官方标准结构。下例为标准结构:
├── pom.xml └── src ├── main │ ├── java │ └── resources └── test ├── java └── resources 复制代码
因为共享库使用的 Groovy 写的,所以,还必须使 Maven 能对 Groovy 代码进行编译。
可以通过 Maven 插件: GMavenPlus 解决以上问题,插件的关键配置如下:
<configuration> <sources> <source> <!-- 指定Groovy类源码所在的目录 --> <directory>${project.basedir}/src</directory> <includes> <include>**/*.groovy</include> </includes> </source> </sources> <testSources> <testSource> <!-- 指定单元测试所在的目录 --> <directory>${project.basedir}/test/groovy</directory> <includes> <include>**/*.groovy</include> </includes> </testSource> </testSources> </configuration> 复制代码
同时,我们还必须加入 Groovy 语言的依赖:
<dependency> <groupId>org.codehaus.groovy</groupId> <artifactId>groovy-all</artifactId> <version>${groovy-all.version}</version> </dependency> 复制代码
最终目录结构如下图所示:
然后我们就可以愉快地对 src 目录中的代码进行单元测试了。
对 vars 目录中的脚本的测试难点在于它强依赖于 Jenkins 的运行时环境。换句话说,你必须启动一个 Jenkins 才能正常运行它。但是这样就变成集成测试了。那么怎么实现单元测试呢?
经 Google 发现,前人已经写了一个 Jenkins 共享库单元测试的框架。我们拿来用就好。所谓,前人载树,后人乘凉。
这个框架叫: Jenkins Pipeline Unit testing framework 。后文简称“框架”。它的使用方法如下:
<dependency> <groupId>com.lesfurets</groupId> <artifactId>jenkins-pipeline-unit</artifactId> <version>1.1</version> <scope>test</scope> </dependency> 复制代码
// test/groovy/codes/showme/pipeline/lib/SayHelloTest.groovy // 必须继承 BasePipelineTest 类 class SayHelloTest extends BasePipelineTest { @Override @Before public void setUp() throws Exception { // 告诉框架,共享库脚本所在的目录 scriptRoots = ["vars"] // 初始化框架 super.setUp() } @Test void call() { // 加载脚本 def script = loadScript("sayHello.groovy") // 运行脚本 script.call() // 断言脚本中运行了 echo 方法 // 同时参数为"hello pipeline" assertThat( helper.callStack .findAll { c -> c.methodName == 'echo' } .any { c -> c.argsToString().contains('hello pipeline') } ).isTrue() // 框架提供的方法,后面会介绍。 printCallStack() } } 复制代码
创建单元测试时,注意选择 Groovy 语言,同时类名要以 Test
结尾。
extends BasePipelineTest
和 setUp
方法抽到一个父类中,所有其它测试类继承于它。 此时,我们最简单的共享库的单元测试脚手架就搭建好了。
但是,实际工作中遇到场景并不会这么简单。面对更复杂的场景,必须了解 Jenkins Pipeline Unit testing framework 的原理。由此可见,写单元测试也是需要成本的。至于收益,仁者见仁,智者见智了。
上文中的单元测试实际上做了三件事情:
loadScript
方法由框架提供。 loadScript
方法返回加载好的脚本。 helper
是 BasePipelineTest
的一个字段。 从第三步的 helper.callStack
中,我们可以猜到第二步中的 script.call()
并不是真正的执行,而是将脚本中方法调用被写到 helper 的 callStack 字段中。从 helper 的 源码 可以确认这一点:
/** * Stack of method calls of scripts loaded by this helper */ List<MethodCall> callStack = [] 复制代码
那么,script.call() 内部是如何做到将方法调用写入到 callStack 中的呢?
一定是在 loadScript
运行过程做了什么事情,否则,script 怎么会多出这些行为。我们来看看它的底层源码:
/** * Load the script with given binding context without running, returning the Script * @param scriptName * @param binding * @return Script object */ Script loadScript(String scriptName, Binding binding) { Objects.requireNonNull(binding, "Binding cannot be null.") Objects.requireNonNull(gse, "GroovyScriptEngine is not initialized: Initialize the helper by calling init().") Class scriptClass = gse.loadScriptByName(scriptName) setGlobalVars(binding) Script script = InvokerHelper.createScript(scriptClass, binding) script.metaClass.invokeMethod = getMethodInterceptor() script.metaClass.static.invokeMethod = getMethodInterceptor() script.metaClass.methodMissing = getMethodMissingInterceptor() return script } 复制代码
gse
是 Groovy 脚本执行引擎 GroovyScriptEngine
。它在这里的作用是拿到脚本的 Class 类型,然后使用 Groovy 语言的 InvokerHelper
静态帮助类创建一个脚本对象。
接下来做的就是核心了:
script.metaClass.invokeMethod = getMethodInterceptor() script.metaClass.static.invokeMethod = getMethodInterceptor() script.metaClass.methodMissing = getMethodMissingInterceptor() 复制代码
它将脚本对象实例的方法调用都委托给了拦截器 methodInterceptor
。Groovy 对元编程非常友好。可以直接对方法进行拦截。拦截器源码如下:
/** * Method interceptor for any method called in executing script. * Calls are logged on the call stack. */ public methodInterceptor = { String name, Object[] args -> // register method call to stack int depth = Thread.currentThread().stackTrace.findAll { it.className == delegate.class.name }.size() this.registerMethodCall(delegate, depth, name, args) // check if it is to be intercepted def intercepted = this.getAllowedMethodEntry(name, args) if (intercepted != null && intercepted.value) { intercepted.value.delegate = delegate return callClosure(intercepted.value, args) } // if not search for the method declaration MetaMethod m = delegate.metaClass.getMetaMethod(name, args) // ...and call it. If we cannot find it, delegate call to methodMissing def result = (m ? this.callMethod(m, delegate, args) : delegate.metaClass.invokeMissingMethod(delegate, name, args)) return result } 复制代码
它做了三件事情:
需要解释一个第二点。并不是所有的共享库中的方法都是需要拦截的。我们只需要对我们感兴趣的方法进行拦截,并实现 mock 的效果。
写到这里,有些读者朋友可能头晕了。笔者在这里进行小结一下。
因为我们不希望共享库脚本中的依赖于 Jenkins 运行时的方法(比如拉代码的步骤)真正运行。所以,我们需要对这些方法进行 mock。在 Groovy 中,我们可以通过方法级别的拦截来实现 mock 的效果。 但是我们又不应该对共享库中所有的方法进行拦截,所以就需要我们在执行单元测试前将自己需要 mock 的方法进行注册到 helper 的 allowedMethodCallbacks
字段中。 methodInterceptor
拦截器会根据它来进行拦截。
在 BasePipelineTest
的 setUp
方法中,框架注册了一些默认方法,不至于我们要手工注册太多方法。以下是部分源码:
helper.registerAllowedMethod("sh", [Map.class], null) helper.registerAllowedMethod("checkout", [Map.class], null) helper.registerAllowedMethod("echo", [String.class], null) 复制代码
registerAllowedMethod
各参数的作用:
以上就是框架的基本原理了。接下来,再介绍几种场景。
当你的共享库脚本使用了 env
变量,可以这样测试:
binding.setVariable('env', new HashMap()) def script = loadScript('setEnvStep.groovy') script.invokeMethod("call", [k: '123', v: "456"]) assertEquals("123", ((HashMap) binding.getVariable("env")).get("k")) 复制代码
binding
由 BasePipelineTest
的一个字段,用于绑定变量。 binding
会被设置到 gse
中。
比如脚本 a 中调用到了 setEnvStep
。这时可以在 a 执行前注册 setEnvStep
方法。
helper.registerAllowedMethod("setEnvStep", [LinkedHashMap.class], null) 复制代码
helper.registerAllowedMethod("getDevOpsMetadata", [String.class, String.class], { return "data from cloud" }) 复制代码
不得不说 Jenkins Pipeline Unit testing framework 框架的作者非常聪明。另外,此类技术不仅可以用于单元测试。理论上还可以用于 Jenkins pipeline 的零侵入拦截,以实现一些平台级特殊的需求。