如何对 Jenkins 共享库进行单元测试

栏目: 编程工具 · 发布时间: 5年前

内容简介:本文首发于:Jenkins 中文社区Jenkins 共享库是除了 Jenkins 插件外,另一种扩展 Jenkins 流水线的技术。通过它,可以轻松地自定义步骤,还可以对现有的流水线逻辑进行一定程度的抽象与封装。至于如何写及如何使用它,读者朋友可以移步附录中的官方文档。但是如何对它进行单元测试呢?共享库越来越大时,你不得不考虑这个问题。因为如果你不在早期就开始单元测试,共享库后期可能就会发展成如下图所示的“艺术品”——能工作,但是脆弱到没有人敢动。

本文首发于:Jenkins 中文社区

如何对 Jenkins 共享库进行单元测试

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 代码

在对 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>

复制代码

最终目录结构如下图所示:

如何对 Jenkins 共享库进行单元测试

然后我们就可以愉快地对 src 目录中的代码进行单元测试了。

测试 vars 目录中 Groovy 代码

对 vars 目录中的脚本的测试难点在于它强依赖于 Jenkins 的运行时环境。换句话说,你必须启动一个 Jenkins 才能正常运行它。但是这样就变成集成测试了。那么怎么实现单元测试呢?

经 Google 发现,前人已经写了一个 Jenkins 共享库单元测试的框架。我们拿来用就好。所谓,前人载树,后人乘凉。

这个框架叫: Jenkins Pipeline Unit testing framework 。后文简称“框架”。它的使用方法如下:

  1. 在 pom.xml 中加入依赖:
<dependency>
    <groupId>com.lesfurets</groupId>
    <artifactId>jenkins-pipeline-unit</artifactId>
    <version>1.1</version>
    <scope>test</scope>
</dependency>

复制代码
  1. 写单元测试
// 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 结尾。

  1. 改进 以上代码是为了让读者对共享库脚本的单元测试有更直观的理解。实际工作中会做一些调整。我们会将 extends BasePipelineTestsetUp 方法抽到一个父类中,所有其它测试类继承于它。

此时,我们最简单的共享库的单元测试脚手架就搭建好了。

但是,实际工作中遇到场景并不会这么简单。面对更复杂的场景,必须了解 Jenkins Pipeline Unit testing framework 的原理。由此可见,写单元测试也是需要成本的。至于收益,仁者见仁,智者见智了。

Jenkins Pipeline Unit testing framework 原理

上文中的单元测试实际上做了三件事情:

  1. 加载目标脚本, loadScript 方法由框架提供。
  2. 运行脚本, loadScript 方法返回加载好的脚本。
  3. 断言脚本中的方法是否有按预期执行, helperBasePipelineTest 的一个字段。

从第三步的 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
    }

复制代码

它做了三件事情:

  1. 将调用方法名和参数写入到 callStack 中
  2. 如果被调用方法名是被注册了的方法,则执行该方法对象的 mock。下文会详细介绍。
  3. 如果被调用方法没有被注册,则真正执行它。

需要解释一个第二点。并不是所有的共享库中的方法都是需要拦截的。我们只需要对我们感兴趣的方法进行拦截,并实现 mock 的效果。

写到这里,有些读者朋友可能头晕了。笔者在这里进行小结一下。

因为我们不希望共享库脚本中的依赖于 Jenkins 运行时的方法(比如拉代码的步骤)真正运行。所以,我们需要对这些方法进行 mock。在 Groovy 中,我们可以通过方法级别的拦截来实现 mock 的效果。 但是我们又不应该对共享库中所有的方法进行拦截,所以就需要我们在执行单元测试前将自己需要 mock 的方法进行注册到 helper 的 allowedMethodCallbacks 字段中。 methodInterceptor 拦截器会根据它来进行拦截。

BasePipelineTestsetUp 方法中,框架注册了一些默认方法,不至于我们要手工注册太多方法。以下是部分源码:

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"))

复制代码

bindingBasePipelineTest 的一个字段,用于绑定变量。 binding 会被设置到 gse 中。

调用其它共享库脚本

比如脚本 a 中调用到了 setEnvStep 。这时可以在 a 执行前注册 setEnvStep 方法。

helper.registerAllowedMethod("setEnvStep", [LinkedHashMap.class], null)

复制代码

希望被 mock 的方法能有返回值

helper.registerAllowedMethod("getDevOpsMetadata", [String.class, String.class], {
    return "data from cloud"
})

复制代码

后记

不得不说 Jenkins Pipeline Unit testing framework 框架的作者非常聪明。另外,此类技术不仅可以用于单元测试。理论上还可以用于 Jenkins pipeline 的零侵入拦截,以实现一些平台级特殊的需求。


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Web Security Testing Cookbook

Web Security Testing Cookbook

Paco Hope、Ben Walther / O'Reilly Media / 2008-10-24 / USD 39.99

Among the tests you perform on web applications, security testing is perhaps the most important, yet it's often the most neglected. The recipes in the Web Security Testing Cookbook demonstrate how dev......一起来看看 《Web Security Testing Cookbook》 这本书的介绍吧!

HTML 压缩/解压工具
HTML 压缩/解压工具

在线压缩/解压 HTML 代码

CSS 压缩/解压工具
CSS 压缩/解压工具

在线压缩/解压 CSS 代码

随机密码生成器
随机密码生成器

多种字符组合密码