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
- each element from the
expectedItems
array belongs in the actual array(usingindexOf
). - 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
Array.prototype.indexOf
is not supported in older IEs- 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 usingjasmine.any(clazz)
- where clazz is a Javascript type such asNumber
,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
- Written a simple Jasmine matcher.
- Made use of existing helper methods attached to Jasmine's
env
object. - Written a custom helper method attached to
jasmine.Env.prototype
. - Refactored matcher logic as another helper method to allow reuse of logic by more than one matcher.