如何對 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>
最終目錄結構如下圖所示:
然後我們就可以愉快地對 src 目錄中的程式碼進行單元測試了。
測試 vars 目錄中 Groovy 程式碼
對 vars 目錄中的指令碼的測試難點在於它強依賴於 Jenkins 的執行時環境。換句話說,你必須啟動一個 Jenkins 才能正常執行它。但是這樣就變成整合測試了。那麼怎麼實現單元測試呢?
經 Google 發現,前人已經寫了一個 Jenkins 共享庫單元測試的框架。我們拿來用就好。所謂,前人載樹,後人乘涼。
這個框架叫:Jenkins Pipeline Unit testing framework。後文簡稱“框架”。它的使用方法如下:
- 在 pom.xml 中加入依賴:
<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 的原理。由此可見,寫單元測試也是需要成本的。至於收益,仁者見仁,智者見智了。
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
}
它做了三件事情:
- 將呼叫方法名和引數寫入到 callStack 中
- 如果被呼叫方法名是被註冊了的方法,則執行該方法物件的 mock。下文會詳細介紹。
- 如果被呼叫方法沒有被註冊,則真正執行它。
需要解釋一個第二點。並不是所有的共享庫中的方法都是需要攔截的。我們只需要對我們感興趣的方法進行攔截,並實現 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)
希望被 mock 的方法能有返回值
helper.registerAllowedMethod("getDevOpsMetadata", [String.class, String.class], {
return "data from cloud"
})
後記
不得不說 Jenkins Pipeline Unit testing framework 框架的作者非常聰明。另外,此類技術不僅可以用於單元測試。理論上還可以用於 Jenkins pipeline 的零侵入攔截,以實現一些平臺級特殊的需求。