Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Mock Gradle project.exec {...} using metaClass

Tags:

gradle

groovy

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

like image 884
brunobowden Avatar asked Nov 10 '22 10:11

brunobowden


1 Answers

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)
        }
    }
}
like image 173
brunobowden Avatar answered Nov 15 '22 05:11

brunobowden