Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support declarative pipeline syntax #13

Merged
merged 7 commits into from
Jan 29, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 69 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ You can mock built-in Jenkins commands, job configurations, see the stacktrace o
# Table of Contents
1. [Usage](#usage)
1. [Configuration](#configuration)
1. [Declarative Pipeline](#declarative-pipeline)
1. [Testing Shared Libraries](#testing-shared-libraries)
1. [Note On CPS](#note-on-cps)
1. [Contributing](#contributing)
Expand All @@ -26,7 +27,7 @@ You can mock built-in Jenkins commands, job configurations, see the stacktrace o

### Add to your project as test dependency

Maven:
Maven:

```xml
<dependency>
Expand All @@ -49,7 +50,7 @@ You can write your tests in Groovy or Java 8, using the test framework you prefe
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 :

```groovy
def execute() {
node() {
Expand Down Expand Up @@ -83,9 +84,9 @@ 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")
Expand Down Expand Up @@ -126,15 +127,15 @@ You can define both environment variables and job execution parameters.
@Before
void setUp() throws Exception {
super.setUp()
// Assigns false to a job parameter ENABLE_TEST_STAGE
// 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.
You can redefine them as you wish.

### Mock Jenkins commands

Expand All @@ -157,8 +158,8 @@ You can register interceptors to mock pipeline methods, including Jenkins comman
}
```

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.
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.

Expand Down Expand Up @@ -186,7 +187,7 @@ void should_execute_without_errors() throws Exception {

```

This will check as well `mvn verify` has been called during the job execution.
This will check as well `mvn verify` has been called during the job execution.


### Check Pipeline status
Expand All @@ -206,9 +207,9 @@ To verify your pipeline is failing you need to check the status with `BasePipeli
class TestCase extends BasePipelineTest {
@Test
void check_build_status() throws Exception {
helper.registerAllowedMethod("sh", [String.class], {cmd->
helper.registerAllowedMethod("sh", [String.class], {cmd->
// cmd.contains is helpful to filter sh call which should fail the pipeline
if (cmd.contains("make")) {
if (cmd.contains("make")) {
binding.getVariable('currentBuild').result = 'FAILURE'
}
})
Expand Down Expand Up @@ -270,7 +271,7 @@ You then can go ahead and commit this change in your SCM to check in the change.

## Configuration

The abstract class `BasePipelineTest` configures the helper with useful conventions:
The abstract class `BasePipelineTest` configures the helper with useful conventions:

- It looks for pipeline scripts in your project in root (`./.`) and `src/main/jenkins` paths.
- Jenkins pipelines let you load other scripts from a parent script with `load` command.
Expand All @@ -294,7 +295,7 @@ class TestExampleJob extends BasePipelineTest {
scriptExtension = 'pipeline'
super.setUp()
}

}

```
Expand All @@ -311,15 +312,58 @@ This will work fine for such a project structure:
└── groovy
└── TestExampleJob.groovy
```
## Declarative Pipeline
There is an experimental support of declarative pipeline.
To try this feature you need to use `DeclarativePipelineTest` class instead of `BasePipelineTest`

```groovy
// 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'
}
}
}
}
```

```groovy
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 decalrative job the same way
like it was a scripted pipeline

## Testing Shared Libraries

With [Shared Libraries](https://jenkins.io/doc/book/pipeline/shared-libraries/) Jenkins lets you share common code
With [Shared Libraries](https://jenkins.io/doc/book/pipeline/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
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
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.

Expand Down Expand Up @@ -361,23 +405,23 @@ Now let's test it:
.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
Library definition is done via a fluent API which lets you set the same configurations as in
[Jenkins Global Pipeline Libraries](https://jenkins.io/doc/book/pipeline/shared-libraries/#using-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
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
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:

Expand Down Expand Up @@ -445,18 +489,18 @@ If you already fiddled with Jenkins pipeline DSL, you experienced strange errors
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](https://en.wikipedia.org/wiki/Continuation-passing_style) (CPS).
The usual errors are partly due to the
[the sandboxing Jenkins applies](https://wiki.jenkins-ci.org/display/JENKINS/Script+Security+Plugin#ScriptSecurityPlugin-GroovySandboxing)

The usual errors are partly due to the
[the sandboxing Jenkins applies](https://wiki.jenkins-ci.org/display/JENKINS/Script+Security+Plugin#ScriptSecurityPlugin-GroovySandboxing)
for security reasons, and partly due to the
[serializability Jenkins imposes](https://github.com/jenkinsci/pipeline-plugin/blob/master/TUTORIAL.md#serializing-local-variables).

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 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.
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
Expand Down
68 changes: 64 additions & 4 deletions src/main/groovy/com/lesfurets/jenkins/unit/BasePipelineTest.groovy
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package com.lesfurets.jenkins.unit

import static java.util.stream.Collectors.joining
import static org.assertj.core.api.Assertions.assertThat

import org.assertj.core.api.AbstractCharSequenceAssert

import groovy.transform.Memoized

abstract class BasePipelineTest {

PipelineTestHelper helper
Expand Down Expand Up @@ -62,7 +67,12 @@ abstract class BasePipelineTest {
}

void registerAllowedMethods() {
helper.registerAllowedMethod("build", [Map.class], null)
helper.registerAllowedMethod("build", [Map.class], {
[
getNumber:{100500},
getDescription:{"Dummy build description"}
]
})
helper.registerAllowedMethod("cron", [String.class], null)
helper.registerAllowedMethod("ws", [String.class, Closure.class], null)
helper.registerAllowedMethod("addShortText", [Map.class], null)
Expand Down Expand Up @@ -131,6 +141,7 @@ abstract class BasePipelineTest {
timeInMillis: 1,
upstreamBuilds: [],
])
binding.setVariable('env',[:])
}

/**
Expand Down Expand Up @@ -175,11 +186,16 @@ abstract class BasePipelineTest {
return helper.runScript(script)
}

@Memoized
String callStackDump() {
return helper.callStack.stream()
.map { it -> it.toString() }
.collect(joining('\n'))
}

void printCallStack() {
if (!Boolean.parseBoolean(System.getProperty("printstack.disabled"))) {
helper.callStack.each {
println it
}
println callStackDump()
}
}

Expand Down Expand Up @@ -214,4 +230,48 @@ abstract class BasePipelineTest {
assertThat(binding.getVariable('currentBuild').result).isEqualTo(status)
}

AbstractCharSequenceAssert assertCallStack() {
return assertThat(callStackDump())
}

void assertCallStackContains(String text) {
assertCallStack().contains(text)
}


/**
* Helper for adding a params value in tests
*/
void addParam(String name, Object val, Boolean overWrite = false) {
Map params = binding.getVariable('params') as Map
if (params == null) {
params = [:]
binding.setVariable('params', params)
}
if (params[name] == null || overWrite) {
params[name] = val
}
}


/**
* Helper for adding a environment value in tests
*/
void addEnvVar(String name, String val) {
Map env = binding.getVariable('env') as Map
if (env == null) {
env = [:]
binding.setVariable('env', env)
}
env[name] = val
}

void addCredential(String key, String val) {
Map credentials = binding.getVariable('credentials') as Map
if (credentials == null) {
credentials = [:]
binding.setVariable('credentials', credentials)
}
credentials[key] = val
}
}
Loading