Unit Testing Isolated Methods With Groovy

| Comments

Glen Smith has been running a great series of posts this month to help people get started with unit testing. In that sprit, I thought I’d put out a post on some unit testing work that I’ve been doing recently.

There are a number of libraries and techniques for unit testing with groovy. It comes with MockFor and StubFor out of the box, and you can also leverage Java libraries like EasyMock and Mockito if your needs aren’t satisfied by the built-in constructs.

The mock and stub implementations that I mention above work well for allowing you to control the behavior of any collaborators, but what if your method calls another method within the same class? None of those solutions (as far as I’m aware) allow you to replace the behavior of other methods on the Class Under Test (CUT). If you are trying to unit test your method in true isolation, you need the ability to stub out the behavior of internal methods on the class (for example, to throw an exception to test try/catch logic).

If you had the following CUT:

class ClassUnderTest {
    def testMe() {
        return "${first()}, ${second()}"
    }
    def first() {
        return "first"
    }
    def second() {
        return "second"
    }
}

If you’re trying to unit test the testMe method, you might want to control the values that come back from first and second to ensure that testMe still reacts as you expect.

This test helper class will allow you to test your methods in isolation, so you can focus on testing the logic in your Method Under Test (MUT), rather than worrying about the contortions that might be necessary to get helper methods into the state you want to test.

com.naleid.test.TestHelper.groovy:

package com.naleid.test

class TestHelper {
    public static def isolateMethod = { methodRef, stubMethods, closure ->
        def clazz = methodRef.owner.theClass
        clazz.metaClass.invokeMethod = { String name, args ->
            def stubMethod = stubMethods[name]
            if (methodRef.method == name || stubMethod?.owner == clazz) {
                // invoke a real method on the class , it's either the method we're testing in isolation or
                // one we've been told to pass through
                return clazz.metaClass.getMetaMethod(name, args).invoke(delegate, args)
            } else if (stubMethod) { // we have a stub method to use
                return stubMethods[name].call(args)
            } else {
                throw new Exception("Testing method in isolation; other method called that we haven't been provided a stub for: $name($args)")
            }
        }

        closure.call() // call the code that tests methodRef in isolation

        GroovySystem.metaClassRegistry.removeMetaClass(clazz) // clean up after ourselves
    }
}

What’s going on here? We’ve got a static method isolateMethod that allows us to pass in a method reference, a map of stub methods that we can control the behavior of, and a closure that will actually execute the tests while we have our method in isolation. We temporarily modify the metaClass of our CUT so that we intercept all calls to it’s instances. Then we execute the test closure that we were passed. Finally, we clean up after ourselves by reverting our CUT’s metaClass back to it’s original state.

There is some similarity here to the use keyword employed by MockFor and StubFor.

Well, it’s probably easier to show rather than just tell. Here is a GroovyTestCase that uses the static isolateMethod to change the behavior of first and second.

import com.naleid.test.TestHelper as TH

class IsolateMethodTests extends GroovyTestCase {

    def stubFirst = { return "stubFirst" }
    def stubSecond = { return "stubSecond" }

    void testIsolateMethod() {
        assertEquals("first, second", new ClassUnderTest().testMe()) // original behavior

        TH.isolateMethod(ClassUnderTest.metaClass.&testMe, [first: stubFirst, second: stubSecond]) {
            assertEquals("stubFirst, stubSecond",  new ClassUnderTest().testMe()) // stubbed out method behavior
        }

        assertEquals("first, second", new ClassUnderTest().testMe()) // back to original behavior
    }
}

We call the TestHelper’s isolateMethod and pass in the following items:

  • ClassUnderTest.metaClass.&testMe - a reference to the testMe method that we get from ClassUnderTest’s metaClass
  • [first: stubFirst, second: stubSecond] - a map of method names to stub closures that have replacement behavior for us to use

We also give it a closure that shows that we’re using stub methods rather than the real methods.

What if you still want the behavior of some of the sub-methods that testMe calls? All you have to do is pass in a reference to the real method and isolateMethod will pass it through.

void testIsolateMethodStubFirstRealSecond() {
    TH.isolateMethod(ClassUnderTest.metaClass.&testMe, [first: stubFirst, second: ClassUnderTest.&second]) {
        assertEquals("stubFirst, second", new ClassUnderTest().testMe())
    }
}

isolateMethod is also designed to throw an exception if a method that you haven’t given a stub is called by the MUT. Here is an example that shows what happens where we’ve given first, but neglected to include a stub for second:

void testIsolateMethodThrowsExceptionForMissingMethod() {
    TH.isolateMethod(ClassUnderTest.metaClass.&testMe, [first: stubFirst]) {

    def e = shouldFailWithCause(Exception) {
        new ClassUnderTest().testMe()
    }
    assertEquals("Testing method in isolation; other method called that we haven't been provided a stub for: second({})", e.toString())
    }
}

Using a technique like isolateMethod has the effect of making mocking libraries and unit testing much easier to work with, as you don’t need to worry about mocking out all of the collaborators that sub-methods of the MUT interact with.

I’m curious to hear how others have dealt with this issue in their unit testing in the past. Are there other solutions out there that I’ve missed?

Comments