Parent for Spock specs that test Jenkins pipeline code.
Such specifications need all of the Pipeline symbols that might exist at runtime (e.g. node, sh, echo, ws, stash, etc) to exist, or else the pipeline code won't run.
This class ensures that all pipeline extension points exist as Spock Mock objects so that the calls will succeed and that interactions can be inspected, stubbed, and verified. Just extend this instead of the regular Specification and test your pipeline scripts. You can access a Spock mock for any pipeline step that would exist by using getPipelineMock(String).
For example, the following Groovy class:
class MyJenkinsPipelineHelper {
public Map helloNode(String _label, Closure _body) {
return node( _label ) {
echo( "Hello from a [${_label}] node!" )
_body()
echo( "Goodbye from a [${_label}] node!" )
}
}
}
can be tested like this:
public class MyJenkinsPipelineHelperSpec extends JenkinsPipelineSpecification {
def "helloNode" () {
given:
MyJenkinsPipelineHelper myVar = new MyJenkinsPipelineHelper()
when:
myVar.helloNode( "nodeType" ) {
echo( "inside node" )
}
then:
1 * getPipelineMock( "node" )("nodeType", _)
1 * getPipelineMock( "echo" )("Hello from a [nodeType] node!")
1 * getPipelineMock( "echo" )("Goodbye from a [nodeType] node!")
1 * getPipelineMock( "echo" )("inside node")
}
}
loadPipelineScriptForTest(java.lang.String)
If you have pipeline scripts (i.e. whole Jenkinsfiles) in src/main/resources, you can load them as Scripts and run them during unit tests with loadPipelineScriptForTest(java.lang.String). Just call Script.run on the returned object, e.g.
CoolJenkinsfile.groovy
node( "legacy" ) {
echo( "hello world" )
}
CoolJenkinsfileSpec.groovy
def "Jenkinsfile"() {
setup:
def Jenkinsfile = loadPipelineScriptForTest("com/homeaway/CoolJenkinsfile.groovy")
when:
Jenkinsfile.run()
then:
1 * getPipelineMock("node")("legacy", _)
1 * getPipelineMock("echo")("hello world")
}
Mock pipeline steps are available at getPipelineMock("stepName")
.
You can verify interactions with them and stub them:
...
then:
1 * getPipelineMock( "echo" )( "hello world" )
1 * getPipelineMock( "sh" )( [returnStdout: true, script: "echo hi"] ) >> "hi"
...
A quirk of Groovy's implementation of Closures and the choice of Closures as the interface
to mock to provide pipeline step mocks changes Spock's argument-capture idiom:
When a pipeline step is called with more than one argument, captured arguments
are wrapped in an extra layer of Object[]
and must be unwrapped.
def "single-argument capture" () {
when:
getPipelineMock("echo")("hello")
then:
1 * getPipelineMock("echo")(_) >> { _arguments ->
assert "hello" == _arguments[0]
}
}
def "multi-argument capture" () {
when:
getPipelineMock("stage")("label") {
echo("body")
}
then:
1 * getPipelineMock("stage")(_) >> { _arguments ->
def args = _arguments[0]
assert "label" == args[0]
}
}
Method calls on variables are available as mocks at getPipelineMock("VariableName.methodName")
.
Because it can be impossible to tell which methods will be valid on a given variable at runtime all method calls on variables are allowed during tests and captured by a Spock Mock.
You can expect and stub interactions with these mocks normally:
...
then:
1 * getPipelineMock( "docker.inside" )( "maven:latest" )
1 * getPipelineMock( "JenkinsPluginSapling.hello" )( [who: "bob", greeting: "hi"] ) >> "hi bob"
...
Implementation Note: If you access getPipelineMock("VariableName")
you will find a PipelineVariableImpersonator that will create mocks on-the-fly (if necessary) for every method called on it.
You shouldn't need to interact directly with these objects.
All property access attempts on pipeline variables will be forwarded to getPipelineMock("VariableName.getProperty")(propertyName)
.
You can expect and stub these accesses normally:
...
when:
String someEnvvar = env.someEnvvar
String otherEnvvar = env.otherEnvvar
then:
1 * getPipelineMock( "env.getProperty" )( "someEnvvar" )
1 * getPipelineMock( "env.getProperty" )( "otherEnvvar" ) >> "expected"
expect:
"expected" == otherEnvvar
...
Exception: You cannot stub .toString()
of the Mock Pipeline Variable itself; getPipelineMock("someVar.toString") >> "hello"
won't work,
meaning if you've written String s = "${someVar} something"
in your code-under-test, that can't be correctly stubbed.
The immediate cause is that a useful PipelineVariableImpersonator.toString is already defined on PipelineVariableImpersonator, for understanding its presence
in log messages. However, due to GROOVY-3493, we can't "fix" this with metaprogramming at test-time either.
Not every pipeline variable needs to be mocked. Sometimes you might just need to define a dummy value. You can just do this in your test class if that's where you use the variable. If the variable is used in a pipeline script that's being tested (as may be the case for variables that Jenkins automatically sets for you), set the variable on the script's Binding:
setup:
def Jenkinsfile = loadPipelineScriptForTest("Jenkinsfile")
Jenkinsfile.getBinding().setVariable( "BRANCH_NAME", "master" )
Any Groovy scripts on the classpath at /vars
will be treated as Pipeline Shared Library Global Variables, and mocked as described above.
Pipeline Shared Library Global Variables are just pipeline scripts on the classpath, so they can be unit-tested with loadPipelineScriptForTest(java.lang.String):
setup:
def MyFunction = loadPipelineScriptForTest("vars/MyFunction.groovy")
when:
MyFunction("...")
then:
1 * getPipelineMock("echo")("Hello World")
The most common way to use Pipeline Shared Library Global Variables is to implicitly .call(...)
them, e.g.
stage( "Do Something" ) {
MyFunction("...")
}
To expect or stub that interaction, use getPipelineMock("FileName.call")(...)
, e.g.
setup:
getPipelineMock("MyFunction.call")("Hello") >> "Hello World"
when:
Jenkinsfile.run()
then:
1 * getPipelineMock("echo")("Hello World")
Expecting
when:
Jenkinsfile.run()
then:
1 * getPipelineMock("MyFunction.call")(_)
By default, all non-interface classes in your project's source directories are instrumented
to be able to call pipeline steps during each Specification test suite (see setupSpec() and setup()),
along with the class that the specification is for (determined by class name, e.g. MyClassSpec
is "for" MyClass
).
If you need to instrument additional objects, you can use addPipelineMocksToObjects(java.lang.Object).
If you provide a Class object, all subsequent instances of that class will be instrumented.
Warning: Attempting to instrument an Interface class may have frustratingly permanent side-effects. Do not do that. Instead, read GROOVY-3493.
explicitlyMockPipelineStep(java.lang.String,java.lang.String)
If for some reason you need to mock a pipeline step that does not come from a detectable plugin, you can use explicitlyMockPipelineStep(java.lang.String,java.lang.String) to add the ability to call that pipeline step and get a Mock object to all currently-instrumented classes and objects.
explicitlyMockPipelineVariable(java.lang.String)
If for some reason you need to capture interactions with a variable that is not detected on the classpath and automatically mocked, you can use explicitlyMockPipelineVariable(java.lang.String) to create and return a PipelineVariableImpersonator that will intercept all subsequent interactions with that variable and forward them to appropriately-named Spock mocks, creating the mocks on-the-fly if necessary.
getPipelineMock("Jenkins")
.
It is set up as follows:
A Mock of the pipeline execution's Binding will be available at getPipelineMock("getBinding")
.
Code-under-test might access this mock by calling CpsScript.getBinding.
Usually, this is done by GlobalVariable implementations.
There is a Spock Spy of type CpsScript available at getPipelineMock("CpsScript")
.
It represents the cps-transformed execution that would exist if Jenkins were running your code for real.
You should never have to interact directly with this object.
Architecture Note: CpsScript overrides CpsScript.invokeMethod and tries to invoke a CPS-transformed equivalent.
Spock mocks won't override this particular method, and so any method invocations on a mock CpsScript
will result in the real invokeMethod
being called and CPS-transformed execution attempted...
But during unit tests, we haven't CPS-transformed anything so this will fail unless we manually override invokeMethod
on a "real" CpsScript object and redirect those method calls back to some "normal" GroovyObject.invokeMethod.
Groovy's metaprogramming capabilities are used heavily to make this class work.
A methodMissing
and propertyMissing
method is dynamically created for every class that needs
to be able to call pipeline steps. What happens if that class already had one of those methods defined? Trouble, that's what.
This class will detect such a situation, and call the other class' appropriate "missing" handler first. If that handler throws either a MissingMethodException or MissingPropertyException, then this class will proceed with the regular "maybe it was a pipeline step" logic. However, a class that "chomps" all missing events, like so:
def methodMissing(String _name, _args) {
LOG.warn( "Called a misssing method: ${_name}(${_args.toString()})" )
}
simply cannot be enabled to call pipeline steps during tests. Avoid writing classes like that.
Modifiers | Name | Description |
---|---|---|
protected static Set<Class<?>> |
DEFAULT_TEST_CLASSES |
All of the classes that should be instrumented to be able to access mocks of pipeline extensions. |
protected static org.slf4j.Logger |
LOG |
|
protected static Set<String> |
PIPELINE_STEPS |
All pipeline steps (StepDescriptors) that exist from classes on the classpath. |
protected static Set<String> |
PIPELINE_SYMBOLS |
All pipeline symbols (org.jenkinsci.Symbol and GlobalVariable) that exist from classes on the classpath. |
protected Map<Class<?>, Object> |
dummy_extension_instances |
In case Jenkins.getExtensionList is called, matching @Extensions are created. |
protected Set<Class<?>> |
instrumented_objects |
An internal record of objects that have bee instrumented to call the mocks when one of the pipeline mocks are invoked. |
protected Map<String, Object> |
mocks |
The mock objects created for this test suite. |
protected Set<String> |
pipeline_steps |
Additional pipeline steps that have been dynamically mocked during a test fixture. |
protected Set<String> |
pipeline_symbols |
Additional pipeline symbols that have been dynamically mocked during a test fixture. |
Fields inherited from class | Fields |
---|---|
class Specification |
_ |
Type Params | Return Type | Name and description |
---|---|---|
|
protected static void |
LOG_CALL_INTERCEPT(def _level, def _note, def _original_receiver, def _original_intercept_method, def _original_method, def _original_args, def _new_receiver, def _new_method, def _new_args, def _unwrap_varargs) Log helpful information about a call intercepted as a result of this specification. |
|
protected void |
addPipelineMocksToObjects(Object... _objects) Add Spock Mock objects for each of the pipeline extensions to each of the _objects. |
|
protected Closure |
explicitlyMockPipelineStep(String _step_name, String _mock_name = null) Explicitly mock a pipeline step that doesn't come from an annotated Extension. |
|
protected def |
explicitlyMockPipelineVariable(String _variable_name) Create and/or retrieve a PipelineVariableImpersonator to mock method calls on a pipeline variable with the given _property_name. |
|
protected Object |
getPipelineMock(String _pipeline_extension) Retrieve the Spock Mock object for the given _pipeline_extension for the current test suite. |
|
protected Set<String> |
getSharedLibraryVariables() Find the names of all .groovy resources on the classpath in vars/*.groovy .
|
|
protected Script |
loadPipelineScriptForTest(String _path) Given a Pipeline Script classpath resource, load it as an executable Script and instrument it with the appropriate pipeline mocks. |
|
def |
setup() Create mocks for the current test run. |
|
def |
setupSpec() Detect existing pipeline extensions and classes that should be able to call them. |
All of the classes that should be instrumented to be able to access mocks of pipeline extensions.
You could modify this in your specification's setupSpec
to change the classes that were instrumented to be able to call pipeline extensions during a test suite.
All pipeline steps (StepDescriptors) that exist from classes on the classpath.
You could modify this in your specification's setupSpec
to change the pipeline steps available during the test suite.
All pipeline symbols (org.jenkinsci.Symbol and GlobalVariable) that exist from classes on the classpath. This may include Pipeline Shared Library global variables, if the project being tested is a Pipeline Shared Library.
You could modify this in your specification's setupSpec
to change the symbols available during the test suite.
In case Jenkins.getExtensionList is called, matching @Extensions are created.
Subsequent calls to that method should return the exact same objects (as would happen in real Jenkins) so the objects must be cached test-suite-wide for re-use.
This is part of the internal workings of JenkinsPipelineSpecification and probably should not be modified by test suites.
An internal record of objects that have bee instrumented to call the mocks when one of the pipeline mocks are invoked.
This list is purely for debugging and optimization purposes; modifying it does not affect the behavior of the test suite.
The mock objects created for this test suite.
This is part of the internal workings of JenkinsPipelineSpecification and probably should not be modified by test suites.
Additional pipeline steps that have been dynamically mocked during a test fixture.
Usually these correspond to method invocations on Global Variables.
This is part of the internal workings of JenkinsPipelineSpecification and probably should not be modified by test suites.
Additional pipeline symbols that have been dynamically mocked during a test fixture.
Usually these correspond to Global Variables.
This is part of the internal workings of JenkinsPipelineSpecification and probably should not be modified by test suites.
Log helpful information about a call intercepted as a result of this specification.
_level
- The log level (e.g. DEBUG
or WARN
, etc)_note
- A description of this intercept_original_receiver
- The original object that the call was made on._original_intercept_method
- The original mechanism used to intercept the call (e.g. propertyMissing
or invokeMethod
, etc)_original_method
- The original call that was attempted_original_args
- The original arguments to the _original_method_new_receiver
- The object that the call is being forwarded to_new_method
- The method on the _new_receiver that will be called_new_args
- The arguments to the _new_method on the _new_receiver_unwrap_varargs
- Whether _new_args is a varargs array that will be unwrapped with *_new_args before being passed to _new_methodAdd Spock Mock objects for each of the pipeline extensions to each of the _objects.
Afterwards, those _objects will be able to call PIPELINE_EXTENSION_NAME()
and this call will not only succeed,
but also register an interaction with the appropriate mocks.
_objects
- The objects to enable to call pipeline extensions.Explicitly mock a pipeline step that doesn't come from an annotated Extension.
If your code-under-test calls methods on CpsScript directly (e.g. CpsScript.getBinding), those methods won't be mocked by default since they aren't Extensions. Using this, you can mock those methods. This method is idempotent and will return the same mock every time it is called.
For example, to mock CpsScript.getBinding().hasVariable() to always return false,
Closure mock_get_binding = explicitlyMockPipelineStep( "getBinding" )
Binding mock_binding = Mock()
mock_get_binding() >> { mock_binding }
mock_binding.hasVariable(_) >> { return false }
If you need this because your code-under-test relies on a regular pipeline step and that step wasn't automatically mocked, this method is not the solution. You should either
_step_name
- The name of the pipeline step to mock._mock_name
- The display name of the mock (used in test failure messages)Create and/or retrieve a PipelineVariableImpersonator to mock method calls on a pipeline variable with the given _property_name.
_property_name
- A property name in a pipeline scriptRetrieve the Spock Mock object for the given _pipeline_extension for the current test suite.
_pipeline_extension
- The pipeline extension to retrieve the mock object for. Find the names of all .groovy resources on the classpath in vars/*.groovy
.
These should correspond to the global variables defined in a shared library.
vars/*.groovy
.Given a Pipeline Script classpath resource, load it as an executable Script and instrument it with the appropriate pipeline mocks.
_path
- The classpath to a pipeline script resourceCreate mocks for the current test run.
getPipelineMock("Jenkins")
getPipelineMock("CpsScript")
Detect existing pipeline extensions and classes that should be able to call them.