Wednesday 4 December 2013

Unit Testing / TDD - off()

G'day:

As alluded to in my earlier article, I initially forgot that as well as a way to bind event handlers (on()) to an event, and trigger() the event, I also really need a way to unbind handlers from an event. Which I am going to implement the same way JQuery does: with an off() function.

As has been my wont, here is a list of the previous articles in this series:
And all the code is on Github, here: https://github.com/daccfml/scratch/tree/master/blogExamples/unittests/events


The first thing I need to do is to alter my tests that check the return value from createEventObject() so as to test for off() being returned:

public void function testReturnValues(){
     assertIsStruct(variables.eventObject, "Returned value should be a struct");
     assertStructKeysCorrect(
          "on,trigger,off",
          variables.eventObject,
          "Incorrect keys returned in eventObject"
     );

     assertTrue(
          isClosure(variables.eventObject.on),
          "The returned on() value should be a function"
     );

     assertTrue(
          isClosure(variables.eventObject.trigger),
          "The returned trigger() value should be a function"
     );

     assertTrue(
          isClosure(variables.eventObject.off),
          "The returned off() value should be a function"
     );
}


And to make these pass, I need to add a shell off() function to the struct returned from createEventObject():

function createEventObject(){
     var eventContainer = {};
     return {
          on = function(required string event, required function handler, struct data={}){
               if (!structKeyExists(eventContainer, event)){
                    eventContainer[event] = [];
               }
               arrayAppend(eventContainer[event], arguments);
          },
          trigger = function(required string event, struct additionalParameters={}){
               if (structKeyExists(eventContainer, event)){
                    for (eventEntry in eventContainer[event]){
                         var eventObj = {
                              event = event,
                              data = eventEntry.data
                         };
                         eventEntry.handler(event=eventObj, argumentCollection=additionalParameters);
                    }
               }
          },
          off = function(){
          }
     };
};

Next, we know off() will need to take an event argument:


public void function testOffRequiresEventArg() {
     var failMsg = "off() should require an EVENT argument";
     try {
          variables.eventObject.off(); // note how it DOESN'T pass an EVENT argument
          fail(failMsg);
     } catch (any e){
          // can't catch this coherently by exception type as CF and Railo return completely different exceptions here
          assertTrue(
               findNoCase("event", e.message) && findNoCase("parameter", e.message),
               failMsg
          );
     }
}

One thing I never mentioned about this previously (I've had several tests like this), is that I call the function, and then force a fail() immediately afterwards. A fail() call causes the unit test to fail (duh). So what I'm expecting here is that the off() call will actually error, so fall through into the catch block (and this is what we want: the function to error if we don't pass an event argument). So the "happy path" here is that off() errors and the fail() is never called. The "unhappy path" is if for some reason off() doesn't error if no event argument is passed, in which case processing continues past the off() call, which is a test failure. Which we force with the fail(). Make sense?


And the code to make this test pass:

off = function(required string event){
}


And, of course, we need to do something with this event argument. First we test that a single event handler can be unbound successfully:

public void function testOffRemovesHandler() {
     structDelete(variables, "testOffRemovesHandlerResponse"); // ensure it doesn't exist
     variables.eventObject.on("TestEvent", function(){
          variables.testOffRemovesHandlerResponse = "TestEventHandlerResponse";
     });

     variables.eventObject.off("TestEvent");

     variables.eventObject.trigger("TestEvent");
     assertFalse(
          structKeyExists(variables, "testOffRemovesHandlerResponse"),
          "testOffRemovesHandlerResponse should not have been created"
     );
}

And the code to make it pass:

off = function(required string event){
     structDelete(eventContainer, event);
}

Next we want to test something similar we did with trigger(): testing we can safely call off() with an event that hasn't actually had anything bound to it yet:

public void function testOffDoesNotFailWithUnknownEvent() {
    variables.eventObject.off("EventWithNoHandlers");
    // the test is that it doesn't error
}

Again... like its trigger() equivalent... this test doesn't fail even now. This is due to the nature of structDelete(): it doesn't have a problem with being asked to delete something that doesn't exist. So there's no code change here.

Next, we need to test that it will unbind multiple event handlers:

public void function testOffRemovesMulipleHandlers() {
    structDelete(variables, "testOffRemovesMulipleHandlersResponse1"); // ensure it doesn't exist
    structDelete(variables, "testOffRemovesMulipleHandlersResponse2"); // ensure it doesn't exist

    variables.eventObject.on("TestEvent", function(){
        variables.testOffRemovesHandlerResponse1 = "TestEventHandlerResponse1";
    });
    variables.eventObject.on("TestEvent", function(){
        variables.testOffRemovesHandlerResponse2 = "TestEventHandlerResponse2";
    });

    variables.eventObject.off("TestEvent");

    variables.eventObject.trigger("TestEvent");

    assertFalse(
        structKeyExists(variables, "testOffRemovesMulipleHandlersResponse1"),
        "testOffRemovesMulipleHandlersResponse1 should not have been created"
    );

    assertFalse(
        structKeyExists(variables, "testOffRemovesMulipleHandlersResponse2"),
        "testOffRemovesMulipleHandlersResponse2 should not have been created"
    );
}


And - again - because of the way the handlers are stored, and the way structDelete() works... there's no code change to make this test work either.

Lastly we need to ensure that when we unbind one event, we don't impact other events' handlers that have been registered:

public void function testOffRemovesCorrectHandlers() {
    structDelete(variables, "testOffRemovesCorrectHandlersResponse1"); // ensure it doesn't exist
    structDelete(variables, "testOffRemovesCorrectHandlersResponse2"); // ensure it doesn't exist

    variables.eventObject.on("TestEvent1", function(){
        variables.testOffRemovesCorrectHandlersResponse1 = "TestEventHandlerResponse1";
    });
    variables.eventObject.on("TestEvent2", function(){
        variables.testOffRemovesCorrectHandlersResponse2 = "TestEventHandlerResponse2";
    });

    variables.eventObject.off("TestEvent1");

    variables.eventObject.trigger("TestEvent1");
    variables.eventObject.trigger("TestEvent2");

    assertFalse(
        structKeyExists(variables, "testOffRemovesMulipleHandlersResponse1"),
        "testOffRemovesMulipleHandlersResponse1 should not have been created"
    );

    assertTrue(
        structKeyExists(variables, "testOffRemovesCorrectHandlersResponse2"),
        "testOffRemovesCorrectHandlersResponse2 should not have been created"
    );
}

And... um... once again... we don't need to adjust any code for this test to pass.

Hmmm. That was unexpectedly easy. And I didn't end up with a very gripping article out of all that lot, did I (even on the scale of how gripping my articles usually are, I mean).

OK... well... that's createEventObject() tested and finished! I'm gonna stick it up on CFLib I think: it's got at least some merit.

For the next article(s) (not sure how many yet), I'll have to come up with some different code to write / test with, as I need something with more "moving parts" to demonstrate other testing techniques. I'll have to think that one over a bit.

Okey doke... that didn't really keep me occupied during my flight home, so I guess I'll watch a DVD instead.

--
Adam

[note: I wrote this on Sunday, and thought I'd released it. I didn't notice I hadn't until I went to cite it earlier today and couldn't find it. Sigh. Anyway, that explains the weekend-sounding reference above about the flight home]