如何對 Jenkins 共享庫進行單元測試

本文首發於:Jenkins 中文社區java

Jenkins 共享庫是除了 Jenkins 插件外,另外一種擴展 Jenkins 流水線的技術。經過它,能夠輕鬆地自定義步驟,還能夠對現有的流水線邏輯進行必定程度的抽象與封裝。至於如何寫及如何使用它,讀者朋友能夠移步附錄中的官方文檔。git

對共享庫進行單元測試的緣由

可是如何對它進行單元測試呢?共享庫愈來愈大時,你不得不考慮這個問題。由於若是你不在早期就開始單元測試,共享庫後期可能就會發展成以下圖所示的「藝術品」——能工做,可是脆弱到沒有人敢動。github

[圖片來自網絡,侵權必刪]編程

這就是代碼越寫越慢的緣由之一。後人要不斷地填前人有意無心挖的坑。json

共享庫單元測試搭建

共享庫官方文檔介紹的代碼倉庫結構

(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 運行時環境的腳本。框架

接下來,分別介紹如何搭建它們的測試腳手架。ide

測試 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>

最終目錄結構以下圖所示:

而後咱們就能夠愉快地對 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 的零侵入攔截,以實現一些平臺級特殊的需求。

附錄

做者:翟志軍

相關文章
相關標籤/搜索