Let's Write a Jasmine Matcher!

Let's write a Jasmine matcher!

I know what Jasmine is, but what is a matcher?

A matcher is used to evaluate an assertion on an object. If the object matches, the assertion passes, if it doesn't match, the assertion fails.

Okay, so it's like when you say expect(x).toEqual(y).

Right, in this case toEqual is the matcher being used. There's already a useful list of built-in matchers, but now we want to write one of our own.

Okay, so what should our matcher do?

Well, I want to be able to say

expect(saidArray).toHaveItemsBeInThisOrder([4, 5, 6])

and that means saidArray has each of 4, 5, and 6 as elements and that the index of 4 should precede that of 5, and the index of 5 should in turn precede that of 6.

First Attempt

Although in the documentation it says to add the matcher within the context of a spec using the addMatchers method, for this case I think it makes sense to add the matcher globally, and, to do that, we simply add a method to the prototype of jasmine.Matchers.

jasmine.Matchers.prototype.toHaveItemsBeInThisOrder = function(){
    // implementation here
}

A matcher method should take this.actual - the value that is being verified, and match it against the expected value - the method's arguments. Our first attempt looks like this

jasmine.Matchers.prototype.toHaveItemsBeInThisOrder = function(){
    var arr = this.actual
      , expectedItems = arguments
    for (var i = 0, len = expectedItems.length - 1; i < len; i++){
        var first = expectedItems[i]
          , second = expectedItems[i + 1]
        if (arr.indexOf(first) === -1 || arr.indexOf(second) === -1)
            return false
        if (arr.indexOf(first) >= arr.indexOf(second)) return false
    }
    return true
}

In this code, we loop through the expected items(from arguments) and make sure that

  1. each element from the expectedItems array belongs in the actual array(using indexOf).
  2. for each pair of adjacent items in expectedItems, the index of the first in the actual array is before that of the second - this ensures the expected ordering.

Now to test this. First we test for presence

expect([1,2,3,4]).toHaveItemsBeInThisOrder(1) // pass
expect([1,2,3,4]).toHaveItemsBeInThisOrder(2) // pass
expect([1,2,3,4]).toHaveItemsBeInThisOrder(5) // fail

Next we test for ordering

expect([1,2,3,4]).toHaveItemsBeInThisOrder(1,2,3,4) // pass
expect([1,2,3,4]).toHaveItemsBeInThisOrder(1,3,4) // pass
expect([1,2,3,4]).toHaveItemsBeInThisOrder(2,3,4) // pass
expect([1,2,3,4]).toHaveItemsBeInThisOrder(1,4,3) // fail

Second Attempt

Okay, cool. But there's something I am uncomfortable about - in particular the use of Array.prototype.indexOf: it poses a couple of problems

  1. Array.prototype.indexOf is not supported in older IEs
  2. I would like to use Jasmine's toEqual matcher's equality test to match items in the array, with which equivalent array literals and object literals can be matched and also you could match classes of objects using jasmine.any(clazz) - where clazz is a Javascript type such as Number, String, Function or a Javascript function acting as a constructor.

I looked up Jasmine's source and found where the equality test for toEqual were implemented - it was under jasmine.Env.prototype.equals_. Jasmine has an Env object which is the global configuration object for the test suite to be run within. Turns out, it is also the place where various helper methods are placed - including equals_. I felt it would make sense to attach my own helper method indexOf_ to it

jasmine.Env.prototype.indexOf_ = function(arr, item){
    for (var i = 0, len = arr.length; i < len; i++){
        if (this.equals_(arr[i], item))
            return i
    }
    return -1
}

Because I wrote indexOf_ as a method of jasmine.Env, I could easily make use of its equals_ method just by accessing it through this.

Now, let's integrate this method into our matcher

jasmine.Matchers.prototype.toHaveItemsBeInThisOrder = function(){
    var arr = this.actual
      , expectedItems = arguments
      , env = this.env
    for (var i = 0, len = expectedItems.length - 1; i < len; i++){
        var idxFirst = env.indexOf(arr, expectedItems[i])
          , idxSecond = env.indexOf(arr, expectedItems[i + 1])
        if (idxFirst === -1 || idxSecond === -1)
            return false
        if (idxFirst >= idxSecond) return false
    }
    return true
}

