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

Mock helper WIP #61

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ You can redefine them as you wish.

### Mock Jenkins commands

#### Basic mock
You can register interceptors to mock Jenkins commands, which may or may not return a result.

```groovy
Expand All @@ -155,7 +156,39 @@ You can take a look at the `BasePipelineTest` class to have the short list of al
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.

### Analyze the mock execution
#### Advanced mock with StepMock
In some cases, you want to reuse the mock you declared for a step and extend them to add an other behaviour
in other conditions. For such situations, you could use the StepMock class. You can then choose which step
parameter is defining how the mock is supposed to behave. Then for this parameter, you can bind a unique mock
response which will be used when this parameter matches a regexp.
This response could either be:
* a string
* a mock file
* a closure

To do that, you must first declare you prefer to use this StepMock strategy. Instead of the `registerAllowedMethod`,
do following:
```groovy
// Here we declare that `sh` result depends on the 'script' parameter
helper.registerMockForMethod(new MethodSignature('sh', Map), { String rule, Map shArgs -> shArgs.script =~ rule })

// then we declare the mock for a `git rev-parse HEAD`, with a simple String
helper.getMock('sh', Map).mockWithString('git rev-parse HEAD', 'bcc19744')

// we can use a mock file as well
helper.getMock('sh', Map).mockWithFile('ls', this.class, 'ls.txt')

// let's mock a echo bash command, thanks to a closure
helper.getMock('sh', Map).mockWithClosure('^echo ', {args -> println args.script[5..-1]})

