Smalltalk-Inspired, Frameworkless, TDDed Todo-MVC


UPDATES

15/01/2017 — I produced a video of me explaining this material, together with a deck of slides. I also updated the code after writing this article.

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 in TodoList. 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.

2 Responses to “Smalltalk-Inspired, Frameworkless, TDDed Todo-MVC”

  1. Luciano Says:

    Matteo thanks for your TDD – Vanilla JS sample.
    Trying to run your example cloned from github with “npm run” on Windows 10 platform I get an error: Exit status 1. I am not a node.js expert and I can’t understand why.
    Following the content of the npm-debug.log:

    0 info it worked if it ends with ok
    1 verbose cli [ ‘C:\\Program Files\\nodejs\\node.exe’,
    1 verbose cli ‘C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js’,
    1 verbose cli ‘start’ ]
    2 info using npm@3.10.9
    3 info using node@v6.9.2
    4 verbose run-script [ ‘prestart’, ‘start’, ‘poststart’ ]
    5 info lifecycle @~prestart: @
    6 silly lifecycle @~prestart: no script for prestart, continuing
    7 info lifecycle @~start: @
    8 verbose lifecycle @~start: unsafe-perm in lifecycle true
    9 verbose lifecycle @~start: PATH: C:\Program Files\nodejs\node_modules\npm\bin\node-gyp-bin;C:\Java-IDE\eclipse-workspaces\frameworkless-tdd-todomvc\node_modules\.bin;C:\Python27\Lib\site-packages\PyQt4;C:\Program Files\Common Files\Microsoft Shared\Windows Live;C:\Program Files (x86)\Common Files\Microsoft Shared\Windows Live;C:\Python27\;C:\Python27\Scripts;C:\ProgramData\Oracle\Java\javapath;C:\instantclient_12_1;C:\javalibs\jrebel;C:\Program Files (x86)\PC Connectivity Solution\;C:\javalibs\apache-maven-3.3.9\bin;C:\WINDOWS\system32;C:\WINDOWS;C:\WINDOWS\System32\Wbem;C:\WINDOWS\System32\WindowsPowerShell\v1.0\;C:\unxutils\usr\local\wbin;C:\unxutils\bin;C:\Program Files\Java\jre1.8.0_73\bin;C:\Program Files (x86)\Intel\Services\IPT\;C:\Program Files\ThinkPad\Bluetooth Software\;C:\Program Files\ThinkPad\Bluetooth Software\syswow64;C:\javalibs\apache-ant-1.7.1\bin;%BPADir%;%TFSPowerToolDir%;C:\Program Files\MySQL\MySQL Server 5.5\bin;C:\Program Files (x86)\Microsoft SDKs\Windows Azure\CLI\wbin;C:\javalibs\selenium-2.44.0;C:\RTE-NE51;C:\Program Files\Calibre2\;C:\svabi\files;C:\Program Files (x86)\Windows Live\Shared;C:\Program Files (x86)\Skype\Phone\;C:\Program Files\TortoiseSVN\bin;C:\Program Files\TortoiseGit\bin;C:\Program Files\Git\cmd;C:\Program Files\nodejs\;C:\Program Files (x86)\Elm Platform\0.17.1\bin;C:\Ruby193\bin;C:\Program Files (x86)\Nmap;C:\Program Files (x86)\Graphviz2.34\bin;C:\Program Files (x86)\Git\bin;C:\Program Files\Docker Toolbox;C:\Users\lucianom\AppData\Local\Microsoft\WindowsApps;C:\Users\lucianom\AppData\Roaming\npm
    10 verbose lifecycle @~start: CWD: C:\Java-IDE\eclipse-workspaces\frameworkless-tdd-todomvc
    11 silly lifecycle @~start: Args: [ ‘/d /s /c’, ‘npm install && npm run watch’ ]
    12 silly lifecycle @~start: Returned: code: 1 signal: null
    13 info lifecycle @~start: Failed to exec start script
    14 verbose stack Error: @ start: `npm install && npm run watch`
    14 verbose stack Exit status 1
    14 verbose stack at EventEmitter. (C:\Program Files\nodejs\node_modules\npm\lib\utils\lifecycle.js:255:16)
    14 verbose stack at emitTwo (events.js:106:13)
    14 verbose stack at EventEmitter.emit (events.js:191:7)
    14 verbose stack at ChildProcess. (C:\Program Files\nodejs\node_modules\npm\lib\utils\spawn.js:40:14)
    14 verbose stack at emitTwo (events.js:106:13)
    14 verbose stack at ChildProcess.emit (events.js:191:7)
    14 verbose stack at maybeClose (internal/child_process.js:877:16)
    14 verbose stack at Process.ChildProcess._handle.onexit (internal/child_process.js:226:5)
    15 verbose pkgid @
    16 verbose cwd C:\Java-IDE\eclipse-workspaces\frameworkless-tdd-todomvc
    17 error Windows_NT 10.0.14393
    18 error argv “C:\\Program Files\\nodejs\\node.exe” “C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js” “start”
    19 error node v6.9.2
    20 error npm v3.10.9
    21 error code ELIFECYCLE
    22 error @ start: `npm install && npm run watch`
    22 error Exit status 1
    23 error Failed at the @ start script ‘npm install && npm run watch’.
    23 error Make sure you have the latest version of node.js and npm installed.
    23 error If you do, this is most likely a problem with the package,
    23 error not with npm itself.
    23 error Tell the author that this fails on your system:
    23 error npm install && npm run watch
    23 error You can get information on how to open an issue for this project with:
    23 error npm bugs
    23 error Or if that isn’t available, you can get their info via:
    23 error npm owner ls
    23 error There is likely additional logging output above.
    24 verbose exit [ 1, true ]

  2. Luciano Says:

    Solved the error using`\”` instead of `’` inside package.json.
    It seems a problem with only the windows platform.
    I found this solution here:
    https://github.com/pjhjohn/jsonplaceholder-client/issues/22
    (see fix)

Leave a Reply