Smalltalk-Inspired, Frameworkless, TDDed Todo-MVC
Tuesday, August 16th, 2016UPDATES
TL;DR
I wrote an alternative to the Vanilla-JS example of todomvc.com. I show that you can drive the design from the tests, that you don’t need external libraries to write OO code in JS, and that simple is better :)
Why yet another Todo App?
A very interesting website is todomvc.com. Like the venerable www.csszengarden.com shows many different ways to render the same page with CSS, todomvc.com instead shows how to write the same application with different JavaScript frameworks, so that you can compare the strengths and weakness of various frameworks and styles.
Given the amount of variety and churn of JS frameworks, it is very good that you can see a small-sized complete example: not too big that it takes ages to understand, but not so small to seem trivial.
Any comparison, though, needs a frame of reference. A good one in this case would be writing the app with no frameworks at all. After all, if you can do a good job without frameworks, why incur the many costs of ownership of frameworks? So I looked at the vanillajs example provided, and found it lacking. My main gripe is that there is no clear “model” in this code. If this were real MVC, I would expect to find a TodoList that holds a collection of TodoItems; this sort of things. Alas, the only “model” provided in that example has the unfortunate name of “Model” and is not a model at all; it’s a collection of procedures that read and write from browser storage. So it’s not really a model because a real “model” should be a Platonic, infrastructure-free implementation of business logic.
There are other shortcomings to that implementation, including that the “view” has a “render” method that accepts the name of an operation to perform, making it way more procedural than I would like. This is so different to what I think of as MVC that made me want to try my hand at doing it better.
Caveats: I’m not a good JS programmer. I don’t know the language well, and I’m sure my code is clumsier than it could be. But I’m also sure that writing a frameworkless app is not a sign of clumsiness, ignorance or old age. Anybody can learn Angular, React or what have you. Learning frameworks is not difficult. What is difficult is to write good code, with or without frameworks. Learning to write good code without frameworks gives you incredible leverage: gone are the hours spent looking on StackOverflow for the magic incantations needed to make framework X do Y. Gone is the cost of maintenance inflicted on you by the framework developers, when they gingerly update the framework from version 3 to version 4. Gone is the tedium of downloading megabytes of compressed code from the server!
So what were my goals?
- Simple design. This means: no frameworks! Really, frameworks are sad. Just write the code that your app needs, and write it well.
- TDD: let the tests drive the design. I try to write tests that talk the language of the app specification, avoiding implementation details as much as possible
- Smalltalk-inspired object orientation. JS generally pushes you to expose the state of objects as public properties. In Smalltalk, the internal state of an object is totally encapsulated. I emulated that with a simple trick that does not require extra libraries.
- I had in the back of my mind the “count” example in Jill Nicola’s and Peter Coad’s OOP book. That is what I think of when I say “MVC”. I tried to avoid specifying this design directly in the tests, though.
- Simple, readable code. You wil be the judge on that.
How did it go?
The first time around I tried to work in a “presenter-first” style. After a while, I gave up and started again from scratch. The code was ugly, and I felt that I was committing the classic TDD mistake, to force my preconceived design. So I started again and the second time was much nicer.
You cannot understand a software design process just by looking at the final result. It’s only by observing how the design evolved that you can see how the designer thinks. When I started again from scratch, my first tests looked like this:
beforeEach(function() { fixture = document.createElement('div'); $ = function(selector) { return fixture.querySelector(selector); } }) describe('an empty todo list', function() { it('returns an empty html list', function() { expect(new TodoListView([]).render()).to.equal('<ul class="todo-list"></ul>'); }); }); describe('a list of one element', function() { it('renders as html', function() { fixture.innerHTML = new TodoListView(['Pippo']).render(); expect($('ul.todo-list li label').textContent).equal('Pippo'); expect($('ul.todo-list input.edit').value).equal('Pippo'); }); });
The above tests are not particularly nice, but they are very concrete: the check that the view returns the expected HTML, with very few assumptions on the design. Note that the “model” in the beginning was just an array of strings.
The final version of those test does not change much on the surface, but the logic is different:
beforeEach(function() { fixture = createFakeDocument('<ul class="todo-list"></ul>'); todoList = new TodoList(); view = new TodoListView(todoList, fixture); }) it('renders an empty todo list', function() { view.render(); expect($('ul.todo-list').children.length).to.equal(0); }); it('renders a list of one element', function() { todoList.push(aTodoItem('Pippo')); view.render(); expect($('li label').textContent).equal('Pippo'); expect($('input.edit').value).equal('Pippo'); });
The better solution, for me, was to pass the document to the view object, call its render()
method, and check how the document was changed as a result. This places almost no constraints on how the view should do its work. This, to me, was key to letting the test drive the design. I was free to change and simplify my production code, as long as the correct code was being produced.
Of course, not all the tests check the DOM. We have many tests that check the model logic directly, such as
it('can contain one element', function() { todoList.push('pippo'); expect(todoList.length).equal(1); expect(todoList.at(0).text()).equal('pippo'); });
Out of a total of 585 test LOCs, we have 32% dedicated to testing the models, 7% for testing repositories, 4% testing event utilities and 57% for testing the “view” objects.
How long did it take me?
I did not keep a scrupolous count of pomodoros, but since I committed very often I can estimate the time taken from my activity on Git. Assuming that every stretch of commits starts with about 15 minutes of work before the first commit in the stretch, it took me about 18 and a half hours of work to complete the second version, distributed over 7 days (see my calculations in this spreadsheet.) The first version, the one I discarded, took me about 6 and a half hours, over two days. That makes it 25 hours of total work.
What does it look like?
The initialization code is in index.html:
<script src="js/app.js"></script> <script> var repository = new TodoMvcRepository(localStorage); var todoList = repository.restore(); new TodoListView(todoList, document).render(); new FooterView(todoList, document).render(); new NewTodoView(todoList, document).render(); new FilterByStatusView(todoList, document).render(); new ClearCompletedView(todoList, document).render(); new ToggleAllView(todoList, document).render(); new FragmentRepository(localStorage, document).restore(); todoList.subscribe(repository); </script>
I like it. It creates a bunch of objects, and starts them. The very first action is to create a repository, and ask it to retrieve a TodoList model from browser storage. The FragmentRepository
should perhaps better named FilterRepository
. The todoList.subscribe(repository)
makes the repository subscribe to the changes in the todoList
model. This is how the model is saved whenever there’s a change.
Each of the “view” objects takes the model and the DOM document as parameters. As you will see, these “views” also perform the function of controllers. This is how they came out of the TDD process. They probably don’t conform exactly to MVC, but who cares, as long as they are small, understandable and testable?
Each of the “views” handles a particular UI detail: for instance, the ClearCompletedView
is in js/app.js:
function ClearCompletedView(todoList, document) { todoList.subscribe(this); this.notify = function() { this.render(); } this.render = function() { var button = document.querySelector('.clear-completed'); button.style.display = (todoList.containsCompletedItems()) ? 'block' : 'none'; button.onclick = function() { todoList.clearCompleted(); } } }
The above view subscribes itself to the todoList
model, so that it can update the visibility of the button whenever the todoList
changes, as the notify
method will then be called.
The test code is in the test folder. For instance, the test for the ClearCompletedView
above is:
describe('the view for the clear complete button', function() { var todoList, fakeDocument, view; beforeEach(function() { todoList = new TodoList(); todoList.push('x', 'y', 'z'); fakeDocument = createFakeDocument('<button class="clear-completed">Clear completed</button>'); view = new ClearCompletedView(todoList, fakeDocument); }) it('does not appear when there are no completed', function() { view.render(); expectHidden($('.clear-completed')); }); it('appears when there are any completed', function() { todoList.at(0).complete(true); view.render(); expectVisible($('.clear-completed')); }); it('reconsider status whenever the list changes', function() { todoList.at(1).complete(true); expectVisible($('.clear-completed')); }); it('clears completed', function() { todoList.at(0).complete(true); $('.clear-completed').onclick(); expect(todoList.length).equal(2); }); function $(selector) { return fakeDocument.querySelector(selector); } });
Things to note:
- I use a real model here, not a fake. This gives me confidence that the view and the model work correctly together, and allows me to drive the development of the
containsCompletedItems()
method inTodoList
. However, it does couple the view and the model tightly. - I use a simplified “document” here, that only contains the fragment of index.html that this view is concerned about. However, I’m testing with the real DOM in a real browser, using Karma. This gives me confidence that the view will interact correctly with the real browser DOM. The only downside is that the view knows about the “clear-completed” class name.
- The click on the button is simulated by invoking the onclick handler.
If you are curious, here is the implementation of createFakeDocument
:
function createFakeDocument(html) { var fakeDocument = document.createElement('div'); fakeDocument.innerHTML = html; return fakeDocument; }
It’s that simple to test JS objects against the real DOM.
All the production code is in file js/app.js. An example model is TodoItem
:
function TodoItem(text, observer) { var complete = false; this.text = function() { return text; } this.isCompleted = function() { return complete; } this.complete = function(isComplete) { complete = isComplete; if (observer) observer.notify() } this.rename = function(newText) { if (text == newText) return; text = newText.trim(); if (observer) observer.notify() } }
As you can see, I used a very simple style of object-orientation. I do not use (or need here) prototype inheritance, but I do encapsulate object state well.
I’m not showing the TodoList model because it’s too long :(. I don’t like this, but I don’t have a good idea at this moment to make it smaller. Another class that’s too long and complex is TodoListView
, with about 80 lines of code. I could probably break it down in TodoListView
and TodoItemView
, making it a composite view with a smaller view for each TodoItem
. That would require creating and destroying the view dynamically. I don’t know if that would be a good idea; I haven’t tried it yet.
Comparison with other Todo-MVC examples
How does it compare to the other examples? There is no way I can read all of the examples, let alone understand them. However, there is a simple metric that I can use to compare my outcome: simple LOC, counting just the executable lines and omitting comments and blank lines. After all, if you use a framework, I expect you to write less code; otherwise, it seems to me that either the framework is not valuable, or that you can’t use it well, which means that it’s not valuable to you. This is the table of LOCs, computed with Cloc. (Caveat: I tried to exclude all framework and library code, but I’m not sure I did that correctly for all examples.) My version is the one labelled “vanillajs/xpmatteo” in bold. I’m excluding test code.
1204 | typescript-angular/js |
1185 | ariatemplates/js |
793 | aurelia |
790 | socketstream |
782 | typescript-react/js |
643 | gwt/src |
631 | closure/js |
597 | dojo/js |
594 | puremvc/js |
564 | vanillajs/js |
529 | dijon/js |
508 | enyo_backbone/js |
489 | typescript-backbone/js |
481 | vanilla-es6/src |
479 | flight/app |
475 | lavaca_require/js |
468 | componentjs/app |
432 | duel/src/main |
383 | polymer/elements |
364 | cujo/app |
346 | sapui5/js |
321 | vanillajs/xpmatteo |
317 | scalajs-react/src/main/scala |
311 | backbone_marionette/js |
310 | ampersand/js |
295 | sammyjs/js |
295 | backbone_require/js |
287 | extjs_deftjs/js |
284 | durandal/js |
280 | rappidjs/app |
276 | thorax/js |
271 | troopjs_require/js |
265 | angular2/app |
256 | angularjs/js |
249 | mithril/js |
242 | thorax_lumbar/src |
235 | chaplin-brunch/app |
233 | vanilladart/web/dart |
233 | somajs_require/js |
232 | serenadejs/js |
226 | emberjs/todomvc/app |
224 | spine/js |
224 | exoskeleton/js |
214 | backbone/js |
213 | meteor |
207 | angular-dart/web |
190 | somajs/js |
167 | riotjs/js |
164 | react-alt/js |
156 | angularjs_require/js |
147 | ractive/js |
146 | olives/js |
146 | knockoutjs_require/js |
145 | canjs_require/js |
139 | atmajs/js |
132 | firebase-angular/js |
130 | foam/js |
129 | canjs/js |
124 | vue/js |
99 | knockback/js |
98 | react/js |
96 | angularjs-perf/js |
34 | react-backbone/js |
Things I learned
It’s been fun and I learned a lot about JS and TDD. Many framework-based solutions are shorter than mine, and that’s to be expected. However, all you need to know to understand my code is JS.
TDD works best when you try to avoid pushing it to produce your preconceived design ideas. It’s much better when you follow the process: write tests that express business requirements, write the simplest code to make the tests pass, refactor to remove duplication.
Working in JS is fun; however, not all things can be tested nicely with the approach I used here. I often checked in the browser that the features I had test-driven were really working. Sometimes they didn’t, because I had forgot to change the “main” code in index.html
to use the new feature. At one point I had an unwanted interaction between two event handlers: the handler for the onchange event fired when the edit text was changed by the onkeyup handler. I wasn’t able to write a good test for this, so I resorted to simply testing that the onkeyup handler removed the onchange handler before acting on the text. (This is not very good because it tests the implementation instead of the outcome.)
You can do a lot of work without jQuery, expecially since there is the querySelector
API. However, in real work I would probably still use it, to improve cross-browser compatibility. It would probably also make my code simpler.