Suppose you have a function that hits Google’s book service to conduct a search. You would like to test this function for various cases: no results, one result, lots of results, or even a service failure. But you don’t want to be dependent on Google’s service in your unit tests. I’ll show you how to use jasmine’s spies to mock the service so that you can get the test coverage you desire.

The First Test

1
2
3
4
5
6
7
describe('No results', function(){
  it('Should have zero results', function(){
    var results = search('Harry');
    expect(results.length).toBe(0);
  });
});

Wait. Where’s the mock? In true TDD fashion, we don’t need it yet! Remember, do the simplest thing to get the test to pass. The simplest thing is:

1
2
3
4
var search = function(book){
  return [];
};

Some people think this is a waste of time. I think of it as “getting the juices flowing”. You have to start somewhere, so smart small!1

The Second Test

1
2
3
4
5
6
7
describe('One Result', function(){
  it('Should have one result', function(){
    var results = search('Harry');
    expect(results.length).toBe(1);
  });
});

It’s time to introduce a mock. But before we do, let’s spend some time thinking about how we want our solution to look.

The ajax call

Let’s suppose our application uses jQuery for making the ajax calls. Typical jQuery ajax looks something like this:

1
2
3
4
5
6
7
8
$.ajax('some url', requestParams)
  .done(function(response){
    //Do something with the response
  })
  .fail(function(response){
    //Let the user know something went wrong
  });

Let’s go ahead and add this to our search function to see what it’s like (but only the done callback, as we are test-driving our code):

1
2
3
4
5
6
7
var search = function(book){
  $.ajax('google api url', {search: book})
    .done(function(books){
      // Do something with the response
    });
};

Close, but we lost our return statement! The current code simply returns an empty array. But because we are making an asynchronous call to an external service, we are introducing time into our function. This means we won’t know when we’ll get our search results back.

To handle this situation, I too often see jQuery code inside of done callbacks that modify the DOM. This mixes presentation logic into your code, and leads to systems that are difficult to understand and change.

There are JavaScript frameworks that can perform two-way data binding between JavaScript objects and the DOM. These frameworks do an excellent job at separating presentation concerns from business concern s. knockout, Angular and Ember are all popular frameworks that do this. These frameworks all encourage good JavaScript coding practices. Since I am most familiar with knockout, I will follow their conventions.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
function BookService(){
  var self = this;

  self.books = [];

  self.search = function(book){
    $.ajax('google api url', {search: book})
      .done(function(books){
        self.books = books;
      })
  };
}

Up until this point, I wasn’t explicit about where the search function lived. BookService will encapsulate the logic for searching the books, and also expose a collection of the books (through the books property) found through the search method.

The books property can be bound to some HTML (perhaps using knockout’s foreach binding or angular’s ngRepeat directive). In the done callback of our search method, the books property will receive the response from the service, and then the JavaScript framework will ensure that the DOM is updated.

Now that we have a good way to handle the response from the ajax call, let’s mock it in our unit tests so that the service call isn’t actually executed.

Creating the Spy

The jQuery ajax methods return what is called a Deffered object that follows the Promise pattern. We will mock out the jQuery ajax call, and return our own fake promise.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
describe('No results', function(){
  it('Should have zero results', function(){
    var d = $.Deferred();
    d.resolve([]);

    spyOn($, 'ajax').and.returnValue(d.promise());

    var bookService = new BookService();
    bookService.search('Harry');
    expect(bookService.books.length).toBe(0);
  });
});

describe('One Result', function(){
  it('Should have one result', function(){
    var d = $.Deferred();
    d.resolve(['Harry']);

    spyOn($, 'ajax').and.returnValue(d.promise());

    var bookService = new BookService();
    bookService.search('Harry');
    expect(bookService.books.length).toBe(1);
  });
});

Let’s go ahead and remove the duplication to make subsequent tests a little easier to write (and more readable).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function respondWith(bookResponse){
  var d = $.Deferred();
  d.resolve(bookResponse);
  spyOn($, 'ajax').and.returnValue(d.promise());

  return new BookService();
}

describe('No results', function(){
  it('Should have zero results', function(){
    var bookService = respondWith([]);
    bookService.search('Harry');
    expect(bookService.books.length).toBe(0);
  });
});

describe('One Result', function(){
  it('Should have one result', function(){
    var bookService = respondWith(['Harry']);
    bookService.search('Harry');
    expect(bookService.books.length).toBe(1);
  });
});

The Final Code

Included below is the final code, including a test for lots of records returned, and a test for when the service would fail.

Test Code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
describe('Book service', function(){
  'use strict';

  describe('Successful calls', function(){
    var ajaxSpy = spyOn($, 'ajax');

    function respondWith(bookResponse){
      var d = $.Deferred();
      d.resolve(bookResponse);
      ajaxSpy.and.returnValue(d.promise());

      return new BookService();
    }

    describe('No results', function(){
      var bookService = respondWith([]);
      bookService.search('Harry');

      it('Should have zero results', function(){
        expect(bookService.books.length).toBe(0);
      });

      it('Should have a friendly message', function(){
        expect(bookService.message).toBe('No books found searching "Harry"');
      });
    });

    describe('One Result', function(){
      var bookService = respondWith(['Harry']);
      bookService.search('Harry');

      it('Should have one result', function(){
        expect(bookService.books.length).toBe(1);
      });

      it('Should have a friendly message', function(){
        expect(bookService.message).toBe('An exact match for "Harry" was found');
      });
    });

    describe('Lots of results', function(){
      var foundBooks = _.range(1,101).map(function(i) { return 'Harry ' + i; });
      var bookService = respondWith(foundBooks);
      bookService.search('Harry');

      it('Should have lots of results', function(){
        expect(bookService.books.length).toBe(100);
      });

      it('Should have a friendly message', function(){
        expect(bookService.message).toBe('We found 100 matches for "Harry"');
      });
    });
  });

  describe('Failed calls', function(){
    describe('Service is down', function(){
      it('Should report an error', function(){
        var d = $.Deferred();
        d.reject();

        spyOn($, 'ajax').and.returnValue(d.promise());

        var bookService = new BookService();
        bookService.search('Harry');
        expect(bookService.message).toBe('An error occurred');
      });
    });
  });
});

Production Code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function BookService(){
  'use strict';

  var self = this;

  self.message = '';
  self.books = [];

  self.search = function(book){
    $.ajax('google api url', {search: book})
      .done(function(books){
        self.books = books;
        if(books.length === 0) {
          self.message = 'No books found searching "' + book + '"';
        }
        else if(books.length === 1){
          self.message = 'An exact match for "' + book + '" was found';
        }
        else {
          self.message = 'We found ' + books.length + ' matches for "' + book + '"';
        }
      })
      .fail(function(response){
        self.message = 'An error occurred';
      });
  };
}


  1. I have never been in situations where I though I started too small; but I have definitely been in situations where I have started too large! [return]