5

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

Community
  • 1
  • 1
brunobowden
  • 1,492
  • 19
  • 37

1 Answers1

1

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)
        }
    }
}
Community
  • 1
  • 1
brunobowden
  • 1,492
  • 19
  • 37
  • I'm not 100% sure it'll work, but you might be able to use the technique in another post on my blog about overriding methods but still using the original implementation: http://naleid.com/blog/2009/06/01/groovy-metaclass-overriding-a-method-whilst-using-the-old-implementation This could get blocked by the interface issue that you've identified though... If so, then the solution that you have looks like a good one to me. – Ted Naleid Jul 01 '15 at 21:44
  • Thanks Ted, this has the same issue with the Interface preventing it from working. I'm interested in your thoughts on coercing types. The simple code fails for `project.files(Object... paths)` for `files(null)` and `files()`. Is there a better way to coerce the types for a varargs parameter in a generic manner? Feel free to edit my post with improvements. – brunobowden Jul 02 '15 at 01:34