Test pollution (part 1)

It’s been quite a while since I’ve blogged, but while my integration-test suite runs, I found time to share this.

I am on a rather large project, where I’m hired to migrate to Grails 3 (among other things). Here I have seen several things in integration tests that can potentially go wrong and lead to Test pollution. This is sometime seen as randomly failing tests and could happen if test-execution order changes, exposing the pollution. One of the issues could be mocking an injected service metaClass because the test requires a specific return value, and this was the only way to make it happen.

An example could be:

A service:

class TestService {
    AnotherService anotherService

    String myProperty

    String myMethod() {
        return "My Method"
    }
}

and the test:

class ChangeMetaClassSpec extends Specification {

    def 'changing the meta class on an instance'() {
        given: 'a test instance'
            TestService sut = new TestService(myProperty: 'My Property')

        expect: 'methods and properties returns expected values'
            sut.myMethod() == 'My Method'
            sut.myProperty == 'My Property'

        when: 'mocking on the instance''
            sut.metaClass {
                myProperty = 'Mocked Property'
                myMethod = { -> "Mocked Method" }
            }

        then: 'meta class values should be returned instead
            sut.myMethod() == 'Mocked Method'
            sut.myProperty == 'Mocked Property'
    }
}

In it self not dangerous, when running it as a unit test, but consider this test:

class ChangeServiceMetaClassSpec extends Specification {

    // This would be like an injected service in an integration test,
    // as it is shared between invocations of the test.
    @Shared TestService testService = new TestService(myProperty: 'My Property')

    def 'changing the meta class on an instance'() {
        expect: 'methods and properties returns expected values'
            testService.myMethod() == 'My Method'
            testService.myProperty == 'My Property'

        when: 'mocking in a bad way'
            testService.metaClass {
                myProperty = 'Mocked Property'
                myMethod = { -> "Mocked Method" }
            }

        then: 'meta class values should be returned instead
            testService.myMethod() == 'Mocked Method'
            testService.myProperty == 'Mocked Property'
    }

    def 'using the instance after it was mocked'() {
        expect: 'methods and properties returns expected values'
            testService.myMethod() == 'My Method'
            testService.myProperty == 'My Property'
    }

}

So while this looks like the previous test, the big culprit here is mocking the injected service instance (in this case a @Shared instance). This immediately shows when running the second test

This would fail, because the metaClass changes are still there, because changes took place on the shared instance. If metaClass changes were done on the class it self:

        when: 'mocking in a better way'
              TestService.metaClass {
                  myProperty = 'Mocked Property'
                  myMethod = { -> "Mocked Method" }
              }

and we use Spock’s @ConfineMetaClassChanges([TestService]) on the test:

@ConfineMetaClassChanges([TestService])
    class ChangeServiceMetaClassSpec extends Specification { .. }

Then one would think that it should work, but it does not (at least not when running it with the @Shared option)

So I came up with a small helper, that solves the problem:

import org.junit.After
import org.junit.AfterClass
import org.slf4j.LoggerFactory

trait MockMetaClassHelper {
    private Map<object> originalMetaClasses = [:]
    private log = LoggerFactory.getLogger(this.getClass().name)

    boolean postponeMetaClassCleanup = false
    /**
     * Replace an instance's metaClass with a new and save reference to the original.
     * A closure can be specified to make changes on the new metaClass.
     * @param instance
     * @param modifications
     * @return
     */
    MetaClass mockMetaClass(Object instance, Closure modifications = null) {
        if (originalMetaClasses.containsKey(instance) || instance.metaClass instanceof ExpandoMetaClass) {
            log.info("MetaClass for ${instance.getClass().name} was already mocked, reusing metaClass")
            return instance.metaClass
        }

        originalMetaClasses.put(instance, instance.metaClass)
        MetaClass temporaryMetaClass = new ExpandoMetaClass(instance.getClass(), false, true)
        temporaryMetaClass.initialize()

        instance.metaClass = temporaryMetaClass

        if (modifications) {
            instance.metaClass(modifications)
        }
        log.debug "Replaced metaClass for ${instance.getClass().name}"
        return instance.metaClass
    }
    /**
     * Restores metaClass for an instance, if it was changed
     * @param instance
     */
    void restoreMetaClass(Object instance) {
        MetaClass originalMetaClass = originalMetaClasses.remove(instance)
        if (originalMetaClass) {
            internalRestoreMetaClass(instance, originalMetaClass)
        }
    }

    /**
     * Automatically restores all metaClass changes after a test finishes (cleanup)
     */
    @After
    void cleanupMetaClasses() {
        if (!postponeMetaClassCleanup) {
            originalMetaClasses.each { instance, originalMetaClass -> internalRestoreMetaClass(instance, originalMetaClass) }
            originalMetaClasses.clear()
        }
    }

    /**
     * Automatically restores all metaClass changes after all tests finishes (cleanupSpec)
     */
    @AfterClass
    void cleanupSpecMetaClasses() {
        if (postponeMetaClassCleanup) {
            originalMetaClasses.each { instance, originalMetaClass -> internalRestoreMetaClass(instance, originalMetaClass) }
            originalMetaClasses.clear()
        }
    }

    private internalRestoreMetaClass(Object instance, MetaClass metaClass) {
        log.debug "Restored metaClass for ${instance.getClass().name}"
        instance.metaClass = metaClass

    }
}

This will save the original metaClass and attach a new ExpandoMetaClass which we can work on, and finally it will automatically call cleanupMetaClasses restoring the objects initial metaClass

So basically we register our changes on the metaClass like this:

class ChangeServiceMetaClassSpec extends Specification implements MockMetaClassHelper {

    ----
    def 'changing the meta class on an instance'() {
        given: ....
        when: "metaClass is mocked"
            mockMetaClass(sut) {
                myProperty = 'Mocked Property'
                myMethod = { -> "Mocked Method"}
            }
        then: ....

and when the test case has completed, it the original metaClass is restored.

the mockMetaClass returns the ExpandoMetaClass and it possible to to either modifications to it, or directly on your class instances metaClass knowing that when the test completes, the original metaClass is restored.

I hope this is helpful and will stop your tests from polluting each other.

Next blog post will be about a similar issue with replacing services with mocks during integration-tests in Grails and of course a solution too.

Stay Groovy!

 
  1. sbglasius posted this
Blog comments powered by Disqus