// If you get a foreign StepMock from a scope you cannot modify, you can remove a rule as well:
helper.getMock('sh', Map).removeRule('ls')
```

Note that you can chain `mockWithFile`, `mockWithString`, `mockWithClosure`, as well
as `registerMockForMethod` can as well be chained with these three methods.

### Analyze the mock executicon

The helper registers every method call to provide a stacktrace of the mock execution.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.lesfurets.jenkins.unit

import java.util.regex.Matcher

import static com.lesfurets.jenkins.unit.MethodSignature.method

import java.lang.reflect.Method
Expand Down Expand Up @@ -75,6 +77,11 @@ class PipelineTestHelper {
*/
protected LibraryLoader libLoader

/**
* Step mocks indexed by signature
*/
Map<MethodSignature, StepMock> allMocks = [:]

/**
* Method interceptor for method 'load' to load scripts via encapsulated GroovyScriptEngine
*/
Expand Down Expand Up @@ -388,14 +395,71 @@ class PipelineTestHelper {
}

/**
* Register the method to be allowed when running the pipeline.
* @param name method name
* @param args parameter types
* @param closure method implementation, can be null
* If the closure is null, then every parameter of the step which are of type Closure will be executed when the
* step is called
*/
void registerAllowedMethod(String name, List<Class> args = [], Closure closure) {
allowedMethodCallbacks.put(method(name, args.toArray(new Class[args?.size()])), closure)
}

/**
* Register the method to be allowed when running the pipeline.
* All step parameters of type Closure will be executed
* @param name method name
* @param args parameter types
*/
void registerAllowedMethod(String name, List<Class> args = []) {
allowedMethodCallbacks.put(method(name, args.toArray(new Class[args?.size()])), null)
}

/**
* Creates a mock for method and register it.
* This mock allows you to change the mock behaviour depending on the step parameters
* @param methodSignature signature of the step to mock
* @param matcherClosure TODO
* @return the step mock. This mock can then be configured, using
* You can later get this mock by using {@link #getMock(com.lesfurets.jenkins.unit.MethodSignature)}
*
* Here is how you can mock everything:
* helper.registerMockForMethod(new MethodSignature('sh', Map), { String rule, Map shArgs -> shArgs.script =~ rule })
* .mockWithString('git rev-parse HEAD', 'bcc19744')
* .mockWithString('whoami', 'jenkins')
* But then, you can still get your mock to add some other behaviours
* helper.getMock('sh', Map).mockWithString('ls', 'Jenkinsfile')
*/
StepMock registerMockForMethod(MethodSignature methodSignature, Closure<Matcher> matcherClosure) {
if (allMocks[methodSignature]) {
println "Warning, the existing mock for $methodSignature will be replaced"
}
allMocks[methodSignature] = new StepMock(methodSignature, this, matcherClosure)
return allMocks[methodSignature]
}

/**
* Return the step mock configured by {@link #registerMockForMethod(com.lesfurets.jenkins.unit.MethodSignature, groovy.lang.Closure)}
* @param signature signature of the step mock
* @return the declared mock if exists, else null
* @see {@link #getMock(java.lang.String, java.lang.Class[])}
*/
StepMock getMock(MethodSignature signature) {
return allMocks[signature]
}

/**
* Return the step mock configured by {@link #registerMockForMethod(com.lesfurets.jenkins.unit.MethodSignature, groovy.lang.Closure)}
* @param methodName step name to mock
* @param arguments argument types
* @return the declared mock if exists, else null
* @see {@link #getMock(com.lesfurets.jenkins.unit.MethodSignature)}
*/
StepMock getMock(String methodName, Class... arguments) {
return getMock(new MethodSignature(methodName, arguments))
}

/**
* Register a callback implementation for a method
* Calls from the loaded scripts to allowed methods will call the given implementation
Expand Down
138 changes: 138 additions & 0 deletions src/main/groovy/com/lesfurets/jenkins/unit/StepMock.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package com.lesfurets.jenkins.unit

/**
* Class to mock the return of a step, with different behaviour depending on the arguments used when the step is called.
* For each configuration, you can match the arguments with a string, a mock file or a closure.
*
* The Mock files are supposed to be located in the {@link StepMock#mockBaseDirectory},
* but you can change this property if needed.
*
* @see {@link PipelineTestHelper#registerMockForMethod(com.lesfurets.jenkins.unit.MethodSignature, groovy.lang.Closure)} for usage
*
* It has been initially designed for sh step, but it can be extended for others, bat or anything.
*/
class StepMock {

/**
* List of rules to follow when this step is called with a map as an Argument
*/
private Map<String, Closure> rules = [:]

/**
* Step signature to be mocked
*/
protected MethodSignature signature

/**
* Location
*/
public String mockBaseDirectory = '/mocks'

/**
* Predicate called with the step arguments to decide how this step will be mocked
*/
protected Closure<Boolean> predicate

/**
* Create a mock for a given step signature
* @param signature signature of the step to mock
* @param helper helper used to register this mock
* @param predicate predicate called with the step arguments to decide how this step will be mocked
* @see {@link #mockWithClosure(java.lang.String, groovy.lang.Closure)}
*/
public StepMock(MethodSignature signature, PipelineTestHelper helper, Closure<Boolean> predicate) {
this.signature = signature
helper.registerAllowedMethod(signature, { Object... it -> this.applyBestRule(it) })
this.predicate = predicate
}

/**
* Add a rule to the mock: if the ruleMatcher matches the parameters, the mockClosure will be executed with the step parameters as arguments
* and its result will be returned as the step result
* @param ruleMatcher matcher of the rule
* @param mockClosure closure to call when the mock matches the parameter
* @return the current mock
*/
public StepMock mockWithClosure(String matcher, Closure mockClosure) {
rules[matcher] = mockClosure
return this
}

/**
* Add a rule to the mock: if the ruleMatcher matches the parameters, the mockResult string will be returned
* @param ruleMatcher matcher of the rule
* @param mockResult what the mock is supposed to return when the step parameters match the rule
* @return the current mock
*/
public StepMock mockWithString(String ruleMatcher, String result) {
mockWithClosure(ruleMatcher, { return result })
return this
}

/**
* Add a rule to the mock: if the ruleMatcher matches the parameters, a mock file will be rendered as the result
* of the mock.
* @param ruleMatcher matcher of the rule
* @param clazz mockfile will be retrieved from fhis resource class
* @param mockFilename name of the mock file
* @return the current mock
*/
public StepMock mockWithFile(String ruleMatcher, Class clazz, final String mockFilename) {
try {
mockWithClosure(ruleMatcher, {getMockResource(clazz, mockFilename)})
} catch (IllegalArgumentException e) {
String errorMessage = "Unable to load $mockFilename for step ${signature.name}: ${e.getMessage()}"
System.err.println(errorMessage) // print the error, in case it is catched by any Jenkins script
throw new IllegalArgumentException(errorMessage, e)
}
return this
}

/**
* Remove a rule in the mock
* @param ruleMatcher name of the rule
* @return the current mock
*/
public StepMock removeRule(String ruleMatcher) {
rules.remove(ruleMatcher)
return this
}

/**
* Apply a rule for the given args of the step.<br/>
* The return type may be either String or Integer.
* It can be a String if you use it for such a sh:
* <code>
* sh(returnStdout: true, script: "curl -L http://example")
* </code>
* It can be a int if you use it for this:
* <code>
* def branchExists = sh(returnStatus: true, script: "git ls-remote -q --exit-code . $branchName")
* </code>
* @param stepArgs args of the step
* @return the mocked result of the step
*/
protected def applyBestRule(Object... stepArgs) {
Map<MethodSignature, Closure<Map>> matchingRules = rules.findAll {
return predicate.curry(it.key).call(stepArgs)
}
if (matchingRules.size() != 1) {
String errorMessage = "No unique rule for ${signature.name} with args [${stepArgs}].\nRules found:${matchingRules.keySet()}"
System.err.println(errorMessage) // print the error, in case it is catched by any Jenkins script
throw new IllegalStateException(errorMessage)
}
Closure foundClosure = matchingRules.find { true /* first result */ }.value
foundClosure.curry(stepArgs).call()
}

protected String getMockResource(Class c, String name) {
String filename = "$mockBaseDirectory/${c.simpleName}/${name}"
try {
new File(c.getResource(filename).toURI()).text
} catch (NullPointerException npe) {
String errorMessage = "$filename not found"
System.err.println(errorMessage) // print the error, in case it is catched by any Jenkins script
throw new IllegalArgumentException(errorMessage)
}
}
}
10 changes: 9 additions & 1 deletion src/test/groovy/com/lesfurets/jenkins/TestRegression.groovy
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.lesfurets.jenkins

import com.lesfurets.jenkins.unit.MethodSignature
import org.junit.Before
import org.junit.Test

Expand All @@ -13,7 +14,14 @@ class TestRegression extends BaseRegressionTestCPS {
scriptRoots += 'src/test/jenkins'
super.setUp()
def scmBranch = "feature_test"
helper.registerAllowedMethod("sh", [Map.class], {c -> 'bcc19744'})

helper.registerMockForMethod(new MethodSignature('sh', Map), { String rule, Map shArgs -> shArgs.script =~ rule })
.mockWithString('git rev-parse HEAD', 'bcc19744')
.mockWithString('whoami', 'jenkins')
// And we can later do:
helper.getMock('sh', Map).mockWithFile('ls', this.class, 'ls.txt')
helper.getMock('sh', Map).mockWithClosure('^echo ', {args -> println args.script[5..-1]})

binding.setVariable('scm', [
$class : 'GitSCM',
branches : [[name: scmBranch]],
Expand Down
2 changes: 2 additions & 0 deletions src/test/jenkins/job/exampleJob.jenkins
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ def execute() {
gitlabCommitStatus("test") {
println properties.key
sh "mvn verify -DgitRevision=$revision"
String lsResult = sh script:'ls', returnStdout:true
println lsResult
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/test/resources/callstacks/TestRegression_example.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@
exampleJob.gitlabCommitStatus(test, groovy.lang.Closure)
exampleJob.println(value)
exampleJob.sh(mvn verify -DgitRevision=bcc19744)
exampleJob.sh({script=ls, returnStdout=true})
exampleJob.println(Jenkinsfile
build.gradle)
2 changes: 2 additions & 0 deletions src/test/resources/mocks/TestRegression/ls.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Jenkinsfile
build.gradle