Notice that the env was readily accessed through the current context - which is an instance of matcher. Now, let's see if that worked - it should now match identical array literals

expect([[1,2],[3,4]]).toHaveItemsBeInThisOrder([1,2],[3,4]) // pass
expect([[1,2],[3,4]]).toHaveItemsBeInThisOrder([3,4],[1,2]) // fail

Cool.

Third Attempt

That was great, but now I want to write another matcher. In this matcher I would write

expect(saidSpy).toHaveCallArgsBeInThisOrder(['foo'], ['bar'])

and it should verify that saidSpy - a Jasmine spy - was first called with the arguments 'foo', and then with 'bar'.

Even though this matcher should be easily derived from the one we just wrote - toHaveItemsBeInThisOrder - I couldn't find an easy way to reuse it directly. The cleanest solution I've found to reuse code was to refactor the matcher logic out to a helper method on jasmine.Env.prototype

jasmine.Env.prototype.arrayItemsInThisOrder_ = function(arr, expectedItems){
    for (var i = 0, len = expectedItems.length - 1; i < len; i++){
        var idxFirst = this.indexOf(arr, expectedItems[i])
          , idxSecond = this.indexOf(arr, expectedItems[i + 1])
        if (idxFirst === -1 || idxSecond === -1)
            return false
        if (idxFirst >= idxSecond) return false
    }
    return true    
}
jasmine.Matchers.prototype.toHaveItemsBeInThisOrder = function(){
    return this.env.arrayItemsInThisOrder_(this.actual, arguments)
}

The matcher method toHaveItemsBeInThisOrder now simply makes use of the new helper method arrayItemsInThisOrder_. Now, this enables us to write the new matcher method as

jasmine.Matchers.prototype.toHaveCallArgsBeInThisOrder = function(){
    return this.env.arrayItemsInThisOrder_(this.actual.argsForCall, arguments)
}

Where argsForCall is where the spy's call arguments are stored as an array. Test it out and we get

var f = jasmine.createSpy('f')
f('foo')
f('bar')
expect(f).toHaveCallArgsBeInThisOrder('foo', 'bar') // pass
expect(f).toHaveCallArgsBeInThisOrder('bar', 'foo') // fail

Fourth Attempt

Almost done, but not quite. At this point, when an assertion fails with our matcher, you get output like this

Expected spy on push to have call args be in this order [ ['bar'], ['foo'] ]

but it doesn't tell us what the actual values were. To show the actual values also, we will need to customize the error messages, and to that, we attach a message method to the matcher instance inside the matcher method like this

jasmine.Matchers.prototype.toHaveCallArgsBeInThisOrder = function(){
    this.message = function() {
        var args = Array.prototype.slice.apply(arguments)
        return [
            'Expected spy ' + this.actual.identity + ' to have call args be in this order ' + jasmine.pp(args) +
            ' but they were ' + jasmine.pp(this.actual.argsForCall),
            'Expected spy ' + this.actual.identity + ' to have call args not be in this order ' + jasmine.pp(args) +
            ' but they were ' + jasmine.pp(this.actual.argsForCall)
        ]
    }
    
    return this.env.arrayItemsInThisOrder_(this.actual.argsForCall, arguments)
}

The message method should return an array of two values: an error message for the positive matcher (expected it to be...) and another for the negative matcher (expected it not to be...). With this, the error message will look like this

Expected spy on push to have call args be in this order [ ['bar'], ['foo'] ] but they were [ ['foo'], ['bar'] ].

Very nice. I am happy with this.

Conclusion

To recap, we have

  1. Written a simple Jasmine matcher.
  2. Made use of existing helper methods attached to Jasmine's env object.
  3. Written a custom helper method attached to jasmine.Env.prototype.
  4. Refactored matcher logic as another helper method to allow reuse of logic by more than one matcher.
blog comments powered by Disqus