Sunday 1 December 2013

Unit Testing / TDD - passing data to on() and trigger()

G'day:
Yesterday I plugged through more test/code and got the code to the point that it would bind and trigger event handlers A-OK. However we still need to update the code so that we can pass data at both bind-time and trigger-time to the handler's execution.

Before I continue, here's the index to the articles in this series I've already written:


Oh, and I'm at my local pub in Galway (yes, I only come to Galway every second weekend, but I have a local... and people here do know my name), drinking Guinness and watching rugby. Pleasingly: I found a rugby match to watch... Wales v Aussie. Go Wales! My geographical origins do not extend to supporting Aussie. Unless they're playing South Africa.

Anyway... TDD. The code in the state we're current at in the blog series is in this gist: https://gist.github.com/daccfml/7734936. And the overall code in whatever state I've decided to commit it is in Github here: https://github.com/daccfml/scratch/tree/master/blogExamples/unittests/events. The stuff in Github will be ahead of where I am in the article series.

First up, trigger() needs to pass an event object to the handler when it calls it. We're stuck with doing our coupled tests still (where we need to use calls to on() to test functionality in trigger() and vice-versa). What we're gonna do is write a test which looks for an event object being available in the handler that on() passes:

public void function testTriggerPassesEventObjToHandler() {
     variables.eventObject.on("TestEvent", function(){
          if (!structKeyExists(arguments, "event")){
               throw(type="MissingArgumentException", message="Event object not passed to handler");
          }
     });
     try {
          variables.eventObject.trigger("TestEvent");
     }
     catch (MissingArgumentException e){
          fail(e.message);
     }
}

And the code change:

trigger = function(required string event){
     if (structKeyExists(eventContainer, event)){
          for (eventEntry in eventContainer[event]){
               var eventObj = {};
               eventEntry.handler(event=eventObj);
          }
     }
}

(goodness... they're playing Frankie Goes to Hollywood's "The Power of Love" at the pub. Haven't heard this for a while. Yes: I'm a child of the 80s)

Ahem... Frankie say: get on with it, Cameron.

OK, we also need to pass some stuff with this event object. First: the event name. This might seems slightly tautological / redundant as we're writing the handlers inline, so the event being bound is right there in the same line of code. Bear in mind though that the handler doesn't need to be written inline: it could be a pre-existing function stored elsewhere. So we do need to rely on values like this being passed into it. To demonstrate this, let's decouple the handler from the binding code this time:

public void function testTriggerPassesEventInEventObjToHandler() {
     variables.eventObject.on("TestEvent", handlerForTestTriggerPassesEventInEventObjToHandler);
     try {
          variables.eventObject.trigger("TestEvent");
     }
     catch (MissingArgumentException e){
          fail(e.message);
     }
}

private void function handlerForTestTriggerPassesEventInEventObjToHandler(required struct event){
     if (!structKeyExists(arguments.event, "event")){
          throw(type="MissingArgumentException", message="Event name not passed to handler");
     }
     if (arguments.event.event != "TestEvent"){
          throw(type="InvalidEventException", message="Event object did not contain correct event name");
     }
}

And, like yesterday and further above... the code to deal with these requirements is trivial:

trigger = function(required string event){
     if (structKeyExists(eventContainer, event)){
          for (eventEntry in eventContainer[event]){
               var eventObj = {
                    event = event
               };
               eventEntry.handler(event=eventObj);
          }
     }
}

To round out the event-object-handling side of things, we need to allow the on() call to pass data which then finds its way into the event object ultimately passed to the handler when it's called. The test for this is a variation of the test for the event being passed:

public void function testTriggerPassesDefaultDataInEventObjToHandler() {
     variables.eventObject.on("TestEvent", function(){
          if (!structKeyExists(arguments.event, "data")){
               throw(type="MissingDataException", message="Data not passed to handler");
          }
     });
     try {
          variables.eventObject.trigger("TestEvent");
     }
     catch (MissingDatatException e){
          fail(e.message);
     }
}

public void function testTriggerPassesSpecificDataInEventObjToHandler() {
     var testData = {key="value"};
     variables.eventObject.on(
          "TestEvent",
          function(){
               if (!structKeyExists(arguments.event, "data")){
                    throw(type="MissingDataException", message="Data not passed to handler");
               }
               if (!structKeyExists(arguments.event.data, "key")){
                    throw(type="InvalidDataException", message="key not found in data");
               }
               if (arguments.event.data.key != "value"){
                    throw(type="InvalidDataException", message="key has incorrect value");
               }
          },
          testData
     );
     try {
          variables.eventObject.trigger("TestEvent");
     }
     catch (MissingArgumentException e){
          fail(e.message);
     }
}

