Jenkins Pipeline Unit testing framework
Jenkins Pipeline Unit is a testing framework for unit testing Jenkins pipelines, written in Groovy Pipeline DSL.
If you use Jenkins as your CI workhorse (like us @ lesfurets.com) and you enjoy writing pipeline-as-code, you already know that pipeline code is very powerful but can get pretty complex.
This testing framework lets you write unit tests on the configuration and conditional logic of the pipeline code, by providing a mock execution of the pipeline. You can mock built-in Jenkins commands, job configurations, see the stacktrace of the whole execution and even track regressions.
Table of Contents
- Usage
- Configuration
- Declarative Pipeline
- Testing Shared Libraries
- Note On CPS
- Contributing
- Demos and Examples
Usage
Add to your project as test dependency
Note: Starting from 1.2
artifacts are published to https://repo.jenkins-ci.org/releases
Maven:
<repositories>
<repository>
<id>jenkins-ci-releases</id>
<url>https://repo.jenkins-ci.org/releases/</url>
</repository>
...
</repositories>
<dependencies>
<dependency>
<groupId>com.lesfurets</groupId>
<artifactId>jenkins-pipeline-unit</artifactId>
<version>1.9</version>
<scope>test</scope>
</dependency>
...
</dependencies>
Gradle:
repositories {
maven { url 'https://repo.jenkins-ci.org/releases/' }
...
}
dependencies {
testImplementation "com.lesfurets:jenkins-pipeline-unit:1.9"
...
}
Start writing tests
You can write your tests in Groovy or Java 8, using the test framework you prefer.
The easiest entry point is extending the abstract class BasePipelineTest
, which initializes the framework with JUnit.
Let's say you wrote this awesome pipeline script, which builds and tests your project :
def execute() {
node() {
def utils = load "src/test/jenkins/lib/utils.jenkins"
String revision = stage('Checkout') {
checkout scm
return utils.currentRevision()
}
gitlabBuilds(builds: ["build", "test"]) {
stage("build") {
gitlabCommitStatus("build") {
sh "mvn clean package -DskipTests -DgitRevision=$revision"
}
}
stage("test") {
gitlabCommitStatus("test") {
sh "mvn verify -DgitRevision=$revision"
}
}
}
}
}
return this
Now using the Jenkins Pipeline Unit you can unit test if it does the job :
import com.lesfurets.jenkins.unit.BasePipelineTest
class TestExampleJob extends BasePipelineTest {
//...
@Test
void should_execute_without_errors() throws Exception {
def script = loadScript("job/exampleJob.jenkins")
script.execute()
printCallStack()
}
}
This test will print the call stack of the execution :
exampleJob.run()
exampleJob.execute()
exampleJob.node(groovy.lang.Closure)
exampleJob.load(src/test/jenkins/lib/utils.jenkins)
utils.run()
exampleJob.stage(Checkout, groovy.lang.Closure)
exampleJob.checkout({$class=GitSCM, branches=[{name=feature_test}], extensions=[], userRemoteConfigs=[{credentialsId=gitlab_git_ssh, url=github.com/lesfurets/JenkinsPipelineUnit.git}]})
utils.currentRevision()
utils.sh({returnStdout=true, script=git rev-parse HEAD})
exampleJob.gitlabBuilds({builds=[build, test]}, groovy.lang.Closure)
exampleJob.stage(build, groovy.lang.Closure)
exampleJob.gitlabCommitStatus(build, groovy.lang.Closure)
exampleJob.sh(mvn clean package -DskipTests -DgitRevision=bcc19744)
exampleJob.stage(test, groovy.lang.Closure)
exampleJob.gitlabCommitStatus(test, groovy.lang.Closure)
exampleJob.sh(mvn verify -DgitRevision=bcc19744)
Mock Jenkins variables
You can define both environment variables and job execution parameters.
@Override
@Before
void setUp() throws Exception {
super.setUp()
// Assigns false to a job parameter ENABLE_TEST_STAGE
binding.setVariable('ENABLE_TEST_STAGE', 'false')
// Defines the previous execution status
binding.getVariable('currentBuild').previousBuild = [result: 'UNSTABLE']
}
The test helper already provides basic variables such as a very simple currentBuild definition. You can redefine them as you wish.
Mock Jenkins commands
You can register interceptors to mock pipeline methods, including Jenkins commands, which may or may not return a result.
@Override
@Before
void setUp() throws Exception {
super.setUp()
helper.registerAllowedMethod("sh", [Map.class], {c -> "bcc19744"})
helper.registerAllowedMethod("timeout", [Map.class, Closure.class], null)
helper.registerAllowedMethod("timestamps", [], { println 'Printing timestamp' })
helper.registerAllowedMethod(method("readFile", String.class), { file ->
return Files.contentOf(new File(file), Charset.forName("UTF-8"))
})
helper.registerAllowedMethod("customMethodWithArguments", [String, int, Collection], { String stringArg, int intArg, Collection collectionArg ->
return println "executing mock closure with arguments (arguments: '${stringArg}', '${intArg}', '${collectionArg}')"
})
}
The test helper already includes some mocks, but the list is far from complete. You need to register allowed methods if you want to override these mocks and add others. Note that you need to provide a method signature and a callback (closure or lambda) in order to allow a method. Any method call which is not recognized will throw an exception.
You can take a look at the BasePipelineTest
class to have the short list of allowed methods.
Some tricky methods such as load
and parallel
are implemented directly in the helper.
If you want to override those, make sure that you extend the PipelineTestHelper
class.
Mocking readFile and fileExists
The readFile
and fileExists
steps can be mocked to return a specific result for a given file name. This can be
useful for testing pipelines for which file operations can influence subsequent steps. An example of such a testing
scenario follows:
// Jenkinsfile
node {
stage('Process output') {
if (fileExists('output') && readFile('output') == 'FAILED!!!') {
currentBuild.result = 'FAILURE'
error 'Build failed'
}
}
}
@Test
void exampleReadFileTest() {
helper.addFileExistsMock('output', true)
helper.addReadFileMock('output', 'FAILED!!!')
runScript("Jenkinsfile")
assertJobStatusFailure()
}
Mocking sh
The sh
step is used by many pipelines for a variety of tasks. It can be mocked to either (a) statically return:
- A string for standard output
- A return code
..., or (b) execute a closure that will be executed when sh
is called before returning a Map
(with stdout
and
exitValue
entries) allowing for dynamic behaviour.
Here is a sample pipeline and corresponding unit tests for each of these variants.
// Jenkinsfile
node {
stage('Mock build') {
def systemType = sh(returnStdout: true, script: 'uname')
if (systemType == 'Debian') {
sh './build.sh --release'
int status = sh(returnStatus: true, script: './test.sh')
if (status > 0) {
currentBuild.result = 'UNSTABLE'
} else {
def result = sh(returnStdout: true, script: './processTestResults.sh --platform debian')
if (!result.endsWith('SUCCESS')) {
currentBuild.result = 'FAILURE'
error 'Build failed!'
}
}
}
}
}
@Test
void debianBuildSuccess() {
helper.addShMock('uname', 'Debian', 0)
helper.addShMock('./build.sh --release', '', 0)
helper.addShMock('./test.sh', '', 0)
// Have the sh mock execute the closure when the corresponding script is run:
helper.addShMock('./processTestResults.sh --platform debian') { script ->
// Do something "dynamically" first...
return [stdout: "Executing ${script}: SUCCESS", exitValue: 0]
}
runScript("Jenkinsfile")
assertJobStatusSuccess()
}
@Test
void debianBuildUnstable() {
helper.addShMock('uname', 'Debian', 0)
helper.addShMock('./build.sh --release', '', 0)
helper.addShMock('./test.sh', '', 1)
runScript("Jenkinsfile")
assertJobStatusUnstable()
}
Note that in all cases, the script
executed by sh
must exactly match the string passed to helper.addShMock
,
including the script arguments, whitespace etc.
Analyze the mock execution
The helper registers every method call to provide a stacktrace of the mock execution.
@Test
void should_execute_without_errors() throws Exception {
runScript("Jenkinsfile")
assertThat(helper.callStack.findAll { call ->
call.methodName == "sh"
}.any { call ->
callArgsToString(call).contains("mvn verify")
}).isTrue()
assertJobStatusSuccess()
}
This will check as well mvn verify
has been called during the job execution.
Check Pipeline status
Let's say you have a simple script and you'd like to check it behaviour if a step is failing
// Jenkinsfile
// ...
node() {
git('some_repo_url')
sh "make"
}
You can mock sh
step to just update the pipeline status to FAILURE
.
To verify your pipeline is failing you need to check the status with BasePipelineTest.assertJobStatusFailure()
class TestCase extends BasePipelineTest {
@Test
void check_build_status() throws Exception {
helper.registerAllowedMethod("sh", [String.class], {cmd->
// cmd.contains is helpful to filter sh call which should fail the pipeline
if (cmd.contains("make")) {
binding.getVariable('currentBuild').result = 'FAILURE'
}
})
runScript("Jenkinsfile")
assertJobStatusFailure()
}
}
Check Pipeline exceptions
Sometimes it is useful to verify exactly that exception is thrown during the pipeline run.
For example by one of your SharedLib
module
To do so you can use org.junit.rules.ExpectedException
import org.junit.Rule
import org.junit.rules.ExpectedException
// ...
@Rule
public ExpectedException thrown = ExpectedException.none();
Here is a simple example to verify exception type and the message:
import org.junit.Rule
import org.junit.rules.ExpectedException
class TestCase extends BasePipelineTest {
@Rule
public ExpectedException thrown = ExpectedException.none();
@Test
void verify_exception() throws Exception {
thrown.expect(Exception)
thrown.expectMessage(containsString("error message"))
runScript("Jenkinsfile")
}
}
Compare the callstacks with a previous implementation
One other use of the callstacks is to check your pipeline executions for possible regressions.
You have a dedicated method you can call if you extend BaseRegressionTest
:
@Test
void testNonReg() throws Exception {
def script = loadScript("job/exampleJob.jenkins")
script.execute()
super.testNonRegression('example')
}
This will compare the current callstack of the job to the one you have in a text callstack reference file.
To update this file with new callstack, just set this JVM argument when running your tests: -Dpipeline.stack.write=true
You then can go ahead and commit this change in your SCM to check in the change.
Preserve original callstack argument references
The default behavior of the callstack capture is to clone each call's arguments to preserve their values at time of the call should those arguments mutate downstream. That is a good guard when your scripts are passing ordinary mutable variables as arguments.
However, argument types that are not Cloneable
are captured as String
values. Most of the time this is a perfect fallback. But for some complex
types, or for types that don't implement toString()
, it can be tricky
or impossible to validate the call values in a test.
Take the following simple example.
pretendArgsFromFarUpstream = [
foo: "bar",
foo2: "more bar please",
aNestedMap: [ aa: 1, bb: 2, ],
plusAList: [ 1, 2, 3, 4, ],
].asImmutable()
node() {
doSomethingWithThis(pretendArgsFromFarUpstream)
}
pretendArgsFromFarUpstream
is a type of uncloneable map and will be recorded
as a String
in the callstack. Your test may want to perform fine grained
validations via map key referencing instead of pattern matching or similar
parsing. For example,
assertEquals(arg.aNestedMap.bb, 2)
If you want to perform this kind of validation--particularly if your pipelines
pass final
and/or immutable variables as arguments--you can retain the
direct reference to the variable in the callstack by setting this switch
in your test setup.
helper.cloneArgsOnMethodCallRegistration = false
Run script inline
In case you want to have some script executed directly within a test case rather
than creating a resource file for it, loadInlineScript
and runInlineScript
can be used.
@Test
void testSomeScript() throws Exception {
def script = loadInlineScript('''
node {
stage('Build') {
sh 'make'
}
}
''')
script.execute()
printCallStack()
assertJobStatusSuccess()
}
Note that inline scripts cannot be debugged via breakpoints as there is no backing files to attach to!
Configuration
The abstract class BasePipelineTest
configures the helper with useful conventions:
- It looks for pipeline scripts in your project in root (
./.
) andsrc/main/jenkins
paths. - Jenkins pipelines let you load other scripts from a parent script with
load
command. Howeverload
takes the full path relative to the project root. The test helper mock successfully theload
command to load the scripts. To make relative paths work, you need to configure the path of the project where your pipeline scripts are, which defaults to.
. - Pipeline script extension, which defaults to jenkins (matches any
*.jenkins
file)
Overriding these default values is easy:
class TestExampleJob extends BasePipelineTest {
@Override
@Before
void setUp() throws Exception {
baseScriptRoot = 'jenkinsJobs'
scriptRoots += 'src/main/groovy'
scriptExtension = 'pipeline'
super.setUp()
}
}
This will work fine for such a project structure:
jenkinsJobs
└── src
├── main
│ └── groovy
│ └── ExampleJob.pipeline
└── test
└── groovy
└── TestExampleJob.groovy
Declarative Pipeline
There is an experimental support of declarative pipeline in 1.3
To try this feature you need to use DeclarativePipelineTest
class instead of BasePipelineTest
// Jenkinsfile
pipeline {
agent none
stages {
stage('Example Build') {
agent { docker 'maven:3-alpine' }
steps {
echo 'Hello, Maven'
sh 'mvn --version'
}
}
stage('Example Test') {
agent { docker 'openjdk:8-jre' }
steps {
echo 'Hello, JDK'
sh 'java -version'
}
}
}
}
import com.lesfurets.jenkins.unit.declarative.*
class TestExampleDeclarativeJob extends DeclarativePipelineTest {
@Test
void should_execute_without_errors() throws Exception {
def script = runScript("Jenkinsfile")
assertJobStatusSuccess()
printCallStack()
}
}
DeclarativePipelineTest
It extends BasePipelineTest
functionality so you can verify your declarative job the same way
like it was a scripted pipeline
Testing Shared Libraries
With Shared Libraries Jenkins lets you share common code
on pipelines across different repositories of your organization.
Shared libraries are configured via a settings interface in Jenkins and imported
with @Library
annotation in your scripts.
Testing pipeline scripts using external libraries is not trivial because the shared library code is checked in another repository. JenkinsPipelineUnit lets you test shared libraries and pipelines depending on these libraries.
Here is an example pipeline using a shared library:
@Library('commons')
import net.courtanet.jenkins.Utils
sayHello 'World'
node() {
stage ('Checkout') {
def utils = new Utils()
checkout "${utils.gitTools()}"
}
stage ('Build') {
sh './gradlew build'
}
stage ('Post Build') {
String json = libraryResource 'net/courtanet/jenkins/request.json'
sh "curl -H 'Content-Type: application/json' -X POST -d '$json' ${acme.url}"
}
}
This pipeline is using a shared library called commons
.
Now let's test it:
// You need to import the class first
import static com.lesfurets.jenkins.unit.global.lib.LibraryConfiguration.library
String clonePath = 'path/to/clone'
def library = library()
.name('commons')
.retriever(gitSource('git@gitlab.admin.courtanet.net:devteam/lesfurets-jenkins-shared.git'))
.targetPath(clonePath)
.defaultVersion("master")
.allowOverride(true)
.implicit(false)
.build()
helper.registerSharedLibrary(library)
runScript("job/library/exampleJob.jenkins")
printCallStack()
Notice how we defined the shared library and registered it to the helper. Library definition is done via a fluent API which lets you set the same configurations as in Jenkins Global Pipeline Libraries.
The retriever
and targetPath
fields tell the framework how to fetch the sources of the library, in which local path.
The framework comes with two naive but useful retrievers, gitSource
and localSource
.
You can write your own retriever by implementing the SourceRetriever
interface.
Note that properties defaultVersion
, allowOverride
and implicit
are optional with
default values master
, true
and false
.
Now if we execute this test, the framework will fetch the sources from the Git repository and load classes, scripts, global variables and resources found in the library. The callstack of this execution will look like the following:
Loading shared library commons with version master
libraryJob.run()
libraryJob.sayHello(World)
sayHello.echo(Hello, World.)
libraryJob.node(groovy.lang.Closure)
libraryJob.stage(Checkout, groovy.lang.Closure)
Utils.gitTools()
libraryJob.checkout({branch=master})
libraryJob.stage(Build, groovy.lang.Closure)
libraryJob.sh(./gradlew build)
libraryJob.stage(Post Build, groovy.lang.Closure)
libraryJob.libraryResource(net/courtanet/jenkins/request.json)
libraryJob.sh(curl -H 'Content-Type: application/json' -X POST -d '{"name" : "Ben"}' http://acme.com)
Library Source Retrievers
There are a few types of SourceRetriever
implementation in addition to previously
discribed GitSource
you can use for different applications
ProjectSource Retriever
ProjectSource
retriever is useful if you write tests for the library itself.
So it lets you to load the library files directly from project root folder (where src
, vars
, ... are loacted)
$ tree -L 1 .
.
├── resources
├── src
└── vars
└── test
└── build.gradle
Then you need you can use projectSource
to point library files location
projectSource()
with no args looking for files in project root.defaultVersion('<notNeeded>')
means you can load it in pipelines usingcommons@master
orcommons@features
which would use the same code base
// TestCase file
// you need to import static method
import static com.lesfurets.jenkins.unit.global.lib.ProjectSource.projectSource
class TestCase extends BasePipelineTest {
...
void setUp() throws Exception {
...
def library = library().name('commons')
.defaultVersion('<notNeeded>')
.allowOverride(true)
.implicit(true)
.targetPath('<notNeeded>')
.retriever(projectSource())
.build()
helper.registerSharedLibrary(library)
...
}
...
}
LocalSource Retriever
LocalSource
retriever is useful if you want to verify how well your library integrates
with the pipelines. For example you may use pre-copied library files of different versions.
import static com.lesfurets.jenkins.unit.global.lib.LocalSource.localSource
class TestCase extends BasePipelineTest {
...
void setUp() throws Exception {
...
def library = library().name('commons')
.defaultVersion("master")
.allowOverride(true)
.implicit(false)
.targetPath('<notNeeded>')
.retriever(localSource('/var/tmp/'))
.build()
helper.registerSharedLibrary(library)
}
...
The retriever assumes that library files are located at
/var/tmp/commons@master
folder
$ tree -L 1 /var/tmp/commons@master
/var/tmp/commons@master
├── resources
├── src
└── vars
Loading library dynamically
There is a partial support of dynamic library loading. It does't implement all the features, however sometimes it could be useful.
Pipeline example:
def lib = library 'commons'
// by the moment you call library method it enables vars
sayHello 'World'
// creates an instance of a library's class
def utils = net.courtanet.jenkins.Utils.new()
Test class example:
String clonePath = 'path/to/clone'
def library = library()
.name('commons')
.retriever(gitSource('git@gitlab.admin.courtanet.net:devteam/lesfurets-jenkins-shared.git'))
.targetPath(clonePath)
.defaultVersion("master")
.allowOverride(true)
.implicit(false)
.build()
helper.registerSharedLibrary(library)
// Registration fo pipeline method 'library'
// should be after you register the shared library
// so unfortenatly you cannot move it to the super class
helper.registerAllowedMethod("library", [String.class], {String expression ->
helper.getLibLoader().loadLibrary(expression)
println helper.getLibLoader().libRecords
return new LibClassLoader(helper,null)
})
loadScript("job/library/exampleJob.jenkins")
printCallStack()
Library global variables accepting library class instances as arguments: troubleshooting
You might have a library defining global variables that implement custom steps accepting library class instances as arguments. For example consider the following library class and global variable.
package org.test
class Monster1 {
String moniker
Monster1(String m) {
moniker = m
}
}
import org.test.Monster1
void call(Monster1 m1) {
echo "$m1.moniker is always very scary"
}
Your pipeline uses both as follows.
vampire = new Monster1("Dracula")
monster1(vampire)
//Expect "Dracula is always very scary"
If this does not yield the expected output but instead throws a
MissingMethodException
with the cause No signature of method: JENKINSFILE.monster1() is applicable for argument types: (org.test.Monster1) values: [org.test.Monster1@45f50182]
you may need to disable library class
preload in your testing. You can do so in your test setup via the following
switch.
helper.libLoader.preloadLibraryClasses = false
You may need to do this for on a test-by-test basis as disabling class preload
can cause problems in other use cases. For example, when you have library
classes that require access to the env
global.
Note on CPS
If you already fiddled with Jenkins pipeline DSL, you experienced strange errors during execution on Jenkins. This is because Jenkins does not directly execute your pipeline in Groovy, but transforms the pipeline code into an intermediate format in order to run Groovy code in Continuation Passing Style (CPS).
The usual errors are partly due to the the sandboxing Jenkins applies for security reasons, and partly due to the serializability Jenkins imposes.
Jenkins requires that at each execution step, the whole script context is serializable, in order to stop and resume the job execution. To simulate this aspect, CPS versions of the helpers transform your scripts into the CPS format and check if at each step your script context is serializable.
To use this experimental feature, you can use the abstract class BasePipelineTestCPS
instead of BasePipelineTest
.
You may see some changes in the call stacks that the helper registers.
Note also that the serialization used to test is not the same as what Jenkins uses.
You may find some incoherence in that level.
Contributing
JenkinsPipelineUnit aims to help devops code and test Jenkins pipelines with a shorter development cycle. It addresses some of the requirements traced in JENKINS-33925. If you are willing to contribute please don't hesitate to discuss in issues and open a pull-request.
Demos and Examples
URL | Frameworks and Tools | Test Subject | Test Layers |
---|---|---|---|
https://github.com/macg33zr/pipelineUnit | Spock, Gradle(Groovy) | JenkinsFile, scripted pipeline, SharedLibrary | UnitTest |
https://github.com/mkobit/jenkins-pipeline-shared-library-example | Spock, Gradle (Kotlin), Junit | SharedLibrary | Integration, Unit |
https://github.com/stchar/pipeline-sharedlib-testharness | Junit, Gradle(Groovy) | SharedLibrary | Integration, Unit |
https://github.com/stchar/pipeline-dsl-seed | Junit, Spock, Gradle(Groovy) | scripted pipeline | Integration(jobdsl), Unit |
https://github.com/SpencerMalone/JenkinsPipelineIntegration | Spock, Gradle(Groovy) | SharedLibrary | Integration |
https://github.com/venosov/jenkins-pipeline-shared-library-example-victor | Junit, Gradle(Kotlin) | SharedLibrary | Unit |