The Making of a Test-Driven Grocery List Application in JS: Part IX
2013 February 15th by todd anderson
This is the ninth 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
In the previous article, we pretty much wrapped up all the user-based functionality and ended with a working Grocery List application that we could start using. There is one little snag though… no persistance. If you made this gloriously elaborate list that detailed everything you needed at the store, then closed the browser and reopened it, the list was gone! That will not do.
There are many factors and paradigms to consider in choosing the level of persistence when it comes to handling session and user based applications. Without introducing a discussion about authentication, when approaching the integration persistence you have to take into account system-based vs user-based persistence, client-side vs server-side storage, and – nowadays, more commonly – the cross pollination of the two: occasional-connectivity. (not to mention browser support in all this) We won’t be getting into all of that We’ll be using the localStorage
of today’s modern browser.
The intent of this article in the series is to implement client-side, browser-based persistence for the Grocery List application. It would be nice to store our list remotely so it can be accessed by all browsers on all devices, but I feel it would introduce too many new libraries, software and concepts to this series. I will most likely add it personally after this series is over, and I invite you to as well – keeping in mind to do it using TDD The most I can offer at this point, is to keep the code we will write clean enough to support such future endeavours.
What Tests to Modify to Get Us There?
Good question. Let’s first think about what actions will prompt an update to the list in storage. Actually, if we look at the feature specs we have created throughout this series and separated out into the /feature
directory itself, we pretty much have all the defined actions that will trigger an update to the stored list:
All of these features are a result from interacting with the list-controller
. My first inkling is to add responsibility to the list-controller
so that, along with the other operations it handles in list management, it communicates with a service layer to update the Grocery List in storage. However, I think that would add too much burden on the list-controller
and, when taking into account that requirements around storage may change, the introduction of such complexity into the list-controller
may quickly make our tests feel chaotic.
As such, I propose that we should start off with the expectation that the list-controller
will notify of its underlying collection having been modified, not only upon its change in length, but of the items within the collection, as well. We can then capture those events and forward them on to whichever service implementation we have without having to pass that dependency into the list-controller
and burdening it with such communication.
New Expectation for Add Item
To start, let’s add a quick spec to the Add Item feature that defines an expectation from the list-controller
to notify when an item has been added:
/test/jasmine/spec/feature/additem.spec.js
async.it('should dispatch a save-item event', function(done) {
var newItem;
$(listController).on('save-item', function(event) {
expect(event.item).not.toBeUndefined();
$(listController).off('save-item');
done();
});
newItem = listController.createNewItem();
});
In creating this expectation, we have also begun to define the actual make-up of the event we intend to receive: the event type being save-item
and the access of the item
that was saved.
Run it and we are red, as expected:
Taking what we have defined as our expectation when an item is added, we’ll modify the list-controller to get this passing. First we’ll add a factory method to generate save-item
events:
/script/controller/list-controller.js
function createSaveEvent(item) {
var event = $.Event('save-item');
event.item = item;
return event;
}
Fairly straight-forward and similar to other event factory methods declared previously in this series. Since we are addressing an expectation of event notification on add of item, we know where in the list-controller we can add that dispatch – in response to the addition of an item on the collection:
/script/controller/list-controller.js
$itemView.appendTo(listController.$view);
rendererList.addItem(itemController);
$(listController).trigger(createSaveEvent(model));
itemController.state = itemControllerFactory.state.EDITABLE;
Back in business.
New Expectation for Remove Item
Let’s quickly just do a similar modification to the list-controller
with regards to the Remove Item feature. First we’ll append a spec in the removeitem.spec suite with an expectation of being notified on remove-item
:
/test/jasmine/spec/feature/removeitem.spec.js
async.it('should dispatch a remove-item event', function(done) {
var removedItem;
$(listController).on('remove-item', function(event) {
expect(event.item).not.toBeUndefined();
$(listController).off('remove-item');
done();
});
removedItem = listController.removeItem(groceryItem);
});
Sparing you another image of the specrunner turning red, that will fail with the timeout that we saw before. We’ll fix that up by adding a trigger in the removal of an item from the collection handler in list-controller
. First with the addition of a factory method for the remove-item
event:
/script/controller/list-controller.js
function createRemoveEvent(item) {
var event = $.Event('remove-item');
event.item = item;
return event;
}
And then with an additional line to the remove
response on the collection:
/script/controller/list-controller.js
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;
Run the tests, and we are back to passing:
New Expectation for Save Item
Sort of repetitive, but we are on a roll… let’s go through the similar process to ensure that a notification for save-item
is dispatched when the user has modified its name and committed it to the list – the Save Item feature we added in the last article.
/test/jasmine/spec/feature/saveitem.spec.js
async.it('should dispatch a save-item event', function(done) {
$(listController).on('save-item', function(event) {
expect(event.item).toEqual(item);
$(listController).off('save-item');
done();
});
item.name = itemName;
itemRenderer.state = itemControllerFactory.state.UNEDITABLE;
});
That’ll put us in the red with the same old timeout issue. Getting back to green, we’ll trigger the save-item
event upon committal of the item to the list, which if you remember – and is described in the test – is in response to the list-item-controller
notifying of change to the item model:
/test/jasmine/spec/feature/list-controller.js
$itemController.on('commit', function(event) {
if(!isValidValue(model.name)) {
listController.removeItem(model);
}
else {
$(listController).trigger(createSaveEvent(model));
}
});
Back to green!
The amount of those little dots just keeps growing. Makes you feel all warm inside. Cherish that, ’cause it will go away…
Hold Up
Just stepping back, it may seem a little odd that we are calling save-item
when we add and commit the item to the list; after all they are the same item, we do we need to notify on save multiple times? The reason being is that upon any modification to an item – including its existence – the store needs to be modified. We haven’t gotten into the service layer for storage yet, but it will be abstracted out that a response from save-item will be internally handled as whether to append the item (from add) or to update an item already existant (from commit). Until we get to that service layer implementation for localStorage
, we’ll go about setting expectations of save-item
notification on modification to an item.
Which actually brings up a good point… what about marking off an item? We will need to notify on change of an item being marked off, as well.
New Expectation for Mark-Off Item
We tackled the Mark-Off Item feature a while back in this series. Just a quick refresher on the story:
// story
—
Story: Item is marked off on grocery list
In order to remember what items have already made it to the cart
As a grocery shopper
I want to mark an item as being attained on the grocery list.
—
We implemented the feature, and upon user press of the item while in non-edit mode, it toggles its marked
property on the model and updates the UI to add or remove a strikethrough on the label.
We’ve got a spec suite for the Mark-Off Item feature already, so we’ll append an expectation for save-item
to it just as we have done with the other feature specs in this article:
/test/jasmine/spec/feature/markitem.spec.js
async.it('should dispatch a save-item event', function(done) {
var timeout = setTimeout(function() {
clearTimeout(timeout);
$(listController).off('save-item');
}, jasmine.DEFAULT_TIMEOUT_INTERNAL);
$(listController).on('save-item', function(event) {
expect(event.item).toBe(item);
$(listController).off('save-item');
done();
});
item.marked = true;
});
… and that will bring us back to failing.
The timeout
placed in there is just to ensure that listener(s) to the save-item
event are removed regardless of the async test timing out.
The resolution to the issue is a trickier one than those of the previous in this article, however. Currently the list-item-controller
is the only component that actually concerned with this change in marked status. It is not concerned with notifying any other party of the change to its model. The model does, however, notify of any property changes. I see two ways in which we can get back to passing:
- Assign a handler for
property-change
on model when it is first created and returned fromlistController.createNewItem()
- Dispatch a
commit
event fromlist-item-controller
on change tomarked
property on the underlying model
While both options will most likely get us where we need to be, the former adds additional management to the list-controller
; its already listening in on commit
from its list-item-controller
instance, so modifying the list-item-controller
to notify of change to the marked
property seems to be the path of least resistance.
We had previously set up the commit
notification on response from leaving the EDITABLE
state of the list-item-controller
:
/script/controller/list-item-controller.js
// append state-based item.
if(event.newState === stateEnum.UNEDITABLE) {
controller.parentView.append(controller.$uneditableView);
controller.save();
}
That implementation got us to passing previously in which we described the expectation of a user committing an item to the list with a valid name (un-empty string). Our issue at hand is to also invoke the save()
method on list-item-controller
when the marked
property is modified. In thinking about it now, while the committal of an item is tied to the change of state, it runs a validation on the name
property to ensure that the item can be added/kept in the collection – so, in actuality commit
can be tied to property updates to the item model.
As such, let’s remove line 48
from the above snippet and insert the invocation of save()
to the handler in list-item-controller
for property-change
on the model:
/script/controller/list-item-controller.js
$(this.model).on('property-change', (function(controller) {
return function(event) {
handlePropertyChange.call(null, controller, event);
controller.save();
};
}(this)));
Run the specrunner again…
… and we broke it
The reason for those X’s is due to the logic we have held in list-controller
on save of an item: it checks it’s name
property and removes it from the list if considered an invalid value – which an empty string is.
I sense some modification to such logic in the future, but for now we can get the tests back to passing by providing a name
property value to the created item in our mark-item spec:
/tests/jasmine/spec/feature/markitem.spec.js
beforeEach( function() {
item = listController.createNewItem();
item.name = 'apples';
});
We’re green!
Tagged 0.1.13: https://github.com/bustardcelly/grocery-ls/tree/0.1.13
Settle Down
We have verified our expectations of save-item
and remove-item
events being dispatched from list-controller
– new and model updates issuing the former, removal issuing the later. The work we have done was to separate concerns and not burden the list-controller
itself with service communication for persisting the Grocery List items across browser sessions, but we have yet to address the actual service layer implementation that will take all these notifications.
Storage Service
The storage-service
will provide a service layer for communication with storage – whether that be remote or local. It will serve as a facade to an existing storage of grocery list items persisted somewhere other than the current application session. For the purposes of this article, that persistence layer is going to be the localStorage
of the browser.
While fleshing out the storage service and its API, we’ll loosely use the technique of ‘TDD as if you meant it’. I say loosely in part because to fully do it and explain each step would be a lot of noise for this article; the main practice point to take away – and I hope I express – is that the component you are testing is actually being built while you make the expectations for it pass.
Tests
To start, we’ll create a bare-bones module for our service layer:
/script/service/storage-service
define(['jquery'], function($) {
var store = {};
return store;
});
And let’s create the beginnings of our test:
/test/jasmine/spec/storage-service.spec.js
define(['jquery', 'script/service/storage-service', 'script/model/grocery-ls-item'],
function($, store, modelFactory) {
describe('Grocery List storage-service', function() {
describe('getItems()', function() {
it('should return of type array', function() {
expect(false).toEqual(true);
});
it('should return array of grocery-ls-item types', function() {
expect(false).toEqual(true);
});
});
});
});
In the test we have set up some tests for the getItems()
method for the service. Prior to any implementation, it should be known that communication with the storage-service
will be considered asynchronous – meaning all operations will return a jQuery Deferred. This will abstract out the storage proxy that will be employed by the storage-service
and will respond in an asynchronous manner regardless of whether the store is immediately accessible – as in the case of localStorage
– or remote.
Truthfully, in practice, I should only do one tests at a time, but we are testing the expectations for access of the same item listing; to save you from reading the ramblings of adding another test, I declared them both at the start.
Let’s stub out the API and start testing and building the storage-service
:
/test/jasmine/spec/storage-service.spec.js
describe('getItems()', function() {
var items,
itemOne = modelFactory.create(),
itemTwo = modelFactory.create(),
async = new AsyncSpec(this);
async.beforeEach( function(done) {
var deferred = $.Deferred(),
getStub = sinon.stub().returns(deferred);
deferred.resolve([itemOne, itemTwo]);
store.getItems = getStub;
store.getItems().then(function(list) {
items = list;
done();
});
});
afterEach( function() {
//
});
it('should return of type array', function() {
expect(Array.isArray(items)).toEqual(true);
expect(items.length).toEqual(2);
});
it('should return array of grocery-ls-item types', function() {
expect(items[0]).toBe(itemOne);
expect(items[1]).toBe(itemTwo);
});
});
In the beforeEach()
, we’re using anonymous stubs from SinonJS, which allow us to stub out methods that may not necessarily already exist on an object. I have used it previously in this series, but we’ll be using it pretty much exclusively while we stub out the API for the storage-service
.
Staying true to our idea that the service will provide an asynchronous communication layer, getItems()
returns a deferred which has resolved to a listing of two grocery-ls-item
instances in our tests.
Sometimes when working with a single feature, I like to isolate it out from my tests for a bit. Here is what the specrunner reports with running just storage-service.spec:
We could move that implementation to storage-service
module now, but we are sort of in a chicken-or-the-egg scenario. We’ve canned the resolved grocery-ls-item
list in the test, but how does the list get filled up in an actual scenario for storage-service
? It’s an excellent question, and something I often puzzle myself with. I mean, we’ll need a saveItem()
method no doubt in order to add items to the store. But shouldn’t that method now be stubbed out in a new test? And how do I test that saveItem()
works without getItems()
being already tested and verified? I could go in circles…
Let’s just stub out an saveItem()
method on storage-service
and, afterward, set expectations in another spec suite:
/test/jasmine/spec/storage-service.spec.js
async.beforeEach( function(done) {
var call = 0,
tempList = [],
deferred = $.Deferred(),
getStub = sinon.stub().returns(deferred),
saveStub = sinon.stub().callsArgOn(0, store),
appendItem = function() {
tempList.push((call++%2 === 0) ? itemOne : itemTwo);
};
store.saveItem = saveStub;
store.getItems = getStub;
store.saveItem(appendItem);
store.saveItem(appendItem);
store.getItems().then(function(list) {
items = list;
done();
});
deferred.resolve(tempList);
});
With these modifications, we have assigned an anonymous stub – saveStub
– as the saveItem
method on the store
and specified that the function-local appendItem
method should be invoked, appending items to the list prior to each of our tests.
A little more work in setup and slightly unrealistic in telling of the arguments to be given to saveItem()
, but it kept us on green without having to hard code the result; it’s a litte truer to life than the previous setup, and still passes:
Implementation
Alright, so I think we should move this out to storage-service
now and trash the stubbing in the test – we’ve got our expectations:
/script/service/storage-service.js
define(['jquery'], function($) {
var itemCache = [],
store = {
saveItem: function(item) {
var deferred = $.Deferred();
itemCache[itemCache.length] = item;
return deferred.resolve(item);
},
getItems: function() {
var deferred = $.Deferred();
deferred.resolve(itemCache);
return deferred;
}
};
return store;
});
/test/jasmine/spec/storage-service.spec.js
describe('getItems()', function() {
var items,
itemOne = modelFactory.create(),
itemTwo = modelFactory.create(),
async = new AsyncSpec(this);
async.beforeEach( function(done) {
store.saveItem(itemOne);
store.saveItem(itemTwo);
store.getItems().then(function(value) {
items = value;
done();
});
});
afterEach( function() {
//
});
it('should return of type array', function() {
expect(Array.isArray(items)).toEqual(true);
expect(items.length).toEqual(2);
});
it('should return array of grocery-ls-item types', function() {
expect(items[0]).toBe(itemOne);
expect(items[1]).toBe(itemTwo);
});
});
Run that, and we are still green!
Tests
Now that we can verify that saveItem()
is working enough for our getItem
spec, let’s properly set the expectations for saveItem
, as well, in our tests:
/test/jasmine/spec/storage-service.spec.js
describe('saveItem()', function() {
var itemOne = modelFactory.create(),
itemTwo = modelFactory.create(),
async = new AsyncSpec(this);
beforeEach( function() {
store.saveItem(itemOne);
});
afterEach( function() {
//
});
async.it('should be grow the length of items', function(done) {
store.getItems().then( function(items) {
expect(items.length).toEqual(1);
done();
});
});
});
Simple enough. Back to the specrunner:
Oh noes! Our expectation is that the length of items is only 1. We have only specified one addition of an item in the setup… where did the length of 5 come from!? Put down the abacus – there are better things to throw. But before that, I have an explanation: we haven’t been cleaning up. We have let afterEach()
just quietly be invoked without a job to do.
To do just enough in getting our tests pass, we can update the afterEach()
declarations in each spec suite to the following:
async.afterEach( function(done) {
store.getItems().then(function(items) {
items.length = 0;
done();
});
});
That will get us back to passing:
I am not particularly fond of that solution, however. Mainly because I think it conveys a usage of the API on storage-service
that I would not condone: directly mutating the underlying list of storage-service
from another party.
I’m not gonna get crazy with the lock-down and privacy of properties and start introducing the latest-and-greatest framework that tries to tout that they really are just a library all in the attempt to stop someone from directly accessing the underlying array of items on storage-service
. If some developers gonna go crazy and do so, hopefully we can find it in more tests later or they can look at our tests as a guideline of how to do what they want.
As such, I think we should add a method to storage-service
that simply allows for emptying the list. First the expectation:
/test/jasmine/spec/storage-service.spec.js
describe('empty()', function() {
var itemOne = modelFactory.create(),
itemTwo = modelFactory.create(),
async = new AsyncSpec(this);
beforeEach( function() {
store.saveItem(itemOne);
store.saveItem(itemTwo);
});
afterEach( function() {
store.empty();
});
async.it('should be appended to the list of items', function(done) {
store.empty().then( function(items) {
expect(items.length).toEqual(0);
done();
});
});
});
/script/service/storage-service.js
define(['jquery'], function($) {
var itemCache = [],
store = {
saveItem: function(item) {
var deferred = $.Deferred();
itemCache[itemCache.length] = item;
return deferred.resolve(item);
},
getItems: function() {
var deferred = $.Deferred();
deferred.resolve(itemCache);
return deferred;
},
empty: function() {
var deferred = $.Deferred();
itemCache.length = 0;
deferred.resolve(itemCache);
return deferred;
}
};
return store;
});
Alright! We are passing expectations on three parts of the API for storage-service
. Now let’s think of what else we need… I think only a removeItem()
method will suffice. In working as we have previously in this article – stubbing out methods to be added to the storage-service
implementation – we can add a spec suite for removeItem()
such as the following:
/test/jasmine/spec/storage-service.spec.js
describe('removeItem()', function() {
var items,
itemOne = modelFactory.create(),
itemTwo = modelFactory.create(),
async = new AsyncSpec(this),
deferred = $.Deferred(),
removeItemFromList = function() {
deferred.resolve(items.splice(0, 1));
};
async.beforeEach( function(done) {
var removeItemStub = sinon.stub().returns(deferred).callsArgOn(0, store);
store.saveItem(itemOne);
store.saveItem(itemTwo);
store.removeItem = removeItemStub;
store.getItems().then( function(value) {
items = value;
done();
});
});
afterEach( function() {
store.empty();
});
async.it('should shorten length of the list', function(done) {
store.removeItem(removeItemFromList).then( function(item) {
store.getItems().then( function(items) {
expect(items.length).toEqual(1);
done();
});
});
});
});
I think there are more expectations to assert for the removeItem()
spec suite, but for now we are passing and we’ll move the implementation over to storage-service
:
/script/service/storage-service.js
define(['jquery'], function($) {
var itemCache = [],
store = {
saveItem: function(item) {
var deferred = $.Deferred();
itemCache[itemCache.length] = item;
return deferred.resolve(item);
},
removeItem: function(item) {
var deferred = $.Deferred(),
itemIndex = itemCache.indexOf(item),
removedItem;
if(itemIndex > -1) {
itemCache.splice(itemIndex, 1);
removedItem = item;
}
return deferred.resolve(removedItem);
},
getItems: function() {
var deferred = $.Deferred();
deferred.resolve(itemCache);
return deferred;
},
empty: function() {
var deferred = $.Deferred();
itemCache.length = 0;
deferred.resolve(itemCache);
return deferred;
}
};
return store;
});
Now, we’ll update the spec suite for removeItem()
and add a few more expectations to ensure the item removal process is correct:
/test/jasmine/spec/storage-service.spec.js
describe('removeItem()', function() {
var itemOne = modelFactory.create(),
itemTwo = modelFactory.create(),
async = new AsyncSpec(this);
beforeEach( function() {
itemOne.name = 'one';
store.saveItem(itemOne);
store.saveItem(itemTwo);
});
afterEach( function() {
store.empty();
});
async.it('should shorten length of the list', function(done) {
store.removeItem(itemOne).then( function(item) {
store.getItems().then( function(items) {
expect(items.length).toEqual(1);
done();
});
});
});
async.it('should remove item specified from the list', function(done) {
store.removeItem(itemOne).then( function(item) {
store.getItems().then( function(items) {
expect(items.indexOf(itemOne)).toEqual(-1);
done();
});
});
});
async.it('should return the item removed if found', function(done) {
store.removeItem(itemOne).then( function(item) {
expect(item).toEqual(itemOne);
done();
});
});
async.it('should return undefined if item not found', function(done) {
store.removeItem(modelFactory.create()).then( function(item) {
expect(item).toBeUndefined();
done();
});
});
});
… and we’re still in business!
Revisiting saveItem()
When we setup the saveItem
stub for our getItem()
spec suite, we really only focused on getting the expectations to pass. To get back to green – at the time – we were only concerned with appending items to the list. I think this needs to be looked at again.
If we remember back to the notifications we set up for the list-controller
, it will dispatch a save-item
event upon the existence of a new item, as well as the modification to an existing item. So we will pass that item through the storage-service
using saveItem()
but we don’t want to continually append items that are previously stored to the list – if so, we’d be buying a lot of spinich spinnash spinach.
Normally I wouldn’t cut corners: a feature spec should be written up for what I have described here prior to modifying the tests. To save you some scrolling, however, I decided to not include walking through one and letting the expectations that we define in the following speak for the specification.
/test/jasmine/spec/storage-service.spec.js
describe('saveItem()', function() {
var itemOne = modelFactory.create(),
itemTwo = modelFactory.create(),
async = new AsyncSpec(this);
beforeEach( function() {
store.saveItem(itemOne);
});
afterEach( function() {
store.empty();
});
async.it('should grow the length of items on new item', function(done) {
store.getItems().then( function(items) {
expect(items.length).toEqual(1);
done();
});
});
async.it('should not grow the length of items on pre-existing item', function(done) {
itemOne.name = 'oranges';
store.saveItem(itemOne).then( function(item) {
store.getItems().then( function(items) {
expect(items.length).toEqual(1);
done();
});
});
});
});
We modified the description of the original expectation to state that the items list should only grow on new existence and added a new expectation that previously stored items do not get appended to the stored list:
As expected Let’s update saveItem()
on the storage-service
to account for this:
/script/service/storage-service.js
saveItem: function(item) {
var deferred = $.Deferred(),
index = itemCache.indexOf(item);
if(index === -1) {
itemCache[itemCache.length] = item;
}
return deferred.resolve(item);
}
Not leaving any to chance, let’s add a couple more expectations as to how items are placed and how they remain in their places:
/test/jasmine/spec/storage-service.spec.js
describe('saveItem() multiples', function() {
var itemOne = modelFactory.create(),
itemTwo = modelFactory.create(),
async = new AsyncSpec(this);
beforeEach( function() {
store.saveItem(itemOne);
store.saveItem(itemTwo);
});
afterEach( function() {
store.empty();
});
async.it('should append new items to the end of the list', function(done) {
store.getItems().then( function(items) {
expect(items[items.length-1]).toBe(itemTwo);
done();
});
});
async.it('should update existing item at position', function(done) {
itemOne.name = 'oranges';
store.saveItem(itemOne).then( function(item) {
store.getItems().then( function(items) {
expect(items.indexOf(itemOne)).toEqual(0);
done();
});
});
});
});
I had begun to add these expectations for multiple items in the list to the saveItem()
spec suite, but I saw a similar setup for them both that differed from the origin setup for the saveItem()
suite. As such, I moved these expectations to their own spec suite and particular setup.
Without any new modification to storage-service
implementation, run that and we are still green!
Tagged 0.1.14: https://github.com/bustardcelly/grocery-ls/tree/0.1.14
This Is All Great But We Still Haven’t Done Anything
In other words, what this subsection header is trying to say, the storage-service
– even after we hook up communication to it from list-controller
events – will do nothing but keep a session-cache of items: there still is no persistence across sessions.
Seeing as this is the case, let’s start integrating communication with localStorage
into our storage-service
. I am not going to modify the tests in order to verify the utilization of localStorage
in relation to the operations available on storage-service
– I am simply going to modify the storage-service
and posible change expectations. The reason being that I do not really care about whether the storage-service relies on localStorage
or a remote resource to read and write items to storage; I am only concerned with communication to storage-service
being supported.
In truth, if this were a real world application and had to support occasional connectivity, i’d have two service modules: local-storage-service and remove-storage-service. They would both support the same API and there would be a service layer facade that would manage the ‘live’ instance and sync of both. That is a little too much for this series, so we’ll stick with a proxy to localStorage
without modifying our tests to assume that the storage-service
requires communication with it.
storage-service modification
To begin with, a String
value is used as a key to read and write to an object held on localStorage. The API of localStorage
is fairly simple and we’ll only be concerned with getItem()
and setItem()
to read and write to the store, respectively. We’ll use a key that we hope is unique to our application and won’t overwrite any object stored previously by another and use that key to access the stored grocery list items:
/script/service/storage-service.js
define(['jquery', 'script/model/grocery-ls-item'], function($, modelFactory) {
var itemCache,
groceryListKey = 'com.custardbelly.grocerylist',
parseToCollection = function(json) {
var i,
length,
list = (json && typeof json === 'string') ? JSON.parse(json) : [];
length = list.length;
for(i = 0; i < length; i++) {
list[i] = $.extend(modelFactory.create(), list[i]);
}
return list;
},
store = {
saveItem: function(item) {
var deferred = $.Deferred();
return deferred.resolve(item);
},
removeItem: function(item) {
var deferred = $.Deferred(),
itemIndex = itemCache.indexOf(item),
removedItem;
if(itemIndex > -1) {
itemCache.splice(itemIndex, 1);
removedItem = item;
}
return deferred.resolve(removedItem);
},
getItems: function() {
var deferred = $.Deferred();
if(itemCache === undefined) {
try {
itemCache = parseToCollection(window.localStorage.getItem(groceryListKey));
deferred.resolve(itemCache);
}
catch(e) {
deferred.reject('Could not access items: ' + e.message);
}
}
else {
deferred.resolve(itemCache);
}
return deferred;
},
empty: function() {
var deferred = $.Deferred();
itemCache.length = 0;
deferred.resolve(itemCache);
return deferred;
}
};
return store;
});
That will light up our tests in pretty red… but that was expected. Actually, if it didn’t make our tests fail horribly, I would have been worried.
There are a couple things going on in this modification to storage-service
that we should go over, however – the first being parseToCollection()
:
/script/service/storage-service.js
parseToCollection = function(json) {
var i,
length,
list = (json && typeof json === 'string') ? JSON.parse(json) : [];
length = list.length;
for(i = 0; i < length; i++) {
list[i] = $.extend(modelFactory.create(), list[i]);
}
return list;
}
This method is invoked from getItems()
and is provided the value of the object held in localStorage
with the key com.custardbelly.grocerylist
. We’ll be saving our data down in JSON format, and as such, parseCollection()
is responsible for parsing that data back out; as well, if it is the first time accessing the data it will be coming in as undefined so a new list is created. What is particularly important in this parsing is how the objects on the list held in localStorage
are converted to instances of our grocery-ls-item
model: we create a new instance using the modelFactory
and extend it with the object values from the item held on the list. The reason for this is because grocery-ls-items
are decorated with getters and setters to allow for property-change
events to be notified. In serializing down to JSON, this object structure is not perserved – it is just a POJSO.
The parseToCollection()
method is invoked from getItems()
:
/script/service/storage-service.js
getItems: function() {
var deferred = $.Deferred();
if(itemCache === undefined) {
try {
itemCache = parseToCollection(window.localStorage.getItem(groceryListKey));
deferred.resolve(itemCache);
}
catch(e) {
deferred.reject('Could not access items: ' + e.message);
}
}
else {
deferred.resolve(itemCache);
}
return deferred;
}
When getItems()
is first invoked in a session, it will go about trying to access and parse the data held on localStorage
; any subsequent invocations will immediately return the currently held reference to the store. In essence, during a session of creating and curating a Grocery List, we are working with live and current data so there is no need to keep accessing the list of grocery items from local storage every time – we’ll just return the live record.
Speaking of which, a lot of the failing tests I suspect are due to not actually not saving the list down to localStorage
. Let’s just modify storage-service
a little to do so and see where that gets us:
/script/service/storage-service.js
define(['jquery', 'script/model/grocery-ls-item'], function($, modelFactory) {
var itemCache,
groceryListKey = 'com.custardbelly.grocerylist',
parseToCollection = function(json) {
var i,
length,
list = (json && typeof json === 'string') ? JSON.parse(json) : [];
length = list.length;
for(i = 0; i < length; i++) {
list[i] = $.extend(modelFactory.create(), list[i]);
}
return list;
},
serialize = function(key, data) {
window.localStorage.setItem(key, JSON.stringify(data));
},
store = {
saveItem: function(item) {
var deferred = $.Deferred();
$.when(this.getItems()).then(function(cache) {
var index = cache.indexOf(item);
try {
if(index === -1) {
cache[cache.length] = item;
}
serialize(groceryListKey, cache);
deferred.resolve(item);
}
catch(e) {
deferred.reject('Could not save item: ' + e.message);
}
});
return deferred;
},
removeItem: function(item) {
// implementation removed to reduce noise
},
getItems: function() {
// implementation removed to reduce noise
},
empty: function() {
// implementation removed to reduce noise
}
};
return store;
});
saveItem()
was modified to access the held list using getItems()
, operate on that list as it had done previously and then try to serialize the list back to storage. Fairly simple. It’s important to note that we don’t access the itemCache
directly in saveItem(), the reason being that we can’t ensure that saveItem()
will only be called after a request to getItems()
. As such, we need to be sure we’re always working with the same data and do so by requesting that cached list from getItems()
within saveItem()
.
That gets us closer to green, but we still have some work to do…
Let’s modify removeItem()
and empty()
to work with the cached list returned from getItems()
just as the modification to saveItems()
has:
/script/service/storage-service.js
removeItem: function(item) {
var deferred = $.Deferred();
$.when(this.getItems()).then(function(cache) {
var itemIndex = cache.indexOf(item),
removedItem;
try {
if(itemIndex > -1) {
cache.splice(itemIndex, 1);
removedItem = item;
serialize(groceryListKey, cache);
}
deferred.resolve(removedItem);
}
catch(e) {
cache.splice(itemIndex, 0, removedItem);
deferred.reject('Could not remove item: ' + e.message);
}
});
return deferred;
}
/script/service/storage-service.js
empty: function() {
var deferred = $.Deferred();
$.when(this.getItems()).then(function(cache) {
try {
cache.length = 0;
serialize(groceryListKey, cache);
deferred.resolve(cache);
}
catch(e) {
deferred.reject('Could not empty cache: ' + e.message);
}
});
return deferred;
}
That oughta do. Basically doing the same as we had done with saveItem()
: accessing the cached list through getItems()
, then modifying that list and serializing back done to localStorage
.
Run those tests again, and we are back to green!
… at least for our storage-service
. Let’s turn on all our tests again and see if our previous expectations are met:
Whoopie!
Tagged 0.1.15: https://github.com/bustardcelly/grocery-ls/tree/0.1.15
We’re not done yet: we have still to hook up list-controller
notification to storage-service
operations. However, I want to end this article here on a good note
Conclusion
We have yet to reach our goal of incorporating session persistence within our Grocery List application – but that is not to say we have gotten nowhere. We implemented our storage-service
layer for localStorage
communication and modified the list-controller
to notify of change events to its collection related to grocery-ls-item
existence. Not to shabby.
I know we want to get a finished product out the door, but we’ll get there… just a few more things to tie up in the next article…
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, unit-testing.