Here I've got two tests. And this is a key part of unit testing. Let's have a look at the code to implement data support before I continue:

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){
               if (structKeyExists(eventContainer, event)){
                    for (eventEntry in eventContainer[event]){
                         var eventObj = {
                              event = event,
                              data = eventEntry.data
                         };
                         eventEntry.handler(event=eventObj);
                    }
               }
          }
     };
};

You'll note that the data argument of on() has a default. This creates two logic branches we need to test: the default situation, and the non-default situation. I'll do a specific article on test variations later on. But it should be clear that there's two tests to be done here.

All our tests are green, so we can move on. I've belaboured this a bit now (mostly in the previous article), but isn't it reassuring that whenever we make a change to our code that we can "know" that it's not had any regressions?

The last thing I'm gonna test in this article is that the trigger() call should also take some situational-specific data. For argument's sake you've run a function to get some data and are now announcing "I've got the data" (this is what the trigger() function does: announce something has happened). It's all well and good to announce that you've got the data. You also need to pass it to the event handlers that are waiting for the event to occur...

Here's the testing...

Firstly we write a test for the default behaviour (trigger() passing no additional data, which is completely legit):

public void function testTriggerDefaultsAdditionalParameters() {
     variables.eventObject.on(
          "TestEvent",
          function(){
               assertEquals(
                    "event",
                    structKeyList(arguments),
                    "trigger() passed unexpected arguments"
               );
          }
     );
     try {
          variables.eventObject.trigger("TestEvent");
     }
     catch (MissingArgumentException e){
          fail(e.message);
     }
}

But we have a "problem": this test already passes. And remember that one of the tenets of TDD is that a test should fail when it's first written, because a test is always testing new code, so it intrinsically must fail when the test is in place, but the code is not. I dunno quite what to say here... I think this is a legit test, but the intended default behaviour of the new code is that it behaves the same as before, as if the code was not there. I can only surmise that the intent with TDD is that when planning a new feature one should implement ALL the tests for the new feature, and as long as at least one test fails, then there's coverage for the feature? I'll need to sharpen my TDD understanding here, I think. Anyway... the non-default test certainly fails at the moment:

public void function testTriggerPassesAdditionalParameters() {
     var additionalParams ={
          triggerParam1 = "triggerParam1Value",
          triggerParam2 = "triggerParam2Value"
     };

     variables.eventObject.on(
          "TestEvent",
          function(){
               var argNames = structKeyList(arguments);
               if (listSort(argNames, "textNoCase") != "event,triggerParam1,triggerParam2"){
                    throw(type="UnexpectedArgumentsException", message="trigger() passed unexpected arguments: #argNames#");
               }
          }
     );

     try {
          variables.eventObject.trigger("TestEvent", additionalParams);
     }
     catch (UnexpectedArgumentsException e){
          fail(e.message);
     }
}

So I guess my position here is that when testing for a new feature, as long as at least one test fails, then the tests are OK. Although I must admit I'm not completely convinced of this, so I'll need to think about that. What do you think? Or do you know better than me (which is entirely reasonable: I'm giving this lot far more thought in this blog series than I do in my day-to-day work!)... in which case what's the model answer?

Here's the code to make the tests work:

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);
          }
     }
}

At this point, I think I have all my bases covered, as far as my original spec goes. I can fire off as many events as I like, I can listen for each event multiple times, and I can pass bind-time- and trigger-time-specific data to all of them. Job done.

There's some interesting metrics here:

  • the actual logic to do all that is encompassed in only 23 lines of code. That's a usable event-driven system in CFML in 23 lines of code. CFML rocks.
  • The testing for this encompasses 18 variations in behaviour.
  • And the testing is 300-odd lines of code (including the base CFC and the assertStructKeysCorrect() assertion). Bear in mind I use Nadel-spacing for my assertions, and most people would not.

Test code does generally take up more space than actual code. but the thing is with the assertion approach to things, test code is very simple: set variables, call functions, make assertions. A lot of that test code I wrote was copy?paste?modify. It's easy. This is fine thought, because testing rules should be a simple thing: the rules themselves are complex, but the inputs and the outputs should be well-defined and simple to process. If your rule is taking too many inputs or there are a lot of output variations: you probable need to refactor your code. But I'll get to that.

I'll sign-off here because Wales lost the match ages ago, and I'm now up to about my fifth pint of The Black. I realise I need to add a new returned-function to my createEventObject() function: it's lovely that I can bind event handlers to an event with on()... but there's no way to change my mind. I need an off() function. Like how JQuery does it.

The more I write / assess, the more I realise there's a lot of interesting facets to TDD I need specific examples to cover because they won't crop up in this example I'm using. You've probably thought of some yourself. I'll cover all that (and you better ask me to cover stuff that's occurred to you, lest I miss something), but I'll finish-out my process of writing this createEventObject() function first.

OK. I'm gonna focus on Guinness and... well... perhaps talking to humans instead of being nose-down in this computer.

Righto.

--
Adam