As part of testing a Gradle plugin, I would like to stub out a groovy method: project.exec {...}
. This is to confirm it's making the correct command line calls. I was attempting this using metaprogramming:
Project proj = ProjectBuilder.builder().build()
proj.metaClass.exec = { Closure obj ->
println 'MOCK EXEC'
}
proj.exec {
executable 'echo'
args 'PROJECT EXEC'
}
// prints 'PROJECT EXEC' instead of the 'MOCK EXEC' I expected
What's curious is that if I rename both exec
methods to othername
, then it works correctly:
Project proj = ProjectBuilder.builder().build()
proj.metaClass.othername = { Closure obj ->
println 'MOCK EXEC'
}
proj.othername {
executable 'echo'
args 'PROJECT EXEC'
}
// prints 'MOCK EXEC' as expected
I'm trying to figure out why the existing project.exec
method causes the metaprogramming to fail and if there's a workaround. Note that Project
is an interface but I'm mocking a specific instance of type DefaultProject
.
The metaprogramming method for stubbing out a single method is from this answer: https://stackoverflow.com/a/23818476/1509221
In Groovy replacing a method defined in an interface using metaClass is broken. In this case, the exec
method is defined in the Project
class, which is an interface. From GROOVY-3493 (reported originally in 2009):
"Cannot override methods via metaclass that are part of an interface implementation"
WORKAROUND
invokeMethod
intercepts all methods and can work. This is overkill but it does work. When the method name matches exec
, it diverts the call to the mySpecialInstance
object. Otherwise it's passed through to the delegate, namely the existing methods. Thanks to invokeMethod delegation and Logging All Methods for input on this.
// This intercepts all methods, stubbing out exec and passing through all other invokes
this.project.metaClass.invokeMethod = { String name, args ->
if (name == 'exec') {
// Call special instance to track verifications
mySpecialInstance.exec((Closure) args.first())
} else {
// This calls the delegate without causing infinite recursion
MetaMethod metaMethod = delegate.class.metaClass.getMetaMethod(name, args)
return metaMethod?.invoke(delegate, args)
}
}
This works well except that you may see exceptions about "wrong number of arguments" or "Cannot invoke method xxxxx on null object". The problem is that the above code doesn't handle coercing of the method arguments. For the project.files(Object... paths)
, the args for invokeMethod should be of the form [['path1', 'path2']]
. BUT, in some cases there's a call to files(null)
or files()
so the args for invokeMethod turn out to be [null]
and []
respectively, which fail as it's expecting [[]]
. Producing the aforementioned errors.
The following code only solves this for the files
method but that was sufficient for my unit tests. I would still like to find a better way of coercing types or ideally of replacing a single method.
// As above but handle coercing of the files parameter types
this.project.metaClass.invokeMethod = { String name, args ->
if (name == 'exec') {
// Call special instance to track verifications
mySpecialInstance.exec((Closure) args.first())
} else {
// This calls the delegate without causing infinite recursion
// https://stackoverflow.com/a/10126006/1509221
MetaMethod metaMethod = delegate.class.metaClass.getMetaMethod(name, args)
logInvokeMethod(name, args, metaMethod)
// Special case 'files' method which can throw exceptions
if (name == 'files') {
// Coerce the arguments to match the signature of Project.files(Object... paths)
// TODO: is there a way to do this automatically, e.g. coerceArgumentsToClasses?
assert 0 == args.size() || 1 == args.size()
if (args.size() == 0 || // files()
args.first() == null) { // files(null)
return metaMethod?.invoke(delegate, [[] as Object[]] as Object[])
} else {
// files(ArrayList) possibly, so cast ArrayList to Object[]
return metaMethod?.invoke(delegate, [(Object[]) args.first()] as Object[])
}
} else {
// Normal pass through
return metaMethod?.invoke(delegate, args)
}
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With