The Making of a Test-Driven Grocery List Application in JS: Part X
2013 March 6th by todd anderson
This is the tenth installment in a series of building a Test-Driven Grocery List application using Jasmine and RequireJS. To learn more about the intent and general concept of the series please visit The Making of a Test-Driven Grocery List Application in JavaScript: Part I
—
Introduction
When we last left, we properly modified list-controller
to support event notification upon change to its collection as well as created a storage-service
communication layer with localStorage
. That gave us some great passing tests, but nothing to show off as the service was not integrated into the Grocery List application. In this article, we’ll do just that, but…
Before we hook up list-controller events to storage-service operations…
We need a way to supply the list-controller
with the stored items. The list-controller
has a createNewItem()
method, but no methods to provide a previously created item. Since we are not burdening the list-controller
in communicating with the storage-service
directly, we’ll need to open up the API to allow items to be added – at least at the onset.
Tests
First we’ll include all our tests in our specrunner again as changes to list-controller
may impact tests across multiple specs. And while we are poking around, let’s add a spec suite for setItems()
on the list-controller
and watch it fail:
/test/jasmine/spec/list-controller.spec.js
describe('setItems()', function() {
var itemOne = modelFactory.create(),
itemTwo = modelFactory.create();
beforeEach( function() {
itemOne.name = 'apples';
itemTwo.name = 'oranges';
listController.setItems([itemOne, itemTwo]);
});
afterEach( function() {
listController.getItemList().removeAll();
});
it('should fill list with provided items', function() {
var items = listController.getItemList();
expect(items.itemLength()).toEqual(2);
expect(items.getItemAt(0)).toBe(itemOne);
expect(items.getItemAt(1)).toBe(itemTwo);
});
});
This simple first spec tells us that an array of items can be provided to the list-controller
using setItems()
and should be accessible by its collection. And we fail with no surprises:
list-controller modification
Throughout this series I have employed a quasi-“TDD as if you mean it” approach when creating new components and modifying the API on existing ones. With this modification to the list-controller
, I am going to stick to getting the tests to pass by modifying the list-controller
directly as I feel it is going to get a little more involved and will require some refactoring that would be better suited by focusing on true implementation.
With that said, let’s modify list-controller
to get that new spec passing:
/script/controller/list-controller.js
listController = {
$view: undefined,
getItemList: function() {
return collection;
},
getRendererFromItem: function(item) {
var i = rendererList.itemLength(),
renderer;
while( --i > -1 ) {
renderer = rendererList.getItemAt(i);
if(renderer.model === item) {
return renderer;
}
}
return undefined;
},
createNewItem: function() {
var model = modelFactory.create();
collection.addItem(model);
return model;
},
removeItem: function(item) {
return collection.removeItem(item);
},
setView: function(view) {
this.$view = (view instanceof $) ? view : $(view);
},
setItems: function(items) {
collection = collectionFactory.create(items);
}
};
Well, that was easy enough!
Tests
Not so fast… I think our single spec may be deceiving our expectations. Let’s add a few more and make sure we are on the right path. To start, we expect changes to these new models to propagate events when it is modified – such as in the work we have done previously.
/test/jasmine/spec/list-controller.spec.js
async.it('should dispatch events of property-change from provided items', function(done) {
var items = listController.getItemList(),
itemOne = items.getItemAt(0);
$(listController).on('save-item', function(event) {
$(listController).off('save-item');
expect(event.item).toBe(itemOne);
done();
});
itemOne.marked = true;
});
This spec tells us that changes to an item should be notified through the list-controller
– basically the work we had done previously in getting the list-controller
to dispatch events related to its underlying collection so as to be captured by observing parties.
This test actually reveals some refactoring that is required within the list-controller
. In essence, creating a new collection from the provided items in setItems()
is not enough to have the application work as expected – each individual item needs to be managed by a list-item-controller
which responds and notifies of changes accordingly. We had previously paired an item with a item controller within the collection-change
event handler of the collection in list-controller
:
/script/controller/list-controller.js
(function assignCollectionHandlers($collection) {
var EventKindEnum = collectionFactory.collectionEventKind,
isValidValue = function(value) {
return value && (value.hasOwnProperty('length') && value.length > 0);
};
$collection.on('collection-change', function(event) {
var model,
itemController,
$itemController,
$itemView;
switch( event.kind ) {
case EventKindEnum.ADD:
$itemView = $('<li>');
model = event.items.shift();
itemController = itemControllerFactory.create($itemView, model);
$itemController = $(itemController);
$itemView.appendTo(listController.$view);
rendererList.addItem(itemController);
$(listController).trigger(createSaveEvent(model));
itemController.state = itemControllerFactory.state.EDITABLE;
$itemController.on('remove', function(event) {
listController.removeItem(model);
});
$itemController.on('commit', function(event) {
if(!isValidValue(model.name)) {
listController.removeItem(model);
}
else {
$(listController).trigger(createSaveEvent(model));
}
});
break;
case EventKindEnum.REMOVE:
model = event.items.shift();
itemController = listController.getRendererFromItem(model),
$itemController = $(itemController);
if(itemController) {
$itemView = itemController.parentView;
$itemView.remove();
itemController.dispose();
$itemController.off('remove');
$itemController.off('commit');
rendererList.removeItem(itemController);
$(listController).trigger(createRemoveEvent(model));
}
break;
case EventKindEnum.RESET:
break;
}
});
}($(collection)));
That IIFE was run in the module prior to returning the list-controller
instance. Now, we could just copy that code from the EventKindEnum.ADD
case and shove it into setItems()
, applying it to each item in a loop, but that wouldn’t be very efficient, not to mention a cry-inducing solution for anyone (including yourself) which need to revisit your code.
list-controller refactor
I think we are going to have to get rid of this IIFE, but let’s do that modification in piecemeal; first, let’s strip out the item management when added to a collection:
/script/controller/list-controller.js
var collection = collectionFactory.create(),
rendererList = collectionFactory.create(),
manageItemInList = function(item, listController) {
var $itemView = $('<li>'),
itemController = itemControllerFactory.create($itemView, item),
$itemController = $(itemController),
isValidValue = function(value) {
return value && (value.hasOwnProperty('length') && value.length > 0);
};
$itemView.appendTo(listController.$view);
rendererList.addItem(itemController);
$itemController.on('remove', function(event) {
listController.removeItem(item);
});
$itemController.on('commit', function(event) {
if(!isValidValue(item.name)) {
listController.removeItem(item);
}
else {
$(listController).trigger(createSaveEvent(item));
}
});
return itemController;
},
listController = {
$view: undefined,
...
};
Most of what was held in the EventKindEnum.ADD
case of the collection-change
handler has been moved to its own function expression – manageItemInList()
. If we look at how this case is modified we see that we have left state initialization and event dispatching:
/script/controller/list-controller.js
case EventKindEnum.ADD:
model = event.items.shift();
itemController = manageItemInList(model, listController);
itemController.state = itemControllerFactory.state.EDITABLE;
$(listController).trigger(createSaveEvent(model));
break;
When an item is added to the collection and the list-controller is notified, it creates a new list-item-controller
using manageItemInList()
, sets the controller’s state to EDITABLE
and notifies of its addition. The last two operations are of note, as they only pertain to new additions to the collection – we don’t want such things for existing items being added to the list from setItems()
.
/script/controller/list-controller.js
setItems: function(items) {
var i, length = items.length;
collection = collectionFactory.create();
for( i = 0; i < length; i++ ) {
manageItemInList(items[i], this);
collection.addItem(items[i]);
}
}
Now if we run the tests again:
Passing!
Tests
I don’t think we are out of the woods yet, however… Let’s continue to add more expectations about setting the collection through setItems()
on list-controller
:
/test/jasmine/spec/list-controller.spec.js
async.it('should dispatch event of remove-item from collection', function(done) {
$(listController).on('remove-item', function(event) {
$(listController).off('remove-item');
expect(event.item).toBe(itemOne);
done();
});
listController.removeItem(itemOne);
});
This test ensures that the list-controller
should still be responding to and notifying of changes to the new collection created through setItems()
just as it should if the list-controller
was only being instructed to modify the collection through calls to createNewItem()
.
list-controller refactoring
To save you some time in downloading more images, believe me when I tell you I just put us back in red The reason being that dang assignCollectionHandlers
IIFE. The collection that is created in setItems()
is not being observed. The IIFE to assign events handlers is only run upon load of the module and only targets the collection instantiated in its declaration. In other words, any new collections will not be observed.
I say we move that IIFE out into its own expression:
/script/controller/list-controller.js
var collection = collectionFactory.create(),
rendererList = collectionFactory.create(),
assignCollectionHandlers = function($collection) {
var EventKindEnum = collectionFactory.collectionEventKind;
$collection.on('collection-change', function(event) {
var model,
itemController,
$itemController,
$itemView;
switch( event.kind ) {
case EventKindEnum.ADD:
model = event.items.shift();
itemController = manageItemInList(model, listController);
itemController.state = itemControllerFactory.state.EDITABLE;
$(listController).trigger(createSaveEvent(model));
break;
case EventKindEnum.REMOVE:
model = event.items.shift();
itemController = listController.getRendererFromItem(model),
$itemController = $(itemController);
if(itemController) {
$itemView = itemController.parentView;
$itemView.remove();
itemController.dispose();
$itemController.off('remove');
$itemController.off('commit');
rendererList.removeItem(itemController);
$(listController).trigger(createRemoveEvent(model));
}
break;
case EventKindEnum.RESET:
break;
}
});
},
manageItemInList = function(item, listController) {
// implementation removed to reduce noise
},
listController = {
// implementation removed to reduce noise
}
We basically took what was the named assignCollectionHandlers
IIFE and added it to the variable declarations within the list-controller
module. That changes the code between those declarations and the return of the listController
instance to:
/script/controller/list-controller.js
var collection = collectionFactory.create(),
rendererList = collectionFactory.create(),
assignCollectionHandlers = function($collection) {
// implementation removed to reduce noise
},
manageItemInList = function(item, listController) {
// implementation removed to reduce noise
},
listController = {
// implementation removed to reduce noise
};
assignCollectionHandlers($(collection));
return listController;
With those changes we are still failing on the last spec we created, but more importantly we have not caused any other tests to fail!
Let’s get that last spec to pass:
/script/controller/list-controller.js
setItems: function(items) {
var i, length = items.length;
collection = collectionFactory.create();
for( i = 0; i < length; i++ ) {
manageItemInList(items[i], this);
collection.addItem(items[i]);
}
assignCollectionHandlers($(collection));
}
Hurrah!
Tagged 0.1.13: https://github.com/bustardcelly/grocery-ls/tree/0.1.13
Hooking it all together
We have created our storage-service
to communicate with localStorage
, modified list-controller
to dispatch events and accept initial items for its collection and all our tests are still passing! It’s a wonderous feeling. Now let’s get to actually hooking them up so that the storage-service
is told how to handle changes to the list by responding to list-controller
events.
Normally, in such situations I would create another component to the application that would serve as an mediator for such integration, receiving events from list-controller
and invoking the storage-service
. Naturally, that would also call for more tests in assuring that the mediator did its job correctly. I am not going to do that here. This is a small application meant for our own personal use and this series has gotten quite long; I don’t want to scare you away by adding more dependencies, but I would encourage you to do so on your own if you feel so…. just don’t forget the tests!
I think modifying the main JavaScript file (/script/grocery-ls.js) that defines the module dependencies and initializes the Grocery List application is fine enough for the task at hand:
/script/grocery-ls.js
(function(window, require) {
require.config({
baseUrl: ".",
paths: {
"lib": "./lib",
"script": "./script",
"jquery": "./lib/jquery-1.8.3.min"
}
});
require( ['jquery', 'script/controller/list-controller', 'script/service/storage-service'],
function($, listController, storageService) {
var $listController = $(listController);
listController.setView($('section.groceries ul'));
storageService.getItems().then(function(items) {
listController.setItems(items);
});
$listController.on('save-item', function(event) {
storageService.saveItem(event.item).then(function(item) {
console.log('Item saved! ' + item.name);
}, function(error) {
console.log('Item not saved: ' + error)
});
});
$listController.on('remove-item', function(event) {
storageService.removeItem(event.item).then(function(item) {
console.log('Item removed! ' + item.name);
}, function(error) {
console.log('Item not removed: ' + error);
});
});
$('#add-item-button').on('click', function(event) {
listController.createNewItem();
});
});
}(window, requirejs));
Just a slight modification to the main file. We added storage-service
as an initial dependency, request and supply stored items to the list-controller and respond to save-item
and remove-item
events, forwarding actions along to the storage-service
appropriately.
If we run the application now, we can add, mark-off, remove items from the list. Same as before, but now, if we refresh the page, items and their state a persisted!
It may look a little different than your if you have been following along in the code. I added some nice styling and committed it to the repo.
Tagged 0.2.0 : https://github.com/bustardcelly/grocery-ls/tree/0.2.0
Conclusion
We have completed our Grocery List application and have it fully tested (well, hopefully ). We now have a grocery list that we can curate and is persisted in the browser. It should be noted that it is not persistent across browsers, plural – so make sure to open it in the same browser on your mobile device when creating the list and shopping. I am most likely going to whip up a little server to persist the list remotely, but am not going to document that in this series. It may end up in the github repo eventually, however, so keep an eye out.
Thanks for sticking around in this long series (ten parts!) of building an application by trying to adhere to TDD. I may have gone off course here and there, but I hope it was informative in any way.
Cheers!
—-
Link Dump
Reference
Test-Driven JavaScript Development by Christian Johansen
Introducing BDD by Dan North
TDD as if you Meant it by Keith Braithwaite
RequireJS
AMD
Jasmine
Sinon
Jasmine.Async
Post Series
grocery-ls github repo
Part I – Introduction
Part II – Feature: Add Item
Part III – Feature: Mark-Off Item
Part IV – Feature: List-Item-Controller
Part V – Feature: List-Controller Refactoring
Part VI – Back to Passing
Part VII – Remove Item
Part VIII – Bug Fixing
Part IX – Persistence
Part X – It Lives!
Posted in AMD, JavaScript, RequireJS, grocery-ls, jasmine.