Mocking Fluent objects in Javascript

Fluent interfaces are an elegant way of expressing functionality in Javascript. Unfortunately, when it comes to stubbing and instrumenting these method calls, one may find it next to impossible to do so – resulting in one or more of the following less-than-ideal outcomes:

  1. Using said library sans fluent interface.
  2. Selecting an alternative on the sole basis of having a non-fluent interface.
  3. Not testing that snippet of functionality altogether.

In this post, we shall mock-up a fluent library called SuperAgent for testing.

A typical SuperAgent call:

superAgent('POST', 'http://example.com/api/todo')
  .type('json')
  .send({
    title: 'Buy Milk',
    timestamp: (new Date()).toISOString()
  })
  .end(function(res) {
    alert((res.ok) ? 'All Good' : 'Something bad happened');
  });

While the examples are based on Jasmine, the principles should be applicable in any decent testing framework.

// First we break the fluent mock into two components

// 1. Its fluent methods
var mockFluentMethods = {
  type: function() { return mockFluentMethods; },
  send: function() { return mockFluentMethods; },
  end:  function() { return mockFluentMethods; }
};

// 2. The "constructor" call
var mockSuperAgent = jasmine.createSpy().andCallFake(function() {
  return mockFluentMethods;
});

Stepping through our mockSuperAgent:

mockSuperAgent('POST', 'http://url/')
// { type: [Function], send: [Function], end: [Function] }

  .type('json')
  // { type: [Function], send: [Function], end: [Function] }

  .send({})
  // { type: [Function], send: [Function], end: [Function] }
  
  // ...
  // on and on!

Now we’re all set to spy on and verify that the methods have been called with the expected values:

spyOn(mockFluentMethods, 'type').andCallThrough();
spyOn(mockFluentMethods, 'send').andCallThrough();
spyOn(mockFluentMethods, 'end' ).andCallThrough();


// Action!
widget.send({}, mockSuperAgent);


expect(mockSuperAgent)
  .toHaveBeenCalledWith('POST', 'http://url/');

expect(mockFluentMethods.type)
  .toHaveBeenCalledWith('json');

expect(mockFluentMethods.send)
  .toHaveBeenCalledWith({});