Tuesday 23 July 2013

Two things: I'm thick; and one cannot use a Mockbox mocked method as a callback

G'day:
And to think someone said on Twitter the other day that I'm one of those people who are never wrong (the subtext being "actually you are, but just will never admit it"). Well here's an example of me being wrong, happily admitting it, and admitting to being a bit stupid to boot.

I have a validation CFC, along these lines (this is simplified example code, written for the purposes of this article):

// Validator.cfc
component {

    public boolean function isValidOptionalValue(required struct data, required string keyToValidate, required string validationCallback){
        if (!structKeyExists(arguments.data, arguments.keyToValidate)){
            return true;
        }
        return arguments.validationCallback(argumentCollection=arguments);
    }

}

This method checks to see if an optional parameter exists, and if it does, uses a callback to run a validation on it. Nothing complicated.

For the unit tests of this, we want to test its functionality:
  • does it pass if the "optional" value is not present;
  • does it invoke the callback if the value is present;
  • does it return the result of the callback correctly?
What we don't want to involve in the test is an actual callback, as the inner workings of the callback are irrelevant to this test (we have separate tests for that), and would require over-complicating the test to pass data that would actually work with a real validator callback. In this case passing a real callback (say the isBoolean() one) would be fine, but our approach is to mock out all outside logic. Also the easiest way to see if the callback was called is to mock it and then inspect its call log.

So I have this MXUnit test:

// TestValidator.cfc
component extends="mxunit.framework.TestCase" {

    public void function beforeTests(){
        variables.mb = new mockbox.system.testing.MockBox();
    }
    public void function testIsValidOptionalValue_usingMockedCallback(){
        var testValidator = new Validator();

        var mockedValidatorLib = variables.mb.createStub();
        mockedValidatorLib.$("isOK", true);

        var validationTestArgs = {
            data = {paramToValidate="test_paramToValidate"},
            keyToValidate = "paramToValidate",
            validationCallback = mockedValidatorLib.isOK
        };
        var result = testValidator.isValidOptionalValue(argumentCollection=validationTestArgs);

        assertTrue(mockedValidatorLib.$once("isOK"));
        var callLog = mockedValidatorLib.$callLog();
        assertEquals(validationTestArgs.keyToValidate, callLog.isOk[1].keyToValidate);
        assertEquals(validationTestArgs.data.paramToValidate, callLog.isOk[1].data.paramToValidate);
    }

}

This all looks fine, and it's the sort of thing we do all the time. However when I run it, I get this:


Well I'm pleased "Element _MOCKRESULTS is undefined in THIS": that's good to know. But it's not much use to me. I had a look at 6B592074-D067-E5E6-F12EA722D47DC763.cfm, which is the stub Mockbox creates when mocking a method, and it had all this bumpf in it:

<cfset this[ "isOK" ] = variables[ "isOK" ]>
<cffunction name="isOK" access="public" output="false" returntype="any">
<cfset var results = this._mockResults>
<cfset var resultsKey = "isOK">
<cfset var resultsCounter = 0>
<cfset var internalCounter = 0>
<cfset var resultsLen = 0>
<cfset var argsHashKey = resultsKey & "|" & this.mockBox.normalizeArguments(arguments)>

<!--- If Method & argument Hash Results, switch the results struct --->
<cfif structKeyExists(this._mockArgResults,argsHashKey)>
    <cfset results = this._mockArgResults>
    <cfset resultsKey = argsHashKey>
</cfif>

<!--- Get the statemachine counter --->
<cfset resultsLen = arrayLen(results[resultsKey])>

<!--- Log the Method Call --->
<cfset this._mockMethodCallCounters[listFirst(resultsKey,"|")] = this._mockMethodCallCounters[listFirst(resultsKey,"|")] + 1>

<!--- Get the CallCounter Reference --->
<cfset internalCounter = this._mockMethodCallCounters[listFirst(resultsKey,"|")]>
<cfset arrayAppend(this._mockCallLoggers["isOK"], arguments)>

    <cfif resultsLen neq 0>
        <cfif internalCounter gt resultsLen>
            <cfset resultsCounter = internalCounter - ( resultsLen*fix( (internalCounter-1)/resultsLen ) )>
            <cfreturn results[resultsKey][resultsCounter]>
        <cfelse>
            <cfreturn results[resultsKey][internalCounter]>
        </cfif>
    </cfif>
    </cffunction>

Which is all fine, and - as I have been looking at a lot of these recently due to various other challenges Mockbox has thrown my way via ColdFusion bugs - it all looks like it should. And I traced the logic back and I could not see how _mockResults could not be present in the this scope.

Just as a control experiment, I implemented another test using a "real" function for a callback, thus:

public void function testIsValidOptionalValue_usingRealCallback(){
    var testValidator = new Validator();

    var validatorLib = new ValidatorLib();

    var validationTestArgs = {
        data = {paramToValidate="test_paramToValidate"},
        keyToValidate = "paramToValidate",
        validationCallback = validatorLib.isOK
    };

    var result = testValidator.isValidOptionalValue(argumentCollection=validationTestArgs);

}

Where ValidatorLib.cfc is this:

// ValidatorLib.cfc
component {

    boolean function isOk(){
        return true;
    }

}

This test runs fine. Except I'm unable to do any call log assertions on the call to isOK() because it's not a mocked method. So not a "test". But removed a lot of doubt as to whether I was doing something wrong, and the Mockbox error was a symptom rather than the actual issue.

(Have you, btw, spotted what I'm doing wrong here yet?)

I started to think there was a problem in Mockbox... there's already been a precedent set of it having issues with callbacks, so figured this could be another one. I was confident (cough) I was reading my code correctly, and had only spent a superficial amount of time looking at the Mockbox code. I can't give Luis a "repro case" which requires MXUnit for it to work, so I pared the code back to this:

mb = createObject("mockbox.system.testing.Mockbox").init();

stub = mb.createEmptyMock("CfcToTest");
stub.$("method", true);

// OK when called directly
stub.method();

// not so good when calling it via reference
try {
    ref = stub.method;
    ref();
}
catch (any e){
    writeDump(e);
    writeDump(stub);
}

So I've factored-out even passing the callback into a method and calling it from with that. It's just a function call now (have you seen the problem now? I still had not).

And I was still getting the same error.

I had written the bug report for Luis, and as a final bit of "helpful info", I hacked into the MockGenerator.cfc to try/catch the reference to this._mockResults and see what the this scope did have in it, eg:

<cftry>
            <cfset var results = this._mockResults>
<cfcatch>
<cfdump var="##this##">
<cfdump var="##cfcatch##">
</cfcatch>
</cftry>

And running this, I got "Variable THIS is undefined." to which I went "what?". How can a CFC not have a this scope? It's as if I'm calling the function... [penny is released]... outside the context of a CFC... [penny hits ground].

DUH.

How am I calling the mocked "callback" method? Like this:

stub = mb.createEmptyMock("CfcToTest");
stub.$("method", true);

// OK when called directly
stub.method();

// not so good when calling it via reference
try {
    ref = stub.method;
    ref();
}

Right. So on the erroring call... what's the context in which the mocked function is being called? It's in the context of my repro case CFM. It's not being called from within the context of the stub, as I've "pulled it out" of there. So... no... there is no this scope.

And, similarly, in my actual test code, I'm passing a reference to the mocked callback into some other CFC, and then running it in the context of that CFC. Which doesn't have all the Mockbox stuff in it, so its no wonder it errors when the stubbed method tries to access it.

What. A. Plonker.

In the end, I came up with a viable way of being able to inject a mocked method into another CFC and still have it running. Here's a mod to that original test function:

public void function testIsValidOptionalValue_usingMockedCallback(){
    var testValidator = new Validator();

    var mockedValidatorLib = variables.mb.createStub();
    mockedValidatorLib.$("isOK", true);

    variables.mb.prepareMock(testValidator);
    testValidator.$("isOK", true);

    var validationTestArgs = {
        data = {paramToValidate="test_paramToValidate"},
        keyToValidate = "paramToValidate",
        validationCallback = mockedValidatorLib.isOK
    };
    var result = testValidator.isValidOptionalValue(argumentCollection=validationTestArgs);

    var callLog = testValidator.$callLog();
    assertTrue(testValidator.$once("isOK"));
    assertEquals(validationTestArgs.keyToValidate, callLog.isOk[1].keyToValidate);
    assertEquals(validationTestArgs.data.paramToValidate, callLog.isOk[1].data.paramToValidate);
}

So I'm still mocking and passing-in a stubbed external callback, but the testValidator object has also been prepped to have a mocked method of the same name as the passed-in stub callback, so when the stub code runs in the context of testValidator, it'll find all the internal Mockbox data structures it needs, and sets them accordingly. What this means as a consequence is that we need to check the call log that's within validatorTest, not mockedValidatorLib.

And now the test passes.

It took me about 5h to identify "wtf", and pare the code back to a repro case (the point at which the penny dropped), and to come up with a resolution. What a waste of bloody time. Oh well. It's done now.

If I was using CF10 or Railo, I could have achieved what I wanted using a closure, which is a rare occasion in which I think a closure would have a use in CFML. However I'm stuck on CF9, so that's cold comfort for me. When I get home, I might knock-together a version of my initial code, using a closure instead of just a function, and see if I can make it work OK.

--
Adam