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