Now that we're done with the user registration and the session initiation, we are ready to test the core of our app, which is to manage to-do items. We will start by segregating that part of the application tests into a file of their own at test/todos.js
, which may start with the following boilerplate:
var assert = require('assert'), Browser = require('zombie'), app = require('../app'), couchdb = require('../lib/couchdb'), dbName = 'todos', db = couchdb.use(dbName), fixtures = require('./fixtures'), login = require('./login'); describe('Todos', function() { before(function(done) { app.start(3000, done); }); after(function(done) { app.server.close(done); }); beforeEach(function(done) { db.get(fixtures.user.email, function(err, doc) { if (err && err.status_code === 404) return done(); if (err) throw err; db.destroy(doc._id, doc._rev, done); }); }); });
Here we have similar boilerplate code for the other modules, with the difference that now we are dealing with a database named todos
, not users
. Another difference is that we want to start with a clean to-do list for each test, so we're adding a beforeEach
hook that removes all the to-do items for the test user.
We are now ready to start carving out some tests, but there is at least one cumbersome repetitive task ahead that can be avoided at an early stage: logging in. We should assume that each test is individually reproducible and that the ordering of the tests doesn't matter—each test should rely on one browser instance, mimicking one individual user session per test. Also, since all the to-do item manipulation is scoped to a user and the user session must be initiated, we need to abstract that away into its own module inside test/login.js
:
var Browser = require('zombie'), fixtures = require('./fixtures'), assert = require('assert'), couchdb = require('../lib/couchdb'), dbName = 'users', db = couchdb.use(dbName); function ensureUserExists(next) { db.get(fixtures.user.email, function(err, user) { if (err && err.status_code === 404) { db.insert(fixtures.user, fixtures.user.email, next); } if (err) throw err; next(); }); } module.exports = function(next) { return function(done) { ensureUserExists(function(err) { if (err) throw err; Browser.visit("http://localhost:3000/session/new", function(err, browser) { if (err) throw err; browser .fill('E-mail', fixtures.user.email) .fill('Password', fixtures.user.password) .pressButton('Log In', function(err) { if (err) throw err; assert.equal(browser.location.pathname, '/todos'); next(browser, done); }); }); }); }; };
This module makes sure that a test user exists before loading, filling, and posting the user login form. After that it hands off the control to a next
function.
Now we are ready to add further description scopes inside our todos
scope. One of these scopes is the to-do list, which will have this code:
describe('Todo list', function() { it('should have core elements', login(function(browser, done) { assert.equal(browser.text('h1'), 'Your To-Dos'); assert(browser.query('a[href="/todos/new"]'), 'should have a link to create a new Todo'); assert.equal(browser.text('a[href="/todos/new"]'), 'New To-Do'); done(); })); it('should start with an empty list', login(function(browser, done) { assert.equal(browser.queryAll('#todo-list tr').length, 0, 'To-do list length should be 0'); done(); })); it('should not load when the user is not logged in', function(done) { Browser.visit('http://localhost:3000/todos', function(err, browser) { if (err) throw err; assert.equal(browser.location.pathname, '/session/new', 'should be redirected to login screen'); done(); }); }); });
Here we can see that that we are making use of our login
module to abstract away the session initiation dance, making sure our callback function only gets called once the user is logged in. Here we have three tests.
In our first test, named should have core elements
, we are simply loading the empty to-do list and asserting that we have some elements in place, such as a heading containing the Your To-dos
text and a link to create a new to-do item.
In the following test, named should start with an empty list
, we are simply testing whether the to-do list contains zero elements.
In the last test of this scope, named should not load when the user is not logged in
, we are asserting that this list is inaccessible to the user that has not yet initiated the session, making sure he is redirected to /session/new
if we try to load the To-do list
URL.
Now, we need to test whether the to-do items can really be created. For that, follow these steps:
Todo creation form
—which will be another subscope of the Todos
one:describe('Todo creation form', function() {
it('should not load when the user is not logged in', function(done) { Browser.visit('http://localhost:3000/todos/new', function(err, browser) { if (err) throw err; assert.equal(browser.location.pathname, '/session/new', 'should be redirected to login screen'); done(); }); });
Here we are verifying that the user gets redirected to the login screen if an attempt is made to load the to-do item creation form without being logged in.
it('should load with title and form', login(function(browser, done) { browser.visit('http://localhost:3000/todos/new', function(err) { if (err) throw err; assert.equal(browser.text('h1'), 'New To-Do'); var form = browser.query('form'); assert(form, 'should have a form'); assert.equal(form.method, 'POST', 'form should use post'); assert.equal(form.action, '/todos', 'form should post to /todos'); assert(browser.query('textarea[name=what]', form), 'should have a what textarea input'); assert(browser.query('input[type=submit]', form), 'should have an input submit type'); done(); }); }));
Here we are verifying that the form is present, that it has the necessary attributes to make a POST
request to the /todos
URL, and that the form has a text area input and a button to press.
it('should allow to create a todo', login(function(browser, done) { browser.visit('http://localhost:3000/todos/new', function(err) { if (err) throw err; browser .fill('What', 'Laundry') .pressButton('Create', function(err) { if (err) throw err; assert.equal(browser.location.pathname, '/todos', 'should be redirected to /todos after creation'); var list = browser.queryAll('#todo-list tr.todo'); assert.equal(list.length, 1, 'To-do list length should be 1'); var todo = list[0]; assert.equal(browser.text('td.pos', todo), 1); assert.equal(browser.text('td.what', todo), 'Laundry'); done(); }); }); }));
Here we are finally testing whether the form allows us to post a new item and whether the item gets created. We are doing that by loading and filling in the to-do item creation form; verifying that we've been redirected to the to-do item list page; and that this page contains the single to-do item that we've just created.
Now that we've tested to-do item insertion, we can test whether one can actually remove these items from one's list. We will place these tests inside a describe scope named Todo removal form
, inside which we will test for two things: the removal of one to-do item when only one exists and the removal of a to-do item when more than one item exists.
Here is the code for the removal from a one-item list:
describe('Todo removal form', function() { describe('When one todo item exists', function() { beforeEach(function(done) { // insert one todo item db.insert(fixtures.todo, fixtures.user.email, done); }); it("should allow you to remove", login(function(browser, done) { browser.visit('http://localhost:3000/todos', function(err, browser) { if (err) throw err; assert.equal(browser.queryAll('#todo-list tr.todo').length, 1); browser.pressButton('#todo-list tr.todo .remove form input[type=submit]', function(err) { if (err) throw err; assert.equal(browser.location.pathname, '/todos'); // assert that all todos have been removed assert.equal(browser.queryAll('#todo-list tr').length, 0); done(); } ); }); })); });
Before we run the test, there is a beforeEach
hook that inserts a to-do item into the todo
database for the test user. That's just one to-do item that's taken from fixtures.todo
, which is a property we need to add to the test/fixtures.json
file:
{ "user" : { "email": "me@email.com", "password": "mypassword" }, "todo": { "todos": [ { "what": "Do the laundry", "created_at": 1346542066308 } ] }, "todos": { "todos": [ { "what": "Do the laundry", "created_at": 1346542066308 }, { "what": "Call mom", "created_at": 1346542066308 }, { "what": "Go to gym", "created_at": 1346542066308 } ] } }
You may notice that, we're taking the opportunity here to add some additional fixtures that will help in future tests.
Continuing analyzing the test code, we see that the test fetches the to-do list and then verifies that the number of to-do items is actually one:
assert.equal(browser.queryAll('#todo-list tr.todo').length, 1);
Then it goes on to try and press the remove button of that one to-do item:
browser.pressButton('#todo-list tr.todo .remove form input[type=submit]', …
The selector assumes that there is one to-do item on the table, which we had already verified before.
Then, after pressing the button and submitting the removal form, we're verifying that no errors occurred, that the browser was redirected back to the /todos
URL, and that the presented list is now empty:
assert.equal(browser.queryAll('#todo-list tr').length, 0);
Now that we've tested that this works well for removing one item from a one-item list, let's create a more evolved test that asserts that we can remove a specific item from a list of three items:
describe('When more than one todo item exists', function() { beforeEach(function(done) { // insert one todo item db.insert(fixtures.todos, fixtures.user.email, done); }); it("should allow you to remove one todo item", login( function(browser, done) { browser.visit('http://localhost:3000/todos', function(err, browser) { if (err) throw err; var expectedList = [ fixtures.todos.todos[0], fixtures.todos.todos[1], fixtures.todos.todos[2] ]; var list = browser.queryAll('#todo-list tr'); assert.equal(list.length, 3); list.forEach(function(todoRow, index) { assert.equal(browser.text('.pos', todoRow), index + 1); assert.equal(browser.text('.what', todoRow), expectedList[index].what); }); browser.pressButton( '#todo-list tr:nth-child(2) .remove input[type=submit]', function(err) { if (err) throw err; assert.equal(browser.location.pathname, '/todos'); // assert that the middle todo item has been removed var list = browser.queryAll('#todo-list tr'); assert.equal(list.length, 2); // remove the middle element from the expected list expectedList.splice(1,1); // test that the rendered list is the expected list list.forEach(function(todoRow, index) { assert.equal(browser.text('.pos', todoRow), index + 1); assert.equal(browser.text('.what', todoRow), expectedList[index].what); }); done(); } ); }); } )); });
This description scope will sit at the same level as the previous one, also inserting a document in the todo
database, but this time the document contains a list of three to-do items, taken from the fixtures.todos
attribute (instead of the previously used singular fixtures.todo
attribute).
The test starts by visiting the todo
list page and building a list of the expected to-do items, stored in the variable named expectedList
. We then retrieve all the to-do list items found on the HTML document and verify that the content is what is expected:
list.forEach(function(todoRow, index) { assert.equal(browser.text('.pos', todoRow), index + 1); assert.equal(browser.text('.what', todoRow), expectedList[index].what); });
Once we have verified that all the expected to-do items are in place and in order, we go on to click on the button for the second item on the list by using the following code:
browser.pressButton( '#todo-list tr:nth-child(2) .remove input[type=submit]', ...
Here, we're using the special CSS selector nth-child
for selecting exactly the row for the second do-to item and then fetching the code for removing submit button inside it, and finally pressing it.
Once the button is pressed, the form is submitted, and the browser calls back, we verify that there are no errors, that we got redirected back to the /todos
URL, and also that it contains the expected list. We do this last bit by removing the second element from the previously used expectedList
array and verifying that this is exactly what is shown in the current page:
var list = browser.queryAll('#todo-list tr'); assert.equal(list.length, 2); expectedList.splice(1,1); // test that the rendered list is the expected list list.forEach(function(todoRow, index) { assert.equal(browser.text('.pos', todoRow), index + 1); assert.equal(browser.text('.what', todoRow), expectedList[index].what); });