Marionette Testing
Marionette Testing
David Sulc
This book is for sale at http://leanpub.com/marionette-testing
This is a Leanpub book. Leanpub empowers authors and publishers with the Lean Publishing
process. Lean Publishing is the act of publishing an in-progress ebook using lightweight tools and
many iterations to get reader feedback, pivot until you have the right book and build traction once
you do.
Cover Credits . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . i
Why Test? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
Enjoyable Code . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
What Dogfights Can Teach Us . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
When to Test . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
Setting Up . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5
Getting Mocha . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5
Getting Chai . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
Header Entity . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
Header Model . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
Not Testing Functionality . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 20
Header Collection . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 20
Stubbing with Sinon.js . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
Contact Entity . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 37
Testing Application Requests . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41
Using the Sinon-Chai Plugin . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43
Testing Asynchronous Features . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43
Time-Dependent Tests . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 50
Testing Localstorage . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 54
The book and referenced Git commits all use Marionette 2.3.2.
Throughout the book, as we code our app, we’ll refer to commit references within the git repository
like this:
This will allow you to follow along and see exactly how the codebase has changed: you can either
look at that particular commit in your local copy of the git repository, or click on the link to see an
online display of the code differences.
Any change in the code will affect all the following commit references, so the links in
your version of the book might become desynchronized. If that’s the case, make sure you
update your copy of the book to get the new links. At any time, you can also see the full
list of commits here⁵, which should enable you to locate the commit you’re looking for (the
commit names match their descriptions in the book).
Even if you haven’t used Git yet, you should be able to get up and running quite easily using online
resources such as the Git Book⁶. This chapter is by no means a comprehensive introduction to Git,
but the following should get you started:
³https://github.com/davidsulc/marionette-testing
⁴https://github.com/davidsulc/marionette-testing/commit/bb6f4b54f853196fb597719286ea083718a21047
⁵https://github.com/davidsulc/marionette-testing/commits/master
⁶http://git-scm.com/book
⁷https://help.github.com/articles/set-up-git
Following Along with Git iv
• From the command line move into the marionette-testing folder that Git created in the step
above, and execute
You can also use Git to view the code at different stages as it evolves within the book:
• Look around in the files, they’ll be in the exact state they were in at that point in time within
the book
• Once you’re done looking around and wish to go back to the current state of the codebase,
run
⁸https://github.com/davidsulc/marionette-testing/archive/master.zip
Why Test?
Well, you’ve cracked open a book on testing, so hopefully you’re at least open to the possibility
that code testing has some benefits. Whether you’re the one needing convincing or you’re looking
for amunition to use in your arguments with peers and bosses, let’s attempt to answer the question
everybody asks when they first learn about the concept of testing code: why bother? Instead of
writing code to test existing features we know are working (we’ve even clicked through the interface
not just once, but FIVE times to make sure everything is fine!), shouldn’t we invest that time cranking
out new features our customers are waiting for? If we fix a bug as soon as it’s discovered, everything
will be fine, right?
How expensive a bug is to fix depends in part in when it is found, which is intuitive to any
programmer who has wondered which idiot wrote the code he’s looking at, only to find out he
wrote it himself 6 months ago. But it turns out there’s even real science to back this up: in the early
1980s, Barry Boehm found that the cost of making a change increases as you move from the stages
of requirements analysis to architecture, design, coding, testing and deployment. A requirements
mistake found and corrected while you are still defining the requirements costs almost nothing. But
if you wait until after you’ve finished designing, coding and testing the system and delivering it to
the customer, it costs a lot more.
In addition, writing a new feature (or changing an existing one) can have unintended consequences
causing regressions. If you broke something, wouldn’t it be best if you found out and fixed it before
shipping to the customer?
Enjoyable Code
Nobody likes to fix a bug only to discover another dozen pop up, and everybody hates having to
completely rewrite tightly coupled code to add a simple feature. What can you do to prevent that?
Invest in your own happiness and maintain a test suite to:
• leverage developer laziness to produce decoupled code: if it’s less work to test, it’s less work
to code for;
• stop living in fear: rely on regression tests to never commit broken code again;
• work as a team to ship complex software: your new features might have unintended
consequences on your colleague’s code (or be based on different assumptions) but the tests
she wrote will bring them to light while your new code is still fresh in your mind;
• increase code quality: as corner cases are identified and added to the test suite, robustness
automatically increases;
Why Test? 2
• decrease code complexity: complex code gets broken down into smaller chunks to keep tests
small and independent;
• contribute to documentation: the way code is expected to behave (including edge cases) is
clearly exposed within the test suite.
1. using pending tests as a “to do” list of functionality and edge cases that need to be covered;
2. exploring various options to implement the desired functionality;
3. writing tests for the “happy path”;
4. writing tests for the edge cases to “finalize” the implementation.
Naturally, the above is only my opinion, based on what seems to work best for me: I like working
my way down the “to do” list as it lets you see your actual progress, while writing most of the tests
after having decided on the best course of action minimizes the amount of code that needs to be
rewritten. Experiment with different approaches, and see what works best for you!
What you need to bear in mind is that some tests are better than no tests, so don’t necessarily aim
for having tests for all of your code, or having all the tests written before coding functionality. Start
small and keep going until you feel the effort is no longer paying off.
What Types of Tests?
There are 3 main types of software testing levels:
• unit testing¹⁵: the focus of this book. Unit testing isolates each part of the program to show
each one is correct. A unit test provides a strict, written contract that the piece of code must
satisfy;
• integration testing¹⁶: occurs after unit testing, and combines individual software modules to
test them as a group;
• system testing¹⁷: occurs after integration testing and is conducted on a complete, integrated
system. As it falls in the scope of black box testing, it should require no knowledge of the inner
design of the code or logic. System testing usually aims to exercise the software as it would
be used in the wild (e.g. simulating a user clicking within a web interface and entering data).
Each level has its uses, and ideally all should be part of a comprehensive test suite. Back in reality,
time and resources are limited, and you often have to make choices as to what should be tested. My
experience is that the most “profitable” tests are the ones on each end of the spectrum: unit tests
ensure each module works as expected (especially with edge cases), while system testing verifies
the whole system works as the user expects. Since system tests tend to be much slower (both to
write and run), their scope and quantity is sometimes reduced. That said, I would at least make a
conscious effort to have system tests covering the user’s happy path¹⁸.
Naturally, integration tests also have their use (especially when different teams/developers have
issues integrating their code), but my experience is that there isn’t usually a need to test every
possible integration: use them judiciously to ensure you’re spending your coding time wisely.
¹⁵http://en.wikipedia.org/wiki/Unit_testing
¹⁶http://en.wikipedia.org/wiki/Integration_testing
¹⁷http://en.wikipedia.org/wiki/System_testing
¹⁸http://en.wikipedia.org/wiki/Happy_path
Setting Up
First, let’s grab a copy of the source code for our basic Contact Manager application (as it was
developed in my “Backbone.Marionette.js: A Gentle Introduction¹⁹” book) from here²⁰ and put it in
a contact_manager folder. We’re doing this for convenience, since we’ll now have all libraries and
assets (images, icons, etc.) available and won’t have to deal with fetching them in the next chapters.
Downloading the entire app also means we have all of our modules ready to go, simply waiting to
be tested.
We’ve now got a copy of our original application ready to go, but we’ll still need somewhere to run
our tests and check their results. For this purpose, we’ll create a test folder at the same level as the
contact_manager folder. This test folder will contain all code relating to testing our application. In
particular, let’s add a test.html file to start our tests and display their results. Since our test.html file
will need to include all of our app files to be able to test them, we’ll simply copy over our original
index.html in test/test.html to save ourselves some work. Here’s what our file structure looks like
right now:
• contact_manager
– index.html
– assets folder
• test
– test.html (copy of index.html)
If you open test.html right now, it won’t find any of the application files because their paths
are incorrect. Don’t worry about that right now, we’ll get to it in a few pages.
Getting Mocha
Mocha²¹ is the javascript test framework we’ll use to write and execute our tests. Grab the
javascript file from the book’s code repo here²² (Mocha’s latest version is here²³) and save it in
test/assets/js/vendor/mocha.js. Also get the CSS file²⁴ and store it in assets/css/mocha.css.
¹⁹https://leanpub.com/marionette-gentle-introduction
²⁰https://github.com/davidsulc/marionette-testing/archive/bb6f4b54f853196fb597719286ea083718a21047.zip
²¹http://mochajs.org/
²²https://raw.githubusercontent.com/davidsulc/marionette-testing/master/test/assets/js/vendor/mocha.js
²³https://raw.githubusercontent.com/mochajs/mocha/master/mocha.js
²⁴https://raw.githubusercontent.com/mochajs/mocha/master/mocha.css
Setting Up 6
Now that the relevant Mocha files have been downloaded, we’ll need to include them in test.html.
While we’re at it, we can also remove the references to the CSS files, as we won’t be needing them
in our tests (none of our app views will be displayed):
test/test.html
1 <!DOCTYPE html>
2 <html lang="en">
3 <head>
4 <meta charset="utf-8">
5 <title>Marionette Contact Manager Tests</title>
6 <link href="./assets/css/bootstrap.css" rel="stylesheet">
7 <link href="./assets/css/application.css" rel="stylesheet">
8 <link href="./assets/css/jquery-ui-1.10.3.custom.css" rel="stylesheet">
9 <link href="assets/css/mocha.css" rel="stylesheet">
10 <script src="assets/js/vendor/mocha.js"></script>
11 </head>
Mocha will also need us to provide a div for it to display the test results, so let’s add that:
test/test.html
1 <body>
2 <div id="mocha"></div>
Finally, we need to configure Mocha and have it run all tests when the test.html file gets loaded:
test/test.html
1 <script src="./assets/js/apps/header/list/list_controller.js"></script>
2
3 <script type="text/javascript">
4 ContactManager.start();
5 mocha.setup("bdd");
6 window.onload = function () {
7 mocha.run();
8 };
9 </script>
10 </body>
11 </html>
Setting Up 7
As you can tell on line 5, we’re going to use the “BDD” style simply because I find it more natural
to write and read. Other test interfaces (e. g. TDD) can be used with Mocha (see documentation²⁵).
Then, on lines 6-8, we simply get Mocha to run our tests when the window is loaded.
If you open test.html in a browser, you’ll see the static message from our app (“Here is static content
in the web page. You’ll notice that it gets replaced by our app as soon as we start it.”) which didn’t
get removed because we never started our app. In addition to that, you’ll see that Mocha inserted
some test stats in the upper right hand corner.
Those statistics look a little sad, don’t they? We’ll soon create our first test, but before that let’s
remove our placeholder text so it no longer distracts us:
test/test.html
We’re going to put all of our tests inside a test/assets/js/spec folder (using a .spec.js suffix), so let’s
start by creating our “hello, world” test in there:
test/assets/js/spec/hello_world.spec.js
1 describe("Mocha", function(){
2 it("should work as expected");
3 });
Of course, Mocha won’t run our new test if we don’t add it to test.html, so let’s do that right now
(line 8):
²⁵http://mochajs.org/#interfaces
Setting Up 8
test/test.html
1 <script type="text/javascript">
2 mocha.setup("bdd");
3 window.onload = function () {
4 mocha.run();
5 };
6 </script>
7
8 <script src="assets/js/spec/hello_world.spec.js"></script>
Before anything else, let’s give it a whirl in the browser and see what happens:
As you can see, 100% of our test suite ran in 0.03 seconds, with no tests passed and no tests failed.
Why is that, when we just added a test? Because our test is currently empty, so it is considered in
a “pending” state and therefore ignored: it is simply displayed with the results so you don’t forget
about it. Actually, if you hover over the test’s name, Mocha will tell you it’s pending:
So what did we put in our first test? Let’s take another look at it:
Setting Up 9
test/assets/js/spec/hello_world.spec.js
1 describe("Mocha", function(){
2 it("should work as expected");
3 });
First, we name a certain funcationality (in this case Mocha), then we provide a function to execute.
This anonymous function will typically contain one or more call to the it function: these calls will
test the functionality we’re describing and make sure it works as expected. So if we read the above
test in English, we could say “We’re going to describe the “Mocha” functionality in here, and it
should work as expected.”
So far, however, we have yet to test anything. Let’s fix that by providing a function as the second
argument to the it call:
test/assets/js/spec/hello_world.spec.js
1 describe("Mocha", function(){
2 it("should work as expected", function(){
3 return true;
4 });
5 });
We’ve added a function for Mocha to run and test. Depending on what happens when this function
is run, Mocha will mark the test as passing or failing. Let’s try it out:
Passing a test
This time around, we can see the test is passing: Mocha adds a green checkmark next to it, and the
count of passed tests in the upper right-hand corner is 1. Let’s now add a test that fails:
Setting Up 10
test/assets/js/spec/hello_world.spec.js
1 describe("Mocha", function(){
2 it("should work as expected", function(){
3 return true;
4 });
5
6 it("shouldn't throw an error", function(){
7 throw new Error("Something broke!");
8 });
9 });
When we run our test suite, we can see we now have 1 test passing and another failing:
Failing a test
We can take a look a the code for the failing test by clicking on the test:
Setting Up 11
We now know how Mocha works on a simplistic level: as long as the function provided to it doesn’t
raise an exception when it is executed, the test is considered successful. Of course, these two tests still
don’t actually test anything, so we’ll create a function to test as well as a helper function that will
raise an exception if our code doesn’t behave as expected. Delete everything in hello_world.spec.js
and replace it with:
test/assets/js/spec/hello_world.spec.js
What have we here? First, we define assertEqual which will compare both its arguments and raise
an exception if they aren’t equal. Then, we define the addTwo function we’re going to test. Finally,
we describe addTwo’s expected behavior:
So our first test passed, but the second one failed because javascript will append to strings when
using the “+” operator… Let’s fix our code and run the test again:
Setting Up 13
test/assets/js/spec/hello_world.spec.js
And now, our tests can prove our function works as expected:
Of course, we’re going to do a lot more than checking function results are equal to some result: we’ll
want to check they are not equal, greater than some value, etc. Instead of writing all of these helper
functions ourselves, we’ll be using Chai to care of that for us.
Getting Chai
Download Chai from the book’s code repo here²⁶ (Chai’s latest version is here²⁷), save it in
test/assets/js/vendor/chai.js, and include it in test.html:
²⁶https://raw.githubusercontent.com/davidsulc/marionette-testing/master/test/assets/js/vendor/chai.js
²⁷http://chaijs.com/chai.js
Setting Up 14
test/test.html
We’ll now be able to use Chai’s functions to assert the tested code behaves as it should. Let’s modify
our tests to reflect that:
test/assets/js/spec/hello_world.spec.js
This is a good start, but constantly typing chai.expect is going to be annoying, and also reduces
readability. So let’s fix that by attaching the expect method to window so it becomes globally
accessible:
Setting Up 15
test/test.html
1 <script type="text/javascript">
2 mocha.setup("bdd");
3 window.expect = chai.expect;
test/assets/js/spec/hello_world.spec.js
1 describe("addTwo", function(){
2 it("should add 2 to an integer", function(){
3 expect(addTwo(3)).to.equal(5);
4 });
5
6 it("should cast strings to integers", function(){
7 expect(addTwo("3")).to.equal(5);
8 });
9 });
Now that we’ve written our first tests, let’s get cracking!
The “hello, world” test code won’t be of any use to us in the future, so go ahead and delete
file test/assets/js/spec/hello_world.spec.js, and remove it from test.html:
1 </script>
2
3 <script src="assets/js/spec/hello_world.spec.js"></script>
4 </body>
Although we’ll be using the expect interface to verify our assertions throughout the book, other
styles²⁸ are available. Where in the book we would use
expect(foo).to.equal("bar");
• foo.should.equal("bar");
• assert.equal(foo, "bar");
²⁸http://chaijs.com/guide/styles/
Header Entity
Header Model
Let’s start by testing our Header models. We’ll organize our test files to mirror the app file
organization:
All we’re going to check in our header model is that it is selectable. Here’s what we’ll start with in
our test file:
test/assets/js/spec/entities/header.spec.js
1 describe("Header entity", function(){
2 describe("Model", function(){
3 it("defines (de)selection functions");
4 it("is selectable");
5 it("is not 'selected' by default");
6 });
7 });
We’re nesting describe blocks to organize our tests: each describe block will group tests that belong
together logically. So far, we’ve got a group of tests for the model, and later we’ll add a second group
for the collection. Then, within the test group for the model, we add a list of things we want to
check. Great!
Before we forget, let’s add this new file to test.html:
test/test.html
1 </script>
2
3 <script src="assets/js/spec/entities/header.spec.js"></script>
4 </body>
5 </html>
In our first test, we want to check that header models define select and deselect functions. Here
we go:
Header Entity 17
test/assets/js/spec/entities/header.spec.js
We’re defining a header model instance to use, then testing the existence of the functions using
javascript’s typeof²⁹ operator.
If we open our test page in a browser, however, we’re told that ContactManager doesn’t exist…
Why is that when we’re including our entire application in test.html? Well, because the paths aren’t
correct. Let’s fix that (you’ll need to change the URLs for all included application files, not just the
ones shown):
test/test.html
²⁹https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/typeof
³⁰https://github.com/davidsulc/marionette-testing/commit/f48d3b8b66f8bf4442ccc3323fa746f7996c0fa9
Header Entity 18
test/assets/js/spec/entities/header.spec.js
Another test in the bag! You’ll notice that Chai provides us with “readable assertions” to check
whether a value is true or false. You can also invert the assertion by using the not operator in the
chain, for example:
expect(header.selected).to.not.be.true;
Sadly, we’ve started to introduce some duplication in our tests: lines 4 and 10 both declare a header
model instance to perform tests on. Since we’re going to need this model in each of our tests, it
would be great if we could have some sort of function to prepare our tests to spare us from writing
boring boilerplate code. Luckily, we aren’t the first to think of this, and it’s nicely provided to us via
Mocha’s beforeEach and afterEach hooks³¹ which, as their names imply, run before and after each
test in the describe block they belong to. Let’s use them:
³¹http://mochajs.org/#hooks
Header Entity 19
test/assets/js/spec/entities/header.spec.js
Mocha’s hooks allow us to add attributes to this (line 4), which is the context within which all
tests are run. We then simply refer to the attribute on this within a test to obtain the desired value
(line 12). Naturally, once our test block has run, we need to clean everything up so these tests don’t
interfere with any other tests we might run in another block: our afterEach function will remove
the attribute we’ve added.
Note that although we don’t really reduce the number of lines in our test file, we’re vastly
improving the maintainability of our tests: each test contains only the actual code we’re exercising,
which means failed tests will be quicker to debug (since no setup code will interfere). In addition,
centralizing setup code will allow us to easily update our tests if the application code gets refactored
(e.g. the Header module gets moved to another sub-module).
And here’s our last test:
Header Entity 20
test/assets/js/spec/entities/header.spec.js
Line 6 has an interesting assertion: ok. It means that the argument will evaluate to something truthy.
Since we’re negating it in our use, we want to make sure that the selected attribute is falsy: false,
null, undefined would all be acceptable values for us.
Header Collection
We can now move on to testing the header collection, starting with adding pending tests to our file:
test/assets/js/spec/entities/header.spec.js
If we now look at our test page, we’ll see our pending tests have been added:
If we click on the “Collection” description, you’ll notice that only that test group gets displayed:
You could also filter the displayed tests by clicking on the “passes” and “failures” links in the top
right-hand corner. When tests are being filtered, you’ll notice that a ?grep= argument gets added to
the URL. You can use this to your advantage by entering any value you want to filter directly in the
URL. For example, entering ?grep=selectable yields the following result:
Header Entity 22
But let’s get back to our tests… Try to implement the first one and take a look at the next page to
see how you fared.
You can use expect(a).to.equal(b) to test whether a and b are the same.
Header Entity 23
We can simply create a new instance of our header collection and check that the model attribute is
set to the Header model value:
test/assets/js/spec/entities/header.spec.js
That was easy! The second test will be slightly trickier: we’ll need to access the actual collection
used in the application. Here’s the relevant application code:
contact_manager/assets/js/entities/header.js
With the code as it currently stands, we’d need to request the collection before we can do anything
with it. This isn’t great for testing: if the fetching doesn’t work for any reason, our test will fail even
though the collection might have contained one model per navigation menu item as expected.
Ideally, you would write the test first, and refactor only after. We’re refactoring first here
for educational reasons: writing the testing code here would require using functionality
that is better explained later.
Header Entity 24
We should always aim for our tests to fail only if the underlying tested code is incorrect, which
means keeping the amount of code exercised in each test to a minimum. Therefore, let’s refactor our
code to improve it:
contact_manager/assets/js/entities/header.js
1 Entities._initializeHeaders = function(){
2 return new Entities.HeaderCollection([
3 { name: "Contacts", url: "contacts", navigationTrigger: "contacts:list" },
4 { name: "About", url: "about", navigationTrigger: "about:show" }
5 ]);
6 };
7
8 var headers;
9 var API = {
10 getHeaders: function(){
11 if(headers === undefined){
12 headers = Entities._initializeHeaders();
13 }
14 return headers;
15 }
16 };
So what did our changes bring about? We’ve changed the function initializing our headers (lines 1-6)
to be accessible from outside the Entities module, which means we’ll be able to have it executed
from our testing code. In addition, it will return the initialized collection instead of assigning an
attribute, which decouples our code and will also make it easier to test.
To replace the Entities.headers attribute we no longer use, we’ve introduced the headers variable
on line 8. This variable then gets checked and set (if necessary) before being returned, all within the
getHeaders function.
contact_manager/assets/js/entities/header.js
On line 3, we check that we’ve got 2 models in our collection, since that’s how many entries we want
to have in our navigation menu. Line 4 then uses Backbone’s pluck³² to grab the name attribute from
each model in the collection, after which we verify that our menu entries exist as expected.
We normally avoid testing the inner workings of our app (e.g. functions starting with an
underscore), so our tests don’t break when internal code is refactored. We’re testing it here
anyway to provide a better learning experience, as it means we need to introduce less
information before writing tests. We’ve got to start somewhere, right?
Refactor our existing 2 collection tests so we don’t need to declare the headers collection in each
test.
³²http://backbonejs.org/#Collection-pluck
Header Entity 26
test/assets/js/spec/entities/header.spec.js
1 describe("Collection", function(){
2 before(function(){
3 this.headers = ContactManager.Entities._initializeHeaders();
4 });
5
6 after(function(){
7 delete this.headers;
8 });
9
10 it("is for Header models", function(){
11 expect(this.headers.model).to.equal(ContactManager.Entities.Header);
12 });
13
14 it("contains one model per navigation menu item", function(){
15 expect(this.headers).to.have.length(2);
16 var entries = this.headers.pluck("name");
17 expect(entries).to.contain("About");
18 expect(entries).to.contain("Contacts");
19 });
20
21 it("is single selectable");
22 it("can be fetched with a 'header:entities' request");
23 it("is a singleton when obtained by request");
24 });
You probably used beforeEach and afterEach for the setup/teardown code, as that is what was
introduced previously. On lines 2 and 6, we’re using before and after to do the job instead. These
functions will be run once before/after the entire group of tests within the describe block. In other
words, beforeEach gets run between two tests in the same block, whereas before gets run once
before the tests within the block.
When do you know what to use? beforeEach will always give the expected result, because it’s run
before every test. The downside of course, is that it makes your tests slower because more code needs
to get executed (since setup code gets run before each test). In the case above, we can afford to use
before because we will only access this.headers in a “read-only” fashion: this ensures that no test
interferes with another via the shared data.
On to the next test:
Header Entity 27
test/assets/js/spec/entities/header.spec.js
As you can see, when testing we’re just dealing with normal javascript code and can therefore
write some helper functions as on lines 2-6. We’re using the reduce³³ function to go through our
collection and count the number of selected models. We store the intermediary count in the memo
variable, which is initialized with 0.
We first check that our collection doesn’t have more than 1 model selected (line 7). After calling
select several times, we verify that the number of selected models in our collection remains exactly
1.
We now want to test whether our header collection can be retrieved through an application request.
Let’s give that a try:
test/assets/js/spec/entities/header.spec.js
Although this works, and sort of tests what we want, it’s a pretty crappy test: nothing in here really
tells us we’re actually getting the collection we want, and it’s essentially duplicating our test for
menu entries. What we really want to test here is that when the “header:entities” request is made,
the requester will receive the value that was created by Entities._initializeHeaders. Let’s try
reassigning that function so that we can then check everything happened as expected in our test:
³³http://underscorejs.org/#reduce
Header Entity 28
test/assets/js/spec/entities/header.spec.js
This is way better: we create a new _initializeHeaders function on lines 4-6 that will return a
known value (declared on line 3). Then, after making the request on line 8, we check the received
value is the expected one on line 9. Finally, we restore the _initializeHeaders header function on
line 11 with the saved value from line 2.
With this test, we actually know for sure that the value returned is the one we want. Unfortunately,
writing all that code for each test where we want to check function execution is going to drive you
crazy. But once again, some nice people have written a library for us to use.
³⁴http://en.wikipedia.org/wiki/Method_stub
³⁵http://sinonjs.org/
³⁶https://raw.githubusercontent.com/davidsulc/marionette-testing/master/test/assets/js/vendor/sinon.js
³⁷http://sinonjs.org/download/
Header Entity 29
test/test.html
1 <title>Marionette Contact Manager Tests</title>
2 <link rel="stylesheet" href="assets/css/mocha.css" />
3 <script src="assets/js/vendor/mocha.js"></script>
4 <script src="assets/js/vendor/chai.js"></script>
5 <script src="assets/js/vendor/sinon.js"></script>
So how can we simplify our code using Sinon.js? Let’s have another go at it:
test/assets/js/spec/entities/header.spec.js
1 it("can be fetched with a 'header:entities' request", function(){
2 var origInitializer = ContactManager.Entities._initializeHeaders,
3 fakeCollection = {};
4 ContactManager.Entities._initializeHeaders = function(){
5 return fakeCollection;
6 };
7 var fakeCollection = {};
8 sinon.stub(ContactManager.Entities,
9 "_initializeHeaders").returns(fakeCollection);
10
11 var headers = ContactManager.request("header:entities");
12 expect(headers).to.equal(fakeCollection);
13
14 ContactManager.Entities._initializeHeaders = origInitializer;
15 ContactManager.Entities._initializeHeaders.restore();
16 });
As you can tell, we can easily create a stub using sinon.stub (lines 8-9): we indicate the object
on which we want to stub a method, and the name of the method we want to stub (provided as a
string). We can also directly chain a return value to be provided any time our stubbed method is
called: in our case, we always want to return fakeCollection. We could have achieved the same
result without chaining:
After our test has executed, we must restore the original behavior of the _initializeHeaders
function or the next test that causes _initializeHeaders to be executed will be in for a nasty
surprise: fakeCollection will be returned instead of the expected header collection. As you can
imagine, it’s a little too easy to forget to restore the original behavior before leaving the test… To
address this, Sinon provides a sandbox mechanism where all stubs will automatically be restored
after the test is run. Let’s use it:
Header Entity 30
test/assets/js/spec/entities/header.spec.js
All we’ve changed is wrapping our test function in a sinon.test() call, which will take care of
the sandboxing for us. Then, we need to use the stub method provided by the sandbox: hence our
change on line 5 replacing sinon.stub with this.stub. The downside to using such a sandbox is
that it modifies our tested code, and we will no longer be able to see the original in our test page:
We’ve got just one test left, so try to prove that 2 requests for the header collection will yield the
exact same object, then take a look at the next page.
Header Entity 31
test/assets/js/spec/entities/header.spec.js
On its own, equal will determine whether the 2 compared objects are the same, which is exactly
what we want in this test: if the objects have the same values but are not the same one (i.e. with
the same memory location), equal will return false. To compare 2 objects and return true if their
values correspond (even if they aren’t the same object instance in memory), we’ll use deep.equal
later in the book. Primitive values will behave as expected when using equal. In other words, these
tests will pass:
expect(2).to.equal(2);
var obj = { foo: "bar" },
otherObj = obj;
expect(otherObj).to.equal(obj);
expect(obj).to.not.equal({ foo: "bar" });
expect(obj).to.deep.equal({ foo: "bar" });
³⁸https://github.com/davidsulc/marionette-testing/commit/b782fccc7c679e3a35f7f67d47f53bbb76cebba1
The Filtered Collection
You should be able to implement the tests for the filtered collection on your own, so let’s give it a
try, shall we? Go ahed and implement these tests:
test/assets/js/spec/entities/common.spec.js
Before we can do anything with our tests, we need a collection with contacts and a filtered collection
instance:
test/assets/js/spec/entities/common.spec.js
To keep our tests independent, we’re going to need to reset the filtered collection between tests. We
could also create a new filtered collection instance before each test, but that would take longer.
test/assets/js/spec/entities/common.spec.js
1 before(function(){
2 // edited for brevity
3 });
4
5 after(function(){
6 // edited for brevity
7 });
8
9 beforeEach(function(){
10 this.filteredCollection.reset(this.collection.models);
11 });
With the setup code in place, we can now move on to implementing our tests:
test/assets/js/spec/entities/common.spec.js
14 });
15
16 it("can filter the collection with #filter", function(){
17 this.filteredCollection.filter("e");
18 expect(this.filteredCollection).to.have.length(3);
19 var nancies = _.filter(this.filteredCollection.models,
20 function(model){ return model.get("firstName") === "Nancy"; })
21 expect(nancies).to.be.empty;
22 });
23
24 it("can filter the collection with #where", function(){
25 this.filteredCollection.where({ firstName: "Mark" });
26 expect(this.filteredCollection).to.have.length(1);
27 expect(this.filteredCollection.first().get("lastName")).to.equal("Merten");
28 });
29
30 it("clears the filter with ''", function(){
31 var originalLength = this.filteredCollection.models.length;
32 this.filteredCollection.filter("e");
33 expect(this.filteredCollection).to.have.length(3);
34 expect(this.filteredCollection.length).to.not.equal(originalLength);
35 this.filteredCollection.filter("");
36 expect(this.filteredCollection.length).to.equal(originalLength);
37 });
38
39 it("refilters itself when the original collection is reset", function(){
40 var harold = new ContactManager.Entities.Contact(
41 { firstName: "Harold", lastName: "Halley" });
42 var ingrid = new ContactManager.Entities.Contact(
43 { firstName: "Ingrid", lastName: "Ippan" });
44
45 this.filteredCollection.filter("e");
46 this.collection.reset([harold, ingrid]);
47
48 expect(this.filteredCollection).to.have.length(1);
49 var firstName = this.filteredCollection.first().get("firstName");
50 expect(firstName).to.equal("Harold");
51 });
52
53 it("filters models added to the original collection", function(){
54 var harold = new ContactManager.Entities.Contact(
55 { firstName: "Harold", lastName: "Halley" });
The Filtered Collection 36
³⁹https://github.com/davidsulc/marionette-testing/commit/a808e5cd14a9bfc8c805156c3ef678eb254ab44d
Contact Entity
Let’s start working on our contact entity now. Here’s a first set of tests for you to implement:
test/assets/js/spec/entities/contact.spec.js
Take a crack at implementing them yourself before reading on. And don’t forget to add our new file
to test.html:
test/test.html
<script src="assets/js/spec/entities/header.spec.js"></script>
<script src="assets/js/spec/entities/contact.spec.js"></script>
Contact Entity 38
Testing the default values is pretty straightforward: we just create a new contact instance and test
the various attributes.
test/assets/js/spec/entities/contact.spec.js
1 before(function(){
2 this.contact = new ContactManager.Entities.Contact();
3 });
4
5 after(function(){
6 delete this.contact;
7 });
8
9 it("sets a default value of '' for firstName", function(){
10 expect(this.contact.get("firstName")).to.equal("");
11 });
12
13 it("sets a default value of '' for lastName", function(){
14 expect(this.contact.get("lastName")).to.equal("");
15 });
16
17 it("sets a default value of '' for phoneNumber", function(){
18 expect(this.contact.get("phoneNumber")).to.equal("");
19 });
test/assets/js/spec/entities/contact.spec.js
1 describe("Validations", function(){
2 beforeEach(function(){
3 this.contact = new ContactManager.Entities.Contact({
4 firstName: "John",
5 lastName: "Doe",
6 phoneNumber: "123-456"
7 });
8 });
9
10 afterEach(function(){
11 delete this.contact;
12 });
13
14 it("accepts models with valid data", function(){
Contact Entity 39
15 expect(this.contact.isValid()).to.be.true;
16 });
17
18 it("refuses blank first names", function(){
19 this.contact.set("firstName", "");
20 expect(this.contact.isValid()).to.be.false;
21 });
22
23 it("refuses blank last names", function(){
24 this.contact.set("lastName", "");
25 expect(this.contact.isValid()).to.be.false;
26 });
27
28 it("refuses last names shorter than 2 characters", function(){
29 this.contact.set("lastName", "a");
30 expect(this.contact.isValid()).to.be.false;
31 });
32 });
We are setting up a valid contact instance, then modifying it to check the validations fail when
they’re supposed to.
Once again, note that in the first group, we used before to set up the test whereas in the second one
we used beforeEach. That is because in the first case we aren’t modifying the setup data, while in
the second one we are. Let’s illustrate the how using before incorrectly can wreak havoc in your
tests. Here’s a smaller code sample using beforeEach:
beforeEach(function(){
this.contact = new ContactManager.Entities.Contact({
firstName: "John",
lastName: "Doe",
phoneNumber: "123-456"
});
});
afterEach(function(){
delete this.contact;
});
});
As you’ll see in your browser, the tests both pass. This is what we expect: all of our tests should be
independent, and since both of these exercise the code as expected, they shouldn’t fail. Now. let’s
take the exact same code sample but replace beforeEach with before:
1 before(function(){
2 this.contact = new ContactManager.Entities.Contact({
3 firstName: "John",
4 lastName: "Doe",
5 phoneNumber: "123-456"
6 });
7 });
8
9 after(function(){
10 delete this.contact;
11 });
12
13 it("refuses blank first names", function(){
14 this.contact.set("firstName", "");
15 expect(this.contact.isValid()).to.be.false;
16 });
17
18 it("accepts models with valid data", function(){
19 expect(this.contact.isValid()).to.be.true;
20 });
This time around, the test on line 18 fails… This is because the previous test is setting the contact’s
first name to a blank string and not cleaning up. Then, when the second test is run it fails due to
the blank first name. This didn’t happen in our previous example, because between each test we’re
deleting the contact instance and creating a brand new (and valid!) one.
Why not just reset the contact’s first name in the previous test? Aside from the fact that it
takes extra effort, it means that changing code in one test (such as deleting the line resetting
the contact) would cause other tests to break!
test/assets/js/spec/entities/contact.spec.js
1 describe("Collection", function(){
2 before(function(){
3 this.contacts = new ContactManager.Entities.ContactCollection();
4 });
5
6 after(function(){
7 delete this.contacts;
8 });
9
10 it("is for Contact models", function(){
11 expect(this.contacts.model).to.equal(ContactManager.Entities.Contact);
12 });
13
14 it("sorts models by first name", function(){
15 expect(this.contacts.comparator).to.equal("firstName");
16 });
17 });
Once again, we simply create an instance of the collection, and check the attributes we’re interested
in. Note that we’re not actually testing the sorting functionality, as that is provided by Backbone.
contact_manager/assets/js/entities/contact.js
1 getContactEntities: function(){
2 var contacts = new Entities.ContactCollection();
3 var defer = $.Deferred();
4 contacts.fetch({
5 success: function(data){
6 defer.resolve(data);
7 }
8 });
9 var promise = defer.promise();
10 $.when(promise).done(function(fetchedContacts){
11 if(fetchedContacts.length === 0){
12 // if we don't have any contacts yet, create some for convenience
Contact Entity 42
The challenge we’re faced with here is the fetch call on line 4: it’s going to attempt to load data
from storage, which we simply can’t accept. Aside from the fact that we’d have to insert data into
our local storage (and keep it separate from production data which might be there), it means our
test would have an unnecessary point of failure (that isn’t directly relevant to the code under test).
To overcome this, we’ll make the collection constructor return a prepared collection which will have
a stubbed fetch method. When fetch is done executing, it will call the provided success callback
(if one has been provided), so we’ll have to implement that in our stub. Finally, since this test will
be asynchronous, we’ll need to make sure Mocha waits for the data to be returned before moving
on to the next test. That’s a lot to take in at once, but relax: we’ll be going step by step.
test/assets/js/spec/entities/contact.spec.js
1 describe.only("contact:entities request", function(){
2 it("fetches the collection of existing models", sinon.test(function(){
3 this.stub(this.contacts, "fetch");
4 this.stub(ContactManager.Entities, "ContactCollection")
5 .returns(this.contacts);
6
7 ContactManager.request("contact:entities");
8 expect(this.contacts.fetch.called).to.be.true;
9 }));
10 });
We’re using .only on line 1 so that only that group of tests gets run. This is handy when
you’re working, as the tests run faster and unrelated tests won’t be distracting you. You
can also use this feature on individual tests with it.only(...).
On line 3, we stub the fetch method, and then stub the ContactCollection constructor on line 4 to
return our prepared collection. In passing, note that our test is wrapped in a sinon.test sandbox:
since we’re modifying this.contacts (by stubbing fetch) we need to ensure it’s returned to its
original state before being used by other tests.
With our setup in place, we can move on to our test proper on lines 7-8: we make the request and
check that our stubbed fetch method was called. However, line 8 isn’t as readable as it could be.
Let’s fix that with a Chai plugin.
Contact Entity 43
test/test.html
1 <script src="assets/js/vendor/chai.js"></script>
2 <script src="assets/js/vendor/sinon.js"></script>
3 <script src="assets/js/vendor/sinon-chai.js"></script>
test/assets/js/spec/entities/contact.spec.js
1 ContactManager.request("contact:entities");
2 expect(this.contacts.fetch).to.have.been.called.once;
⁴⁰https://raw.githubusercontent.com/davidsulc/marionette-testing/master/test/assets/js/vendor/sinon-chai.js
⁴¹https://github.com/domenic/sinon-chai
Contact Entity 44
test/assets/js/spec/entities/contact.spec.js
We’ve added our done asynchronous indicator to the test function call on line 2, then we execute
it on line 11 once we obtained the data. We also had to modify line 10 due to the change in scope.
Unfortunately, when we run the test, this is the result:
contact_manager/assets/js/entities/contact.js
1 getContactEntities: function(){
2 var contacts = new Entities.ContactCollection();
3 var defer = $.Deferred();
4 contacts.fetch({
5 success: function(data){
6 defer.resolve(data);
7 }
8 });
9 var promise = defer.promise();
10 $.when(promise).done(function(fetchedContacts){
11 if(fetchedContacts.length === 0){
12 // if we don't have any contacts yet, create some for convenience
13 var models = initializeContacts();
14 contacts.reset(models);
15 }
16 });
17 return promise;
18 },
The function’s return value is the promise on line 17, which is linked to the deferred value on line
9. This deferred, in turn, is only resolved on line 6 via the success callback provided to the fetch
function. In other words, if we never call our success callback, we’re never going to resolve the
promise: the code waiting for the promise to be resolved will never run.
Another thing we need to take into account is that we don’t want initializeContacts to be run for
this particular test: it’s not needed for the test we’re writing, and each test should always execute
the minimum amount of code possible to reduce the risk of unrelated code making our tests fail.
Therefore, we want our promise to resolve with at least one contact. That will satisfy the condition
on line 11 and avoid running initializeContacts.
So how can we adapt our test to take all of this into account? We just need to create a contact
instance, then defined our stubbed fetch function to call the provided success callback with this
new contact:
Contact Entity 46
test/assets/js/spec/entities/contact.spec.js
On line 3, we create a new contact instance. Then, we change our stub definition on line 4 and
provide a third argument, which is the definition of the stubbed function. In other words, we’re
replacing the existing fetch function with our new implementation. This new implementation will
simply execute the success callback in the provided object, passing in an array containing our
newly-created contact, and finally returning the result.
Our test now works as desired, but before we move on let’s also check the return value is the one
we’re expecting (see line 14):
test/assets/js/spec/entities/contact.spec.js
13 expect(self.contacts.fetch).to.have.been.called.once;
14 expect(fetchedContacts).to.deep.equal([ contact ]);
15 done();
16 });
17 }));
18 });
On line 14, we’re using deep.equal instead of just equal because we need to compare the contents
of the array. We can’t just test the array is the same object, because it will never be: we create a new
array instance just for the test on line 14 (which can therefore never be equal to the one created on
line 5).
Try to implement a new test for our collection:
To do that, you’ll need to refactor the application code somewhat, just like we did with the header
entity… In addition, what you’ll need to test is:
• that the contact initialization function gets called if no contacts were fetched;
• that the return value of the initialization function is what gets used to populate the contact
collection.
For that second point, you’ll want to know if the collection’s reset method gets called, and check
the arguments. You can achieve that using a spy, which won’t change the function being spied on
but will let us verify things about it (such as if it was called). To declare a spy:
Naturally, you’ll have to restore the spy (just like a stub) by calling object.methodName.restore()
when you’re done with it. Or you could just declare the spy within a sinon.test sandbox. With a
spy available, you can later check if the method was called with
expect(mySpy).to.have.been.calledWith(args);
where mySpy and args are the function spied on (e.g. object.methodName above) and the call
arguments, respectively.
Don’t forget we already covered how to stub the fetch method so it returns what we want! And
while you’re at it, clean up the test group to remove duplication. Once you’re done, go on to the
next page.
Contact Entity 48
Here’s what our first test looks like after having been refactored:
test/assets/js/spec/entities/contact.spec.js
We’ve removed the only call from describe on line 1, because we’re going to add it to our
new test instead.
Since we’re now using one single instance of an array being passed around (the one created on line
4), we no longer need the deep equal on line 23 and have therefore removed it.
Let’s now move on to our test and modify the initialization method so that we can access it from
our tests to stub it:
Contact Entity 49
contact_manager/assets/js/entities/contact.js
test/assets/js/spec/entities/contact.spec.js
First, we configure our fetch stub to execute the success callback with an empty array, simulating
an empty collection (lines 3-5). On lines 6-7, we stub the contact initialization to return a prepared
Contact Entity 50
value, and line 8 configures a spy on our contact collection’s reset method. We then get to the actual
testing code where we check the contact collection initialization code was run once, and that the
reset method was called with the value returned by the initialization function (as stubbed on lines
6-7).
And we’re done with our test! Don’t forget to remove the call to only so the entire test suite gets
run…
Time-Dependent Tests
Let’s write a test for the “contact:entity” request, which should return a contact instance. Here’s the
relevant application code that gets executed:
contact_manager/assets/js/entities/contact.js
1 getContactEntity: function(contactId){
2 var contact = new Entities.Contact({id: contactId});
3 var defer = $.Deferred();
4 setTimeout(function(){
5 contact.fetch({
6 success: function(data){
7 defer.resolve(data);
8 },
9 error: function(data){
10 defer.resolve(undefined);
11 }
12 });
13 }, 2000);
14 return defer.promise();
15 }
You’ll have noticed that it is quite similar to the code handling the collection, the biggest difference
being the 2-second delay on line 13. In order to test this code, we’ll be using some fake timers that
we can control: instead of having to wait 2 seconds to obtain our contact, we can “fast-forward” the
clock to get it immediately. Here’s what that looks like:
Contact Entity 51
test/assets/js/spec/entities/contact.spec.js
On line 3, we setup our fake clock (which gets cleaned up on line 15): it lets us control time by
calling the tick method to advance time. Line 20 makes our request, and on the next line we check
our Contact constructor was called with the expected arguments. Line 23 then advances the time
by 2 seconds (the duration of our artificial delay) and verifies that the contact’s fetch method has
been called. Finally, on lines 26-28 we check that the value returned by our request matches the one
retrieved by our stubbed fetch method.
Contact Entity 52
By using our fake clock we completely control the time, and that our test therefore is not
asynchronous. In addition to giving us control over time (which will come in handy in the
next test), our fake clock also speeds up our test: we don’t have to wait around for the code
to return the result. Fast tests are a good goal: slow tests are annoying and get run less
often, which is counter-productive. We’re writing tests precisely so they get run often to
catch bugs!
Write your own test for “it has a 2 second delay” checking that the provided promise will only be
resolved after 2 seconds. Use the promise’s state()⁴² method to check the state it is currently in.
I’ll be waiting for you on the next page.
⁴²http://api.jquery.com/deferred.state/
Contact Entity 53
To ensure the promise only gets resolved after 2 seconds, we’ll check its state right before 2 seconds
and at the 2 second mark. Since we can precisely control time with our fake clock, this turns out to
be quite straightforward:
test/assets/js/spec/entities/contact.spec.js
⁴³https://github.com/davidsulc/marionette-testing/commit/342560b27d755bbe1030b8a9ff9bbdbac1a88c18
Testing Localstorage
We will now test our entities are correctly configured for localstorage, and that our localstorage
code works properly. First, let’s add a small test to the contact model and collection to check they’re
configured for localstorage:
test/assets/js/spec/entities/contact.spec.js
Here’s the test structure we’ll use to verify our localstorage adapter (don’t forget to add it to the
test.html file):
Testing Localstorage 55
test/assets/js/spec/apps/config/localstorage.spec.js
1 describe("Entities.localstorage.configureStorage", function(){
2 it("configures the constructor to add a localStorage attribute");
3
4 describe("setting the localStorage key", function(){
5 it("can use the model's `urlRoot` value");
6 it("can use a collection's `url` value");
7 });
8 });
In order to test our model, we’ll need to create a model constructor, and check that calling
configureStorage on it will add a localStorage property to any instances created later. After
translating that into code, we get:
test/assets/js/spec/apps/config/localstorage.spec.js
1 describe("Entities.localstorage.configureStorage", function(){
2 beforeEach(function(){
3 this.urlValue = "testUrl";
4 window._TestModel = Backbone.Model.extend({ urlRoot: this.urlValue });
5 });
6
7 afterEach(function(){
8 delete window._TestModel;
9 delete this.urlValue;
10 });
11
12 it("configures the constructor to add a localStorage attribute", function(){
13 var model = new window._TestModel();
14 expect(model.localStorage).to.not.be.ok;
15 ContactManager.Entities.configureStorage("_TestModel");
16 model = new window._TestModel();
17 expect(model.localStorage).to.be.ok;
18 expect(model.localStorage instanceof Backbone.LocalStorage).to.be.true;
19 });
20
21 // edited for brevity
22 });
As you may remember, when calling configureStorage, we need to provide a path from the window
object. Therefore, we’ve attached our new model definition directly to the window.
In addition, we also want to ensure that each entity will get the same instance of the localstorage
object:
Testing Localstorage 56
test/assets/js/spec/apps/config/localstorage.spec.js
1 describe("Entities.localstorage.configureStorage", function(){
2 beforeEach(function(){
3 // edited for brevity
4 });
5
6 afterEach(function(){
7 // edited for brevity
8 });
9
10 it("configures the constructor to add a localStorage attribute", function(){
11 // edited for brevity
12 });
13
14 it("uses the same localStorage instance for all instances of a given entity",
15 function(){
16 ContactManager.Entities.configureStorage("_TestModel");
17 var modelA = new window._TestModel();
18 var modelB = new window._TestModel();
19
20 expect(modelA.localStorage).to.equal(modelB.localStorage);
21 });
22
23 // edited for brevity
24 });
Remember, on line 20 we’re not using deep.equal (just equal), because we want to verify
that both values refer to the exact same object instance.
test/assets/js/spec/apps/config/localstorage.spec.js
1 describe("Entities.localstorage.configureStorage", function(){
2 // edited for brevity
3
4 describe("setting the localStorage key", function(){
5 before(function(){
6 this.collectionUrl = "collectionUrl";
7 window._TestCollection = Backbone.Collection.extend({
8 url: this.collectionUrl
9 });
10 ContactManager.Entities.configureStorage("_TestCollection");
11 this.collection = new window._TestCollection();
12 });
13
14 after(function(){
15 delete window._TestCollection;
16 delete this.collection;
17 delete this.collectionUrl;
18 });
19
20 it("can use the model's `urlRoot` value", function(){
21 ContactManager.Entities.configureStorage("_TestModel");
22 var model = new window._TestModel();
23 expect(model.localStorage.name).to.equal(this.urlValue);
24 });
25
26 it("can use a collection's `url` value", function(){
27 expect(this.collection.localStorage.name).to.equal(this.collectionUrl);
28 });
29 });
30 });
⁴⁴https://github.com/davidsulc/marionette-testing/commit/086c8ed609b7564df020e7aacf078fc741807514
Testing a Base View
We’ll now be testing our first view: the loading view displaying the spinner! Well, not really. Since
we won’t need to display the common loading view to test its behavior, the testing won’t really be
very different from what we’ve grown used to. But let’s get started anyway:
test/assets/js/spec/common/views.spec.js
1 describe("Common.Views.Loading", function(){
2 it("displays a spinner in the '#spinner' node");
3
4 describe("serializeData", function(){
5 it("serializes default options properly");
6 it("serializes provided options properly");
7 });
8 });
Let’s start by testing that our view will correctly serialize the default placeholder data:
test/assets/js/spec/common/views.spec.js
Your turn: verify that the view correctly serializes the provided options, then move on to the next
page.
Testing a Base View 59
This test is very similar to the one we’ve already implemented, except this time around we provide
options to the view when instantiating it:
test/assets/js/spec/common/views.spec.js
And now things will get serious: we’ll have to take our stubbing to the next level. Here’s the code
we’ll be testing:
test/assets/js/common/views.js
1 onShow: function(){
2 var opts = {
3 // edited for brevity
4 };
5 $("#spinner").spin(opts);
6 }
We’re going to select a DOM element, then call the spin method on it. To be able to test this, we’ll
have to stub the jQuery selector. Except we can just stub all of jQuery, since Backbone relies on it
to work properly. Instead, we’re going to stub it in such a manner that its behavior will be modified
only if it’s called with the “#spinner” argument. In all other cases, we want it to behave as normal.
However, we’ll need to stub the method exactly as it’s called in our code. Currently, our code uses
the global $ value, which isn’t very convenient: let’s modify it to use the Backbone.$ value instead
as it will be far easier to stub:
Testing a Base View 60
test/assets/js/common/views.js
1 onShow: function(){
2 var opts = {
3 // edited for brevity
4 };
5 $("#spinner").spin(opts);
6 Backbone.$("#spinner").spin(opts);
7 }
test/assets/js/spec/common/views.spec.js
On line 2, we keep a reference to the original jQuery value because we’ll need to proxy calls that
shouldn’t be stubbed (i.e. all calls that aren’t “select the #spinner DOM element”). We then create a
stub called spinnerEl containing an empty spin function on line 3.
Then, we stub Backbone.$ in the following manner (lines 5-10):
The actual test is implemented by instantiating a new view and calling its onShow method (which
gets called automatically by Marionette when a view is displayed). We then verify two things:
Testing a Base View 61
⁴⁵https://github.com/davidsulc/marionette-testing/commit/7ad4a071ec01649e66594a42d9e65d19b56c48e8
The About App
Testing a Displayed View
This time around, we’re going to test a view that gets displayed. To do so, we’re going to need some
place to put it once it’s rendered (even if doesn’t actually get displayed) so let’s create a div in
test.html to contain our tested views:
test/test.html
1 <script src="../contact_manager/assets/js/apps/header/list/list_controller.js">
2 </script>
3
4 <!-- View container for tests -->
5 <div id="view-test-container" style="display: none; visibility: hidden;"></div>
6
7 <script type="text/javascript">
We’ve hidden the div using CSS properties, because we’re not actually interested in seeing
the views: we just want somewhere to put them while they’re tested. The only thing we’re
interested in seeing is the test report.
In order to test our view, we’ll need to do some setting up. We’ll use the div added above as the
container for our views, then create a fixture inside the container, which we’ll use as the el provided
to our view constructors. Since we’ll be providing our tested views with an existing DOM element,
that’s where they’re going to render themselves. Here’s our setup code:
test/assets/js/spec/apps/about/show/show_view.spec.js
1 describe("AboutApp.Show.Message", function(){
2 before(function(){
3 this.$container = $("#view-test-container");
4 this.$fixture = $("<div>", { id: "fixture" });
5 });
6
7 after(function(){
8 delete this.$fixture;
9 this.$container.empty();
The About App 63
10 delete this.$container;
11 });
12 });
We’re storing a reference to the div destined to contain our view tests, and creating a view fixture
that will serve as the tested view’s el property.
Now, for the test:
test/assets/js/spec/apps/about/show/show_view.spec.js
1 describe("AboutApp.Show.Message", function(){
2 before(function(){
3 this.$container = $("#view-test-container");
4 this.$fixture = $("<div>", { id: "fixture" });
5 });
6
7 after(function(){
8 this.$container.empty();
9 delete this.$fixture;
10 });
11
12 it("displays the 'about' message", function(){
13 this.$fixture.empty().appendTo(this.$container);
14
15 var view = new ContactManager.AboutApp.Show.Message({
16 el: this.$fixture
17 });
18
19 view.once("render", function(){
20 expect(view.$el.text()).to.contain("About this application");
21 });
22 view.render();
23 });
24 });
Within our test code, we empty the prepared fixture and append it to our designated container (line
13): this ensures that our fixture doesn’t contain leftover DOM elements from previous tests. We can
then create a view instance on lines 15-17, providing our prepared fixture as the tested view’s el
attribute.
Lines 19-22 require a bit more explanation: since we need to test things happening at render time,
we need to prepare the test code before rendering. With the test code ready, we make it run as a
The About App 64
registered event handler: lines 19-21 will get run once when the “render” event is fired. Finally, with
our test code all wired up, we can call render on line 22 which will then trigger the waiting test
code.
As for the actual test on line 20, we simply grab a reference to view.$el which the view’s DOM
element wrapped by jQuery. This way, we can directly call jQuery’s text method⁴⁶ on it to verify
it contains the string we’re looking for.
which is better, as it preserves encalpsulation. But don’t worry if it doesn’t yet make sense
to you. We’ll discuss that later.
test/assets/js/spec/apps/about/show/show_controller.spec.js
1 describe("AboutApp.Show.Controller", function(){
2 describe("showAbout", function(){
3 it.only("displays the 'about' view in the main region",
4 sinon.test(function(){
5 var controller = ContactManager.AboutApp.Show.Controller;
6 var view = {};
7 this.stub(ContactManager.AboutApp.Show, "Message").returns(view);
8 this.stub(ContactManager.regions.main, "show");
9
10 controller.showAbout();
11 expect(ContactManager.regions.main.show).to.have
12 .been.calledWith(view).once;
13 }));
14 });
15 });
test/assets/js/spec/apps/about/show/show_controller.spec.js
1 describe("AboutApp.Show.Controller", function(){
2 describe("showAbout", function(){
3 it.only("displays the 'about' view in the main region",
4 sinon.test(function(){
5 var controller = ContactManager.AboutApp.Show.Controller;
6 var view = {};
7 this.stub(ContactManager.AboutApp.Show, "Message").returns(view);
8 ContactManager.regions = {
9 main: {
10 show: function(){}
11 }
12 }
13 this.stub(ContactManager.regions.main, "show");
14
15 controller.showAbout();
16 expect(ContactManager.regions.main.show).to.have
17 .been.calledWith(view).once;
18 }));
19 });
20 });
It works, but it’s going to be quite annoying in the long run: we’ll have to create an empty region
structure each time we want to test a view. To make our lives easier, we’re going to refactor our
application so we have an internal function that’ll configure the app’s regions. That way, we’ll be
able to simply call that function from our tests and we’ll have regions ready for stubbing.
The About App 66
contact_manager/assets/js/app.js
1 ContactManager.on("before:start", function(){
2 var RegionContainer = Marionette.LayoutView.extend({
3 // edited for brevity
4 });
5
6 ContactManager.regions = new RegionContainer();
7 ContactManager.regions.dialog.onShow = function(view){
8 // edited for brevity
9 };
10 });
contact_manager/assets/js/app.js
1 ContactManager.RegionContainer = Marionette.LayoutView.extend({
2 // edited for brevity
3 });
4
5 ContactManager.on("before:start", function(){
6 ContactManager.regions = new ContactManager.RegionContainer();
7 ContactManager.regions.dialog.onShow = function(view){
8 // edited for brevity
9 };
10 });
contact_manager/assets/js/app.js
1 ContactManager.RegionContainer = Marionette.LayoutView.extend({
2 // edited for brevity
3 });
4
5 ContactManager._configureRegions = function(){
6 this.regions = new ContactManager.RegionContainer();
7 };
8
9 ContactManager.on("before:start", function(){
10 ContactManager._configureRegions();
11 ContactManager.regions.dialog.onShow = function(view){
12 // edited for brevity
13 };
14 });
With this small refactor in place, we’ll be able to call the internal ContactManager._configureRe-
gions function to get our regions set up for us. Let’s do that right now:
test/assets/js/spec/apps/about/show/show_controller.spec.js
1 describe("AboutApp.Show.Controller", function(){
2 describe("showAbout", function(){
3 it.only("displays the 'about' view in the main region",
4 sinon.test(function(){
5 var controller = ContactManager.AboutApp.Show.Controller;
6 var view = {};
7 this.stub(ContactManager.AboutApp.Show, "Message").returns(view);
8 ContactManager.regions = {
9 main: {
10 show: function(){}
11 }
12 }
13 ContactManager._configureRegions();
14 this.stub(ContactManager.regions.main, "show");
15
16 controller.showAbout();
17 expect(ContactManager.regions.main.show).to.have
18 .been.calledWith(view).once;
19 }));
20 });
21 });
The About App 68
Testing Routes
First, let’s copy our application code here for reference:
contact_manager/assets/js/apps/about/about_app.js
We can now move on to testing our “about” app file, which means checking that all routing takes
place as intended. But first, let’s check a router actually gets created when the sub-app is started
(corresponding to the functionality on lines 21-25 above):
The About App 69
test/assets/js/spec/apps/about/about_app.spec.js
1 describe("AboutApp", function(){
2 it("instantiates a router when started", sinon.test(function(){
3 this.stub(ContactManager.AboutApp, "Router");
4
5 ContactManager.AboutApp.start();
6 expect(ContactManager.AboutApp.Router).to.have.been.calledWithNew.once;
7
8 ContactManager.AboutApp.stop();
9 }));
10 });
On line 6, we’re using calledWithNew to ensure a new router instance was created, instead of just
checking that the function has been called.
Let’s now test the “about:show” trigger. If you refer to the application code above, the “about:show”
trigger should result in
test/assets/js/spec/apps/about/about_app.spec.js
1 describe("AboutApp", function(){
2 it("instantiates a router when started", sinon.test(function(){
3 // edited for brevity
4 }));
5
6 describe("triggers", function(){
7 describe("'about:show'", function(){
8 it("navigates to 'about' fragment", sinon.test(function(){
9 ContactManager.AboutApp.start();
10 this.stub(ContactManager, "navigate");
11 this.stub(ContactManager.AboutApp.Show.Controller, "showAbout");
12 this.stub(ContactManager, "execute");
The About App 70
13
14 ContactManager.trigger("about:show");
15 expect(ContactManager.navigate).to.have.been.calledWith("about").once;
16
17 ContactManager.AboutApp.stop();
18 }));
19 });
20 });
21 });
Nothing really exciting here, but note that on line 11, we need to stub showAbout even though we
never use it in our tests. If we don’t stub it out, showAbout will dutifully display the about message:
We still need to test that as a result of the “about:show” trigger, the showAbout controller action will
be run and that the “set:active:header” command gets sent:
test/assets/js/spec/apps/about/about_app.spec.js
1 describe("triggers", function(){
2 beforeEach(function(){
3 ContactManager.AboutApp.start();
4 sinon.stub(ContactManager, "navigate");
5 });
6
7 afterEach(function(){
8 ContactManager.AboutApp.stop();
9 ContactManager.navigate.restore();
10 });
11
The About App 71
12 describe("'about:show'", function(){
13 beforeEach(function(){
14 sinon.stub(ContactManager.AboutApp.Show.Controller, "showAbout");
15 sinon.stub(ContactManager, "execute");
16
17 ContactManager.trigger("about:show");
18 });
19
20 afterEach(function(){
21 ContactManager.AboutApp.Show.Controller.showAbout.restore();
22 ContactManager.execute.restore();
23 });
24
25 it("navigates to 'about' fragment", sinon.test(function(){
26 expect(ContactManager.navigate).to.have.been.calledWith("about").once;
27 }));
28
29 it("executes AboutApp.Show.Controller.showAbout", function(){
30 expect(ContactManager.AboutApp.Show.Controller.showAbout).to.have
31 .been.called.once;
32 });
33
34 it("sets the 'about' header as active", function(){
35 expect(ContactManager.execute).to.have
36 .been.calledWith("set:active:header", "about").once;
37 });
38 });
39 });
contact_manager/assets/js/apps/about/about_app.js
1 AboutApp.Router = Marionette.AppRouter.extend({
2 appRoutes: {
3 "about" : "showAbout"
4 }
5 });
6
7 var API = {
8 showAbout: function(){
9 AboutApp.Show.Controller.showAbout();
10 ContactManager.execute("set:active:header", "about");
The About App 72
11 }
12 };
When the “about” URL fragment is hit, we’re going to execute the controller’s showAbout action,
which will:
Sound familiar? We now need to make a choice in how we code our app, and how we test it. Ideally,
API would remain a private variable in the app, because that means that the only way to access it
is through the defined channels (routing or triggers). In addition, our tests should be independent
from the application’s implementation, and we should therefore test twice (e.g.) that showAbout gets
called: once via routing, another via the trigger. Sadly, this means duplicating test code, which is
also a recipe for unhappiness.
What to do? The reason tests shouldn’t depend on application internals, is because they’re prone to
change: refactoring the app might mean breaking tests, even when there was no external difference
(and the tests should still work). Personally, I’m fine with accepting a small risk of needing to rewrite
tests after refactoring, provided it is limited and will make my life easier in the long run. Therefore,
I suggest refactoring our app slightly to make testing easier:
contact_manager/assets/js/apps/about/about_app.js
1 AboutApp._API = {
2 showAbout: function(){
3 AboutApp.Show.Controller.showAbout();
4 ContactManager.execute("set:active:header", "about");
5 }
6 };
7
8 ContactManager.on("about:show", function(){
9 ContactManager.navigate("about");
10 AboutApp._API.showAbout();
11 });
12
13 AboutApp.on("start", function(){
14 new AboutApp.Router({
15 controller: AboutApp._API
16 });
17 });
By making the API accessible, we can stub it out, which will greatly simplify our tests: we can
individually test the _API.showAbout behavior, and then simply check that routing and triggers call
that function. Let’s start by refactoring our existing code:
The About App 73
test/assets/js/spec/apps/about/about_app.spec.js
1 describe("triggers", function(){
2 beforeEach(function(){
3 ContactManager.AboutApp.start();
4 sinon.stub(ContactManager, "navigate");
5 });
6
7 afterEach(function(){
8 ContactManager.AboutApp.stop();
9 ContactManager.navigate.restore();
10 });
11
12 describe("'about:show'", function(){
13 beforeEach(function(){
14 sinon.stub(ContactManager.AboutApp._API, "showAbout");
15 sinon.stub(ContactManager.AboutApp.Show.Controller, "showAbout");
16 sinon.stub(ContactManager, "execute");
17
18 ContactManager.trigger("about:show");
19 });
20
21 afterEach(function(){
22 ContactManager.AboutApp._API.showAbout.restore();
23 ContactManager.AboutApp.Show.Controller.showAbout.restore();
24 ContactManager.execute.restore();
25 });
26
27 it("navigates to 'about' fragment", sinon.test(function(){
28 expect(ContactManager.navigate).to.have.been.calledWith("about").once;
29 }));
30
31 it("executes the API's showAbout", function(){
32 expect(ContactManager.AboutApp._API.showAbout).to.have.been.called.once;
33 });
34
35 it("executes AboutApp.Show.Controller.showAbout", function(){
36 expect(ContactManager.AboutApp.Show.Controller.showAbout).to.have
37 .been.called.once;
38 });
39
40 it("sets the 'about' header as active", function(){
41 expect(ContactManager.execute).to.have
The About App 74
42 .been.calledWith("set:active:header", "about").once;
43 });
44 });
45 });
We’re now simply checking that the API’s showAbout gets called (line 32 above), which we’ll test
separately:
test/assets/js/spec/apps/about/about_app.spec.js
1 describe("API", function(){
2 describe("showAbout", function(){
3 beforeEach(function(){
4 sinon.stub(ContactManager.AboutApp.Show.Controller, "showAbout");
5 sinon.stub(ContactManager, "execute");
6
7 ContactManager.AboutApp._API.showAbout();
8 });
9
10 afterEach(function(){
11 ContactManager.AboutApp.Show.Controller.showAbout.restore();
12 ContactManager.execute.restore();
13 });
14
15 it("executes AboutApp.Show.Controller.showAbout", function(){
16 expect(ContactManager.AboutApp.Show.Controller.showAbout).to.have
17 .been.called.once;
18 });
19
20 it("sets the 'about' header as active", function(){
21 expect(ContactManager.execute).to.have
22 .been.calledWith("set:active:header", "about").once;
23 });
24 });
25 });
26
27 describe("triggers", function(){
28 // edited for brevity
We still need to test routing: when a certain route is triggered, we need to verify that the proper
action is taken. In normal use, the route change would be triggered by the browser: when the user
The About App 75
hits “enter” after entering a URL, for example. In our tests, however, we’ll need to trigger the route
manually by passing trigger: true when we navigate.
Of course, if we want any navigation to work at all, we’ll need to start Backbone.history. There
is a trick, though: as we navigate around, we’re going to change the URL fragment. Then, probably
later and in an unrelated test, we’re going to start history again but this time there’ll be a fragment
already in the URL (from a previous test); this in turn will trigger routing events unrelated to our
tests and will be difficult to debug. To address this problem, we need to navigate to “” (i.e. empty
string) before stopping history, as this will remove any fragment from the URL, leaving a stable
state for the next routing test.
test/assets/js/spec/apps/about/about_app.spec.js
1 describe("API", function(){
2 // edited for brevity
3 });
4
5 describe("routing", function(){
6 beforeEach(function(){
7 ContactManager.AboutApp.start();
8 sinon.stub(ContactManager.AboutApp._API, "showAbout");
9 Backbone.history.start();
10 });
11
12 afterEach(function(){
13 ContactManager.AboutApp.stop();
14 ContactManager.AboutApp._API.showAbout.restore();
15 Backbone.history.navigate("");
16 Backbone.history.stop();
17 });
18
19 it("executes the API's showAbout", function(){
20 ContactManager.navigate("about", { trigger: true });
21 expect(ContactManager.AboutApp._API.showAbout).to.have.been.called.once;
22 });
23 });
24
25 describe("triggers", function(){
Why is that? The fact that our test is accessing the real controller’s showAbout code is telling: our
test is executing the actual application code, then crashing because no regions are configured to
display views. The question then becomes “why isn’t our stub on line 8 working as expected?”. As
it happens, we don’t need to file a bug report just yet: the stubbing is happening correctly, but a tad
too late. Remember our application code:
contact_manager/assets/js/apps/about/about_app.js
1 AboutApp.on("start", function(){
2 new AboutApp.Router({
3 controller: AboutApp._API
4 });
5 });
When we start our application, a router is initialized with whatever it finds in _API. Sadly, at that
point in time we hadn’t stubbed it out yet, so the router is using the original application code. Let’s
try again, but stubbing the API before starting the About app:
The About App 77
test/assets/js/spec/apps/about/about_app.spec.js
1 describe("routing", function(){
2 beforeEach(function(){
3 sinon.stub(ContactManager.AboutApp._API, "showAbout");
4 ContactManager.AboutApp.start();
5 sinon.stub(ContactManager.AboutApp._API, "showAbout");
6 Backbone.history.start();
7 });
⁴⁷https://github.com/davidsulc/marionette-testing/commit/84a7d16b5cd14cd6f77bcc7db5da6e70e3a391d8
Contacts New View
Implement the tests for the contact app’s new view yourself. This is the code it contains:
contact_manager/assets/js/apps/contacts/new/new_view.js
test/assets/js/spec/apps/contacts/new/new_view.spec.js
1 describe("ContactsApp.New.Contact", function(){
2 it("inherits from ContactsApp.Common.Views.Form");
3 it("sets the 'title' attribute to 'New Contact'");
4 it("sets the submit button text to 'Create contact'");
5 });
A few hints:
• javascript’s instanceof⁴⁸ operator will tell you whether an object is an instance of another,
and can also be used to check “inheritance”;
• don’t forget to provide a contact model instance when you create the view;
• you can use jQuery’s find⁴⁹ and text⁵⁰ functions to determine a DOM element’s text value;
When you’re ready, move on to the next page. See you there!
⁴⁸https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/instanceof
⁴⁹http://api.jquery.com/find/
⁵⁰http://api.jquery.com/text/
Contacts New View 79
Before we’ll be able to do any testing at all, we need some setup code:
test/assets/js/spec/apps/contacts/new/new_view.spec.js
1 describe("ContactsApp.New.Contact", function(){
2 before(function(){
3 this.$fixture = $("<div>", { id: "fixture" });
4 this.$container = $("#view-test-container");
5 });
6
7 after(function(){
8 delete this.$fixture;
9 this.$container.empty();
10 delete this.$container;
11 });
12
13 beforeEach(function(){
14 this.$fixture.empty().appendTo(this.$container);
15
16 this.view = new ContactManager.ContactsApp.New.Contact({
17 el: this.$fixture,
18 model: new ContactManager.Entities.Contact()
19 });
20 });
21
22 afterEach(function(){
23 delete this.view;
24 });
25 });
test/assets/js/spec/apps/contacts/new/new_view.spec.js
10 });
11
12 it("sets the 'title' attribute to 'New Contact'", function(){
13 expect(this.view.title).to.equal("New Contact");
14 });
15
16 it("sets the submit button text to 'Create contact'", function(){
17 this.view.once("render", function(){
18 expect(this.$el.find(".js-submit").text()).to.equal("Create contact");
19 });
20 this.view.render();
21 });
Let’s take a few moments to discuss that last test. We attach our event handler on this.view, which
means the callback’s context will be the value of this.view as determined on line 17. Therefore, on
line 18, this will evaluate to the view instance we are currently testing, which in turn means we
can access its $el property to perform our tests.
this.view.once("render", function(){
expect(this.view.$el.find(".js-submit").text()).to.equal("Create contact");
});
our tests would raise an error saying “this.view is undefined”. That’s because our callback’s context
is the tested view, and this.view would therefore be equivalent to tested view’s view property,
which is undefined.
Note that there are other ways to achieve correct test code that might be more readable to you or
other members on your team. Here are two:
and
Contacts New View 81
Both of these take into account that javascript will create a new scope within a function, so they
declare variables before the function. Since those variables exist in the scope when the function is
created, they will exist within the function’s closure. Problem solved!
Git commit with our tests for the contact app’s ‘new’ view:
344d0b1deea61ee4acdff34cbfd042c392bf8df3⁵¹
⁵¹https://github.com/davidsulc/marionette-testing/commit/344d0b1deea61ee4acdff34cbfd042c392bf8df3
The Contact App’s “Show” Action
We’ll now test the contact sub-app’s “show” functionality, composed of its views and controller.
Let’s start with the views, which look like this:
contact_manager/assets/js/apps/contacts/show/show_view.js
Try and create your own test for these two views. Hints:
• you’ll want to look at the template for the missing contact view, so you know what DOM
element to select to check for text;
• to trigger a click event, look into jQuery’s click⁵².
⁵²http://api.jquery.com/click/
The Contact App’s “Show” Action 83
After adding our usual setup/teardown code for view tests, the test for the missing contact view is
pretty simple: we just check for known text.
test/assets/js/spec/apps/contacts/show/show_view.spec.js
1 describe("ContactsApp.Show", function(){
2 // fixture and container setup code (edited for brevity)
3
4 describe("MissingContact", function(){
5 it("indicates the contact is missing", function(){
6 var view = new ContactManager.ContactsApp.Show.MissingContact();
7 view.once("render", function(){
8 expect(
9 this.$el.find(".alert-error").text()
10 ).to.equal("This contact doesn't exist !");
11 });
12 view.render();
13 });
14 });
15 });
Our test for the show view isn’t much harder: we simply trigger a click event on the edit button and
verify that the triggered event provides the same model as in the view:
test/assets/js/spec/apps/contacts/show/show_view.spec.js
1 describe("Contact", function(){
2 it("triggers 'contact:edit' with the same model when the
3 edit button is clicked", sinon.test(function(){
4 var model = new ContactManager.Entities.Contact({
5 id: 1,
6 firstName: "John",
7 lastName: "Doe"
8 });
9 var view = new ContactManager.ContactsApp.Show.Contact({ model: model });
10 this.stub(view, "trigger");
11
12 view.once("render", function(){
13 this.$el.find(".js-edit").click();
14 expect(this.trigger).to.have.been.calledWith("contact:edit", model).once;
15 });
16 view.render();
17 }));
18 });
The Contact App’s “Show” Action 84
This test doesn’t work properly! We’ll address why, how to fix it, and how to avoid non-
functional tests in chapter “Improving View Test Resilience”. If you want to fix it right
away, change the stub on line 10 to a spy.
Technically, you wouldn’t really need a sandbox in the code above: you could just use
sinon.stub on line 10 and never actually restore the original functionality. That’s because
we’re stubbing a local variable (that is, declared within our test function), which will fall
out of scope when we leave the test function. However, it’s best to keep a consistent use
of stubbing, or it will come back to bite you sooner or later (such as after a refactor where
view is move to a higher scope, but the stubbing isn’t adapted).
And now, try and implement the following tests, remembering how intelligently stubbing function-
ality allows you to control the testing environment:
test/assets/js/spec/apps/contacts/show/show_controller.spec.js
1 describe("ContactsApp.Show.Controller", function(){
2 describe("showContact", function(){
3 it("shows a loading view before loading a contact");
4 it("shows the missing contact view if the requested contact can't be found");
5 it("shows the contact view");
6 it("proxies 'contact:edit' when it is triggered on the view");
7 });
8 });
Once you’ve got those tests working (or have struggled for a while), turn the page!
The Contact App’s “Show” Action 85
test/assets/js/spec/apps/contacts/show/show_controller.spec.js
1 describe("ContactsApp.Show.Controller", function(){
2 before(function(){
3 ContactManager._configureRegions();
4 this.controller = ContactManager.ContactsApp.Show.Controller;
5 });
6
7 after(function(){
8 delete this.controller;
9 });
10
11 beforeEach(function(){
12 sinon.stub(ContactManager.regions.main, "show");
13 });
14
15 afterEach(function(){
16 ContactManager.regions.main.show.restore();
17 });
18
19 describe("showContact", function(){
20 it("shows a loading view before loading a contact", sinon.test(function(){
21 var view = {};
22 this.stub(ContactManager.Common.Views, "Loading").returns(view);
23
24 this.controller.showContact(1);
25 var showFunction = ContactManager.regions.main.show;
26 expect(showFunction).to.have.been.calledWith(view).once;
27 }));
28
29 it("shows the missing contact view if the requested contact can't be found");
30 it("shows the contact view");
31 it("proxies 'contact:edit' when it is triggered on the view");
32 });
33 });
We stub the loading view constructor to return an object we have a reference to. Then, when the
controller action is called, we just have to check that our app attempts to display this object in the
main region.
The Contact App’s “Show” Action 86
Why use a plain object on lines 21-22? Because we don’t actually need a view object for
what we want to test and relying less on our app code means that fewer tests will break
when problems arise. In other words, we wouldn’t want our test above to fail just because
there was a problem with the loading view’s constructor: the loading view will be tested
elsewhere, so we can assume it works in our current test. In an ideal scenario, if your app
code has a bug, only a single test would fail allowing you to rapidly pinpoint the specific
problem that arose. Naturally, that usually isn’t possible: it remains a goal, though, and is
why we try to keep reliance on our own code to the absolute minimum necessary.
To test whether our “missing contact” view gets displayed when a contact isn’t found, we’ll just stub
the request to return undefined:
test/assets/js/spec/apps/contacts/show/show_controller.spec.js
1 it("shows the missing contact view if the requested contact can't be found",
2 sinon.test(function(){
3 this.stub(ContactManager, "request").withArgs("contact:entity", 1)
4 .returns(undefined);
5 var view = {};
6 this.stub(ContactManager.ContactsApp.Show, "MissingContact").returns(view);
7
8 this.controller.showContact(1);
9 expect(ContactManager.request).to.have.been
10 .calledWith("contact:entity", 1).once;
11 expect(ContactManager.regions.main.show).to.have.been.calledWith(view).once;
12 }));
Again, on line 6 we stub out the missing contact view’s constructor: it was tested in the
show_view.spec.js file, so we can assume it works in our current test. Right now, all we’re
interested in is whether our app will display whatever is returned from the MissingContact
constructor in our main region if a contact can’t be located.
Notice that on lines 3-4, we’re stubbing our request function with specific arguments. This means
that the stubbed function will return the provided values only if called with the matching arguments,
in other cases it will return undefined. In our specific case, it doesn’t change much since we want
the stub to return undefined anyway, but it does make our tests more explicit. Here’s an extract
from Sinon’s documentation⁵³ detailing the feature:
⁵³http://sinonjs.org/docs/
The Contact App’s “Show” Action 87
The next test, checking the normal contact view gets displayed if a contact is found, is similar in
nature to the test we just wrote so let’s start with the same code:
test/assets/js/spec/apps/contacts/show/show_controller.spec.js
Line 3 returns an empty object as the model value: once again, we don’t care about the
actual value. We just need it to be “not undefined” so that the application code will
instantiate the correct view (i.e. the normal contact view, not the missing contact view).
Sadly, this will yield an error: “contactView.on is not a function”. Why is that? Let’s peek at our
application code again:
The Contact App’s “Show” Action 88
contact_manager/assets/js/apps/contacts/show/show_controller.js
On line 6, our controller code wants to register an event handler on our view. Unfortunately, our
stubbed constructor provides an empty object which isn’t equipped to deal with events, hence the
raised error. It’s easy enough to fix, though:
test/assets/js/spec/apps/contacts/show/show_controller.spec.js
Line 5 simply uses Underscore’s extend⁵⁴ to add Backbone’s events module⁵⁵. With that change in
place, our simple view object can register event handlers, and now our test works fine!
Let’s finish up by implementing our last test:
⁵⁴http://underscorejs.org/#extend
⁵⁵http://backbonejs.org/#Events
The Contact App’s “Show” Action 89
test/assets/js/spec/apps/contacts/show/show_controller.spec.js
1. create a known model and stub the request method to return it;
2. stub the view constructor so it returns a view using our model;
3. stub ContactManager.trigger so we can check if and how it got called.
Only then can we actually perform our test. However, if you think about it, our view will depend
directly on the contact model: as long as the contact is defined, a Show view instance will get built.
So we could refactor our app slightly to facilitate our testing: we just need to grab a reference to the
view that the controller creates and configures (by adding an event listener).
Happily, now that we have a test in place for this functionality, we can refactor fearlessly: as long
as our tests run correctly after the refactor, we can be confident we didn’t break anything.
This is the controller we currently have:
The Contact App’s “Show” Action 90
contact_manager/assets/js/apps/contacts/show/show_controller.js
1 Show.Controller = {
2 showContact: function(id){
3 // edited for brevity
4
5 var fetchingContact = ContactManager.request("contact:entity", id);
6 $.when(fetchingContact).done(function(contact){
7 var contactView;
8 if(contact !== undefined){
9 contactView = new Show.Contact({
10 model: contact
11 });
12
13 contactView.on("contact:edit", function(contact){
14 ContactManager.trigger("contact:edit", contact.get("id"));
15 });
16 }
17 else{
18 contactView = new Show.MissingContact();
19 }
20
21 ContactManager.regions.main.show(contactView);
22 });
23 }
24 }
All we have to do to simplify our test code is to have the controller return the view it instantiates:
contact_manager/assets/js/apps/contacts/show/show_controller.js
1 Show.Controller = {
2 showContact: function(id){
3 // edited for brevity
4
5 var fetchingContact = ContactManager.request("contact:entity", id);
6 var contactView;
7 $.when(fetchingContact).done(function(contact){
8 var contactView;
9 if(contact !== undefined){
10 contactView = new Show.Contact({
11 model: contact
12 });
The Contact App’s “Show” Action 91
13
14 contactView.on("contact:edit", function(contact){
15 ContactManager.trigger("contact:edit", contact.get("id"));
16 });
17 }
18 else{
19 contactView = new Show.MissingContact();
20 }
21
22 ContactManager.regions.main.show(contactView);
23 });
24
25 return contactView;
26 }
27 }
test/assets/js/spec/apps/contacts/show/show_controller.spec.js
We’re now able to grab a reference (line 13) to the view instantiated by the controller, so we no
longer need to instantiate one ourselves and stub the constructor.
The Contact App’s “Show” Action 92
⁵⁶https://github.com/davidsulc/marionette-testing/commit/c8bf5a8d427d8155ba596cba7459298e44419b68
The Contact App’s “Edit” Action
You’ve become quite proficient at testing at this time, so go ahead and implement the tests for the
“edit” functionality. Here are our lists of tests:
test/assets/js/spec/apps/contacts/edit/edit_view.spec.js
1 describe("ContactsApp.Edit.Contact", function(){
2 it("inherits from ContactsApp.Common.Views.Form");
3 it("sets the submit button text to 'Update contact'");
4 it("sets the 'title' attribute according to the contact's name");
5 it("creates an H1 title if options.generateTitle is true");
6 });
test/assets/js/spec/apps/contacts/edit/edit_controller.spec.js
1 describe("ContactsApp.Edit.Controller", function(){
2 describe("editContact", function(){
3 it("shows a loading view before loading a contact");
4 it("shows the missing contact view if the requested contact can't be found");
5
6 describe("contact updating", function(){
7 it("shows the contact view");
8 it("triggers 'contact:show' with the same id if
9 saving the modification was successful");
10 it("triggers method 'onFormDataInvalid' if
11 saving the modification was not successful");
12 });
13 });
14 });
The tests for the edit view are very similar to those covering the new view:
test/assets/js/spec/apps/contacts/edit/edit_view.spec.js
1 describe("ContactsApp.Edit.Contact", function(){
2 before(function(){
3 this.$fixture = $("<div>", { id: "fixture" });
4 this.$container = $("#view-test-container");
5 });
6
7 after(function(){
8 delete this.$fixture;
9 this.$container.empty();
10 delete this.$container;
11 });
12
13 beforeEach(function(){
14 this.$fixture.empty().appendTo(this.$container);
15
16 this.view = new ContactManager.ContactsApp.Edit.Contact({
17 el: this.$fixture,
18 model: new ContactManager.Entities.Contact({
19 firstName: "John",
20 lastName: "Doe"
21 })
22 });
23 });
24
25 afterEach(function(){
26 delete this.view;
27 });
28
29 it("inherits from ContactsApp.Common.Views.Form", function(){
30 var CommonForm = ContactManager.ContactsApp.Common.Views.Form;
31 expect(this.view instanceof CommonForm).to.be.true;
32 });
33
34 it("sets the submit button text to 'Update contact'", function(){
35 this.view.once("render", function(){
36 expect(this.$el.find(".js-submit").text()).to.equal("Update contact");
37 });
38 this.view.render();
39 });
The Contact App’s “Edit” Action 95
40
41 it("sets the 'title' attribute according to the contact's name", function(){
42 var firstName = this.view.model.get("firstName"),
43 lastName = this.view.model.get("lastName");
44 expect(this.view.title).to.equal("Edit " + firstName + " " + lastName);
45 });
46
47 it("creates an H1 title if options.generateTitle is true", function(){
48 this.view.once("render", function(){
49 expect(this.$el.find("h1").first().text()).to.equal('');
50 });
51 this.view.render();
52
53 this.view.options.generateTitle = true;
54 this.view.once("render", function(){
55 var firstName = this.model.get("firstName"),
56 lastName = this.model.get("lastName"),
57 viewTitle = this.$el.find("h1").first().text();
58 expect(viewTitle).to.equal("Edit " + firstName + " " + lastName);
59 });
60 this.view.render();
61 });
62 });
test/assets/js/spec/apps/contacts/edit/edit_controller.spec.js
1 describe("ContactsApp.Edit.Controller", function(){
2 before(function(){
3 ContactManager._configureRegions();
4 this.controller = ContactManager.ContactsApp.Edit.Controller;
5 });
6
7 after(function(){
8 delete this.controller;
9 });
10
11 describe("editContact", function(){
12 beforeEach(function(){
13 sinon.stub(ContactManager.regions.main, "show");
14 });
15
The Contact App’s “Edit” Action 96
16 afterEach(function(){
17 ContactManager.regions.main.show.restore();
18 });
19
20 it("shows a loading view before loading a contact", sinon.test(function(){
21 var view = {};
22 this.stub(ContactManager.Common.Views, "Loading").returns(view);
23
24 this.controller.editContact(1);
25 expect(ContactManager.regions.main.show).to.have
26 .been.calledWith(view).once;
27 }));
28
29 it("shows the missing contact view if the requested contact can't be found",
30 sinon.test(function(){
31 this.stub(ContactManager, "request").withArgs("contact:entity", 1)
32 .returns(undefined);
33 var view = {};
34 this.stub(ContactManager.ContactsApp.Show, "MissingContact").returns(view);
35
36 this.controller.editContact(1);
37 expect(ContactManager.regions.main.show).to.have
38 .been.calledWith(view).once;
39 }));
40
41 describe("contact updating", function(){
42 // edited for brevity
43 });
44 });
45 });
The code above should be quite familiar to you, as it strongly resembles other tests we’ve produced.
Let’s move on to more interesting aspects:
The Contact App’s “Edit” Action 97
test/assets/js/spec/apps/contacts/edit/edit_controller.spec.js
test/assets/js/spec/apps/contacts/edit/edit_controller.spec.js
On line 4, we stub the model’s save method to ensure our test will always be dealing with the case
where the model was able to be saved successfully.
Now for our last test:
test/assets/js/spec/apps/contacts/edit/edit_controller.spec.js
This time around, we’re stubbing the model’s save method to add a validationError property
and return false (lines 4-8). But this test doesn’t work and we get the following error: “Cannot
stub non-existent own property onFormDataInvalid”. Once again, this due to our view instance not
implementing all the necessary functionality. To fix this, we’ll simply instantiate a full view instance
for our tests:
The Contact App’s “Edit” Action 99
test/assets/js/spec/apps/contacts/edit/edit_controller.spec.js
Our tests now all work, and we’re done testing the contact app’s “edit” functionality.
⁵⁷https://github.com/davidsulc/marionette-testing/commit/eb3709ae20154463e79839ff10afcc6b5c3c91d6
Testing the Common View
Let’s start testing our common view (contact_manager/assets/js/apps/contacts/common/view.js)
with these tests:
test/assets/js/spec/apps/contacts/common/view.spec.js
1 describe("ContactsApp.Common.Views.Form", function(){
2 before(function(){
3 this.$fixture = $("<div>", { id: "fixture" });
4 this.$container = $("#view-test-container");
5 });
6
7 after(function(){
8 delete this.$fixture;
9 this.$container.empty();
10 delete this.$container;
11 });
12
13 beforeEach(function(){
14 this.$fixture.empty().appendTo(this.$container);
15 });
16
17 describe("error display", function(){
18 it("displays form errors on 'form:data:invalid' event");
19 it("clears the displayed errors before displaying new error messages");
20 });
21 });
Try implementing these tests, with a twist: since we’ll need to access the error messages displayed
in the DOM, write a getErrorText helper function we can use for this purpose.
Testing the Common View 101
Our tests will need a view, so let’s create one for them:
test/assets/js/spec/apps/contacts/common/view.spec.js
We can then implement our first test, which will deal with form errors. To do so, it will naturally
have to locate the error messages within the DOM. This is our view code adding the error messages:
contact_manager/assets/js/apps/contacts/common/view.js
The end result is that an error message for (e.g.) the “firstName” attribute gets added within the next
sibling to the “#contact-firstName” DOM element. This next sibling will have an “error” CSS class,
and will in turn contain the actual error message within a span. Happily, jQuery’s text⁵⁸ function
will include descendants, so we just need to grab the next sibling (of the “#contact-…” element)
which has an “error” class. Here it goes:
test/assets/js/spec/apps/contacts/common/view.spec.js
⁵⁸http://api.jquery.com/text/
Testing the Common View 102
10 });
11 this.view.render();
12 });
As you can see, lines 3 and 8 are virtual duplicates one of another. Let’s refactor our code by creating
a getErrorText helper function:
test/assets/js/spec/apps/contacts/common/view.spec.js
1 describe("error display", function(){
2 beforeEach(function(){
3 this.view = new ContactManager.ContactsApp.Common.Views.Form({
4 el: this.$fixture,
5 model: new ContactManager.Entities.Contact()
6 });
7
8 this.getErrorText = function(attribute){
9 var errorEl = this.view.$el.find("#contact-" + attribute).next(".error");
10 return errorEl.text();
11 };
12 });
13
14 it("displays form errors on 'form:data:invalid' event", function(){
15 var self = this;
16 this.view.once("render", function(){
17 expect(self.getErrorText("firstName")).to.equal('');
18 this.triggerMethod("form:data:invalid", {
19 firstName: "first name error message"
20 });
21 expect(self.getErrorText("firstName"))
22 .to.equal("first name error message");
23 });
24 this.view.render();
25 });
26
27 // edited for brevity
First, the getErrorText function definition: it’s defined on line 8 as a method on the current object.
When we refer to this.view on the following line, this therefore evaluates to that same object and
we’ll receive the view declared on lines 3-6.
In the callback function on lines 16-23 however, javascript will create a new scope due to the function
definition starting on line 16. The result is that a reference to this within that same function would
return the value of this.view as determined on line 16. This allows to use this.triggerMethod
on line 18 to refer to the view’s triggerMethod function, for example. It cannot work, however, to
access the getErrorText function, because it isn’t defined on the view.
To circumvent this issue, we save a reference to the current test context on line 15, and use it to
access the getErrorText function on line 17. So when getErrorText gets called, it will be executed
as a method of the object referred to by the value of this on line 15, and will use that object as the
value of this within its own definition. In other words, the this values we have on line 9 above
will evaluate to the context of line 15, which happens to be our test context.
test/assets/js/spec/apps/contacts/common/view.spec.js
test/assets/js/spec/apps/contacts/common/view.spec.js
test/assets/js/spec/apps/contacts/common/view.spec.js
1 it("triggers 'form:submit' with the form data when the submit button is clicked",
2 function(){
3 var modelData = {
4 firstName: "John",
5 lastName: "Doe",
6 phoneNumber: "888-12345"
7 };
8 var view = new ContactManager.ContactsApp.Common.Views.Form({
9 el: this.$fixture,
10 model: new ContactManager.Entities.Contact(modelData)
11 });
12
13 var submitSpy = sinon.spy();
Testing the Common View 105
14 view.on("form:submit", submitSpy);
15
16 view.once("render", function(){
17 expect(submitSpy.called).to.be.false;
18 modelData.lastName = "Dunn";
19
20 $("#contact-lastName").val(modelData.lastName);
21 view.$el.find(".js-submit").click();
22 expect(submitSpy.calledOnce).to.be.true;
23 expect(submitSpy.firstCall.args[0]).to.deep.equal(modelData);
24 });
25 view.render();
26 });
On line 13, we declare a spy instance, which is basically an object that will log calls made to it. We
then add it as an event handler to the view’s “form:submit” event on line 14. In turn, we can then
check that before a click event is triggered on the “edit” button the “form:submit” event hasn’t been
fired (line 17), but that it is in fact fired after the button is clicked (line 22).
Not only can we use a spy to determine whether or not a function was called, we can also use it to
inspect the actual call arguments. On line 23, we access the first call to the spy using the firstCall
property, from which we can access the args property to inspect that call’s arguments. We expect
the first call argument to be an object containing the form data, which should be the same as the
values from modelData since that’s what we used to fill in the form. We then just use deep equal
to check equivalence, as we know we’ll be dealing with 2 distinct objects and a simple equal test
would return false.
Note also that on line 20 we directly modify the form value in the DOM, for 2 reasons:
• we’re testing that the data sent when the form is submitted comes from the form (not the
underlying model), therefore it makes sense to modify the form data, and not rely on updating
the underlying model;
• our test depends on an event handler registered to the first “render” event, which precludes
rerendering within our test without reworking the flow.
Testing the Common View 106
It is of course possible to render multiple times within a single test, for example:
view.once("render", function(){
expect(submitSpy.called).to.be.false;
modelData.lastName = "Dunn";
view.once("render", function(){
console.log("rerendered");
view.$el.find(".js-submit").click();
expect(submitSpy.calledOnce).to.be.true;
expect(submitSpy.firstCall.args[0]).to.deep.equal(modelData);
});
view.model.set(modelData);
view.render();
});
view.render();
⁵⁹https://github.com/davidsulc/marionette-testing/commit/d84628afdd74c1db3066de5df7f559c3f7236923
The Main Contacts App
Once again, we’ll refactor our application code to make testing more manageable.
contact_manager/assets/js/apps/contacts/contacts_app.js
1 var API = {
2 ContactsApp._API = {
3 // edited for brevity
4 };
5
6 ContactManager.on("contacts:list", function(){
7 ContactManager.navigate("contacts");
8 API.listContacts();
9 ContactsApp._API.listContacts();
10 });
11
12 ContactManager.on("contacts:filter", function(criterion){
13 // edited for brevity
14 });
15
16 ContactManager.on("contact:show", function(id){
17 ContactManager.navigate("contacts/" + id);
18 API.showContact(id);
19 ContactsApp._API.showContact(id);
20 });
21
22 ContactManager.on("contact:edit", function(id){
23 ContactManager.navigate("contacts/" + id + "/edit");
24 API.editContact(id);
25 ContactsApp._API.editContact(id);
26 });
27
28 ContactsApp.on("start", function(){
29 new ContactsApp.Router({
30 controller: API
31 controller: ContactsApp._API
32 });
33 });
The Main Contacts App 108
test/assets/js/spec/apps/contacts/contacts_app.spec.js
1 describe("ContactsApp", function(){
2 it("instantiates a router when started", sinon.test(function(){
3 this.stub(ContactManager.ContactsApp, "Router");
4
5 ContactManager.ContactsApp.start();
6 expect(ContactManager.ContactsApp.Router).to.have.been.calledWithNew.once;
7
8 ContactManager.ContactsApp.stop();
9 }));
10 });
test/assets/js/spec/apps/contacts/contacts_app.spec.js
25 if present", sinon.test(function(){
26 ContactManager.ContactsApp._API.listContacts("test");
27 expect(ContactManager.ContactsApp.List.Controller.listContacts).to.have
28 .been.calledWith("test").once;
29 }));
30
31 it("sets the 'contacts' header as active", function(){
32 expect(ContactManager.execute).to.have
33 .been.calledWith("set:active:header", "contacts").once;
34 });
35 });
36 });
test/assets/js/spec/apps/contacts/contacts_app.spec.js
Write the tests for both the “showContact” and the “editContact” controller actions. Naturally, there
will be duplicated code, so don’t forget to refactor.
The Main Contacts App 110
test/assets/js/spec/apps/contacts/contacts_app.spec.js
1 describe("API", function(){
2 beforeEach(function(){
3 sinon.stub(ContactManager, "execute");
4 });
5
6 afterEach(function(){
7 ContactManager.execute.restore();
8 });
9
10 describe("listContacts", function(){
11 beforeEach(function(){
12 sinon.stub(ContactManager.ContactsApp.List.Controller, "listContacts");
13 sinon.stub(ContactManager, "execute");
14
15 ContactManager.ContactsApp._API.listContacts();
16 });
17
18 afterEach(function(){
19 ContactManager.ContactsApp.List.Controller.listContacts.restore();
20 ContactManager.execute.restore();
21 });
22
23 // edited for brevity
24 });
25
26 describe("showContact", function(){
27 beforeEach(function(){
28 sinon.stub(ContactManager.ContactsApp.Show.Controller, "showContact");
29 });
30
31 afterEach(function(){
32 ContactManager.ContactsApp.Show.Controller.showContact.restore();
33 });
34
35 it("executes ContactsApp.Show.Controller.showContact and forwards
36 the id", function(){
37 ContactManager.ContactsApp._API.showContact(3);
38 expect(ContactManager.ContactsApp.Show.Controller.showContact).to.have
39 .been.calledWith(3).once;
The Main Contacts App 111
40 });
41
42 it("sets the 'contacts' header as active", function(){
43 ContactManager.ContactsApp._API.showContact(3);
44 expect(ContactManager.execute).to.have
45 .been.calledWith("set:active:header", "contacts").once;
46 });
47 });
48 });
If we look at these tests critically for a bit, we can see they’re rife with duplication. Each test group
boils down to:
When we encounter duplicated code in programming, we refactor to combine common use cases
and reduce duplication. We can do the same with testing code:
test/assets/js/spec/apps/contacts/contacts_app.spec.js
1 describe.only("ContactsApp", function(){
2 it("instantiates a router when started", sinon.test(function(){
3 // edited for brevity
4 }));
5
6 describe("API", function(){
7 beforeEach(function(){
8 sinon.stub(ContactManager, "execute");
9 this.API = ContactManager.ContactsApp._API;
10 });
11
12 afterEach(function(){
13 ContactManager.execute.restore();
14 delete this.API;
15 });
16
17 var testController = function(controllerName, action, argument){
18 return (function(){
The Main Contacts App 112
19 describe(action, function(){
20 var controller = eval(controllerName);
21 before(function(){
22 sinon.stub(controller, action);
23 });
24
25 after(function(){
26 controller[action].restore();
27 });
28
29 it("executes " + controllerName + "." + action +
30 " and forwards the argument", sinon.test(function(){
31 this.API[action](argument);
32 expect(controller[action]).to.have.been.calledWith(argument).once;
33 }));
34
35 it("sets the 'contacts' header as active", function(){
36 this.API[action](argument);
37 expect(ContactManager.execute).to.have
38 .been.calledWith("set:active:header", "contacts").once;
39 });
40 });
41 }());
42 };
43
44 testController("ContactManager.ContactsApp.List.Controller",
45 "listContacts", "test");
46 testController("ContactManager.ContactsApp.Show.Controller",
47 "showContact", 3);
48 testController("ContactManager.ContactsApp.Edit.Controller",
49 "editContact", 3);
50 });
51 });
So, what’s going on in our testController helper function? Well, it takes a controllerName, action,
and argument and produces a describe block with the appropriate tests. Let’s ignore lines 18 and 41
for now, we’ll get back to them later.
On line 20, we use eval to get a reference to the javascript object related to the provided string
value. We then stub that controller’s action on line 22 before restoring in on line 26. You’ll recall
that foo["bar"] in javascript is equivalent to foo.bar: line 26 is simply calling restore on the
controller’s action, much like it is done on line 13. We then go on and produce tests by adapting our
code, mainly by accessing the tested controller’s action with controller[action].
The Main Contacts App 113
But how does all of this get wired up correctly? When we call testController, it creates a new
anonymous function instance (lines 18-41) and immediately returns the result of its execution
(known as an immediatley-invoked function expression⁶⁰), which is why we the function definition
looks like (function(){}()). This function gets executed in the test context and in turn executes
the describe and test function calls it defines.
Using functions such as this to test code is a double-edged sword: if they’re incorrect, a lot
of your code could end up untested (because your helper function doesn’t work correctly).
Be careful if you decide to use such helper functions: make sure they actually improve your
tests (e.g. reduce duplication without making readability worse), and that they actually test
the code properly (e.g. by adding an error in your app code and verifying the tests fail).
Testing Routes
Let’s get ready to test our routes:
test/assets/js/spec/apps/contacts/contacts_app.spec.js
1 describe("routing", function(){
2 describe("API", function(){
3 // edited for brevity
4 });
5
6 describe("routes", function(){
7 beforeEach(function(){
8 this.sandbox = sinon.sandbox.create();
9 this.sandbox.stub(ContactManager.ContactsApp._API, "listContacts");
10 this.sandbox.stub(ContactManager.ContactsApp._API, "showContact");
11 this.sandbox.stub(ContactManager.ContactsApp._API, "editContact");
12 ContactManager.ContactsApp.start();
13 Backbone.history.start();
14 });
15
16 afterEach(function(){
17 this.sandbox.restore();
18 Backbone.history.navigate("");
19 Backbone.history.stop();
20 ContactManager.ContactsApp.stop();
21 });
22 });
23 });
⁶⁰http://en.wikipedia.org/wiki/Immediately-invoked_function_expression
The Main Contacts App 114
We’ve introduced the use of Sinon’s sandbox⁶¹: it’s a useful tool to be able to easily restore a group of
stubs, spies, etc. much like the use we made of sinon.test(...) to sandbox a single test. In our case,
we’re able to restore all of our stubbed API methods with a single restore call on the sandbox (line
17). Now that we’ve met immediatley-invoked function expressions (IIFE), let’s use one to lighten
up our code and remove those long variable references:
test/assets/js/spec/apps/contacts/contacts_app.spec.js
1 beforeEach(function(){
2 this.sandbox = sinon.sandbox.create();
3 (function(sb, API){
4 sb.stub(API, "listContacts");
5 sb.stub(API, "showContact");
6 sb.stub(API, "editContact");
7 }(this.sandbox, ContactManager.ContactsApp._API));
8 ContactManager.ContactsApp.start();
9 Backbone.history.start();
10 });
This time, our use of an IIFE is much simpler: we’re simply leveraging it to “replace” our long
variables with shorter names. We’re also going to refer to the API quite often in our tests, so let’s
create a handy reference to that value also. It is to be noted that this new variable significantly
reduces the usefulness of our IIFE in this case, but we’ll keep it as a reminder of how it can be used
to easily temporarily rename tedious variable names:
test/assets/js/spec/apps/contacts/contacts_app.spec.js
1 beforeEach(function(){
2 this.sandbox = sinon.sandbox.create();
3 this.API = ContactManager.ContactsApp._API;
4 (function(sb, API){
5 sb.stub(API, "listContacts");
6 sb.stub(API, "showContact");
7 sb.stub(API, "editContact");
8 }(this.sandbox, this.API));
9 ContactManager.ContactsApp.start();
10 Backbone.history.start();
11 });
⁶¹http://sinonjs.org/docs/#sandbox
The Main Contacts App 115
test/assets/js/spec/apps/contacts/contacts_app.spec.js
1 describe("routes", function(){
2 beforeEach(function(){
3 // edited for brevity
4 });
5
6 afterEach(function(){
7 // edited for brevity
8 });
9
10 it("executes the listContacts API method for the 'contacts' fragment",
11 function(){
12 ContactManager.navigate("contacts", { trigger: true });
13 expect(this.API.listContacts).to.have.been.called.once;
14 });
15
16 it("executes the listContacts API method and forwards the criterion value for
17 the 'contacts/filter/criterion::criterion' fragment", function(){
18 ContactManager.navigate("contacts/filter/criterion:test", { trigger: true });
19 expect(this.API.listContacts).to.have.been.calledWith("test").once;
20 });
21
22 it("executes the showContact API method and forwards the id value for the
23 'contacts/:id' fragment", function(){
24 ContactManager.navigate("contacts/3", { trigger: true });
25 expect(this.API.showContact).to.have.been.calledWith("3").once;
26 });
27
28 it("executes the editContact API method and forwards the id value for the
29 'contacts/:id/edit' fragment", function(){
30 ContactManager.navigate("contacts/3/edit", { trigger: true });
31 expect(this.API.editContact).to.have.been.calledWith("3").once;
32 });
33 });
So far, we’ve checked that our API methods act as expected, and that hash changes on the URL are
wired up correctly to those API methods. Within our application, we manage navigation with events
and we need to test those, too:
The Main Contacts App 116
test/assets/js/spec/apps/contacts/contacts_app.spec.js
1 describe.only("ContactsApp", function(){
2 beforeEach(function(){
3 this.API = ContactManager.ContactsApp._API;
4 });
5
6 afterEach(function(){
7 delete this.API;
8 });
9
10 it("instantiates a router when started", sinon.test(function(){
11 // edited for brevity
12 }));
13
14 describe("routing", function(){
15 // edited for brevity
16 });
17
18 describe("triggers", function(){
19 beforeEach(function(){
20 this.sandbox = sinon.sandbox.create();
21 (function(sb, API){
22 sb.stub(ContactManager, "navigate");
23 sb.stub(API, "listContacts");
24 sb.stub(API, "showContact");
25 sb.stub(API, "editContact");
26 }(this.sandbox, this.API));
27 });
28
29 afterEach(function(){
30 this.sandbox.restore();
31 });
32
33 describe("contacts:list", function(){
34 it("navigates to the 'contacts' fragment", function(){
35 ContactManager.trigger("contacts:list");
36 expect(ContactManager.navigate).to.have.been.calledWith("contacts").once;
37 });
38
39 it("executes the 'listContacts' API method", function(){
40 ContactManager.trigger("contacts:list");
41 expect(this.API.listContacts).to.have.been.called.once;
The Main Contacts App 117
42 });
43 });
44
45 describe("contacts:filter", function(){
46 it("navigates to the 'contacts/filter/criterion::criterion' fragment if
47 a criterion is provided", function(){
48 ContactManager.trigger("contacts:filter", "ab");
49 expect(ContactManager.navigate).to.have
50 .been.calledWith("contacts/filter/criterion:ab").once;
51 });
52
53 it("navigates to the 'contacts' fragment if no criterion is provided",
54 function(){
55 ContactManager.trigger("contacts:filter");
56 expect(ContactManager.navigate).to.have.been.calledWith("contacts").once;
57 });
58 });
59
60 describe("contact:show", function(){
61 it("navigates to the 'contacts/:id' fragment", function(){
62 ContactManager.trigger("contact:show", 3);
63 expect(ContactManager.navigate).to.have
64 .been.calledWith("contacts/3").once;
65 });
66
67 it("executes the 'showContact' API method", function(){
68 ContactManager.trigger("contact:show", 3);
69 expect(this.API.showContact).to.have.been.calledWith(3).once;
70 });
71 });
72
73 describe("contact:edit", function(){
74 it("navigates to the 'contacts/:id/edit' fragment", function(){
75 ContactManager.trigger("contact:edit", 3);
76 expect(ContactManager.navigate).to.have
77 .been.calledWith("contacts/3/edit").once;
78 });
79
80 it("executes the 'editContact' API method", function(){
81 ContactManager.trigger("contact:edit", 3);
82 expect(this.API.editContact).to.have.been.calledWith(3).once;
83 });
The Main Contacts App 118
84 });
85 });
86 });
We’ve moved the this.API assignment to the top-most describe block (lines 2-8).
There isn’t much to discuss here, except maybe that we’re reusing the sandbox attribute name: it’s
already been restored in a previous block and is therefore available for use again.
The Main Contacts App 119
A word of warning about this contexts: the object this refers to within beforeEach and
afterEach is different from the this object referred to in before and after functions.
Therefore, the following won’t work:
1 describe("...", function(){
2 beforeEach(function(){
3 this.foo = { bar: function(){ console.log("test"); };
4 });
5
6 describe("...", function(){
7 before(function(){
8 sinon.stub(this.foo, "bar");
9 });
10 });
11 });
This code will raise an exception telling you that this.foo on line 8 is undefined. You can
either use the same type of setup function:
describe("...", function(){
beforeEach(function(){
this.foo = { bar: function(){ console.log("test"); };
});
describe("...", function(){
beforeEach(function(){
sinon.stub(this.foo, "bar");
});
});
});
or use a normal javascript variable which will eventually fall out scope:
describe("...", function(){
var foo = { bar: function(){ console.log("test"); };
describe("...", function(){
beforeEach(function(){
sinon.stub(foo, "bar");
});
});
});
⁶³https://github.com/davidsulc/marionette-testing/commit/ed2a398926fa450ca2d88d0055defba0b1ec0983
The Contact App’s “List” Action
We’re nearly done testing our “contacts” sup-application, all that’s left is the “list” action. So let’s
get that done.
test/assets/js/spec/apps/contacts/list/list_controller.spec.js
1 describe("ContactsApp.List.Controller", function(){
2 before(function(){
3 ContactManager._configureRegions();
4 this.controller = ContactManager.ContactsApp.List.Controller;
5 });
6
7 after(function(){
8 delete this.controller;
9 });
10
11 describe("listContacts", function(){
12 it("shows a loading view before loading the contacts", sinon.test(function(){
13 this.stub(ContactManager.regions.main, "show");
14 var view = {};
15 this.stub(ContactManager.Common.Views, "Loading").returns(view);
16
17 this.controller.listContacts();
18 expect(ContactManager.regions.main.show).to.have
19 .been.calledWith(view).once;
20 }));
21 });
22 });
test/assets/js/spec/apps/contacts/list/list_controller.spec.js
Our main layout is supposed to display a list layout and a panel layout, so we need to check that
also. Unfortunately, our test for that gets pretty ugly:
test/assets/js/spec/apps/contacts/list/list_controller.spec.js
contact_manager/assets/js/apps/contacts/list/list_controller.js
1 contactsListLayout.on("show", function(){
2 contactsListLayout.panelRegion.show(contactsListPanel);
3 contactsListLayout.contactsRegion.show(contactsListView);
4 });
Our controller is defining the layout’s behavior for the “show” event. For starters, that means that
our testing code can’t simply stub out the main region’s show method, or the “show” event would
never get triggered: in turn, the controller code above would never get run, and we’d never see the
sub-views. Hence our test code on lines 2-4: when the main region’s show method gets called, we
trigger the view’s “show” event and do nothing else. But that also means we need to stub out the
layout’s region’s show methods (lines 7-8), so they don’t actually try to display anything.
All of this means our tests are getting kind of heavy to implement, because we’re stubbing things
that aren’t really related to the functionality we want to test. What we can do instead here, is to
refactor: we’ll move sub-view configuration to the layout view, and just check that our controller
instantiates a layout view with the proper sub-view instances. Whether they get displayed properly
will be tested within the layout view’s tests.
contact_manager/assets/js/apps/contacts/list/list_controller.js
1 listContacts: function(criterion){
2 // edited for brevity
3
4 var contactsListLayout = new List.Layout();
5 var contactsListPanel = new List.Panel();
6
7 $.when(fetchingContacts).done(function(contacts){
8 // edited for brevity
9
10 contactsListLayout.on("show", function(){
11 contactsListLayout.panelRegion.show(contactsListPanel);
12 contactsListLayout.contactsRegion.show(contactsListView);
13 });
14
15 // edited for brevity
16
17 var contactsListLayout = new List.Layout({
18 panelView: contactsListPanel,
19 contactsView: contactsListView
20 });
21
The Contact App’s “List” Action 124
22 ContactManager.regions.main.show(contactsListLayout);
23 });
24 }
contact_manager/assets/js/apps/contacts/list/list_view.js
1 List.Layout = Marionette.LayoutView.extend({
2 template: "#contact-list-layout",
3
4 initialize: function(options){
5 this.on("show", function(){
6 this.panelRegion.show(this.getOption("panelView"));
7 this.contactsRegion.show(this.getOption("contactsView"));
8 });
9 },
10
11 regions: {
12 // edited for brevity
All we’ve done so far is to add two options to our layout: a panelView and a contactsView. We
provide these options on creation, then use our initializer to display these views in the appropriate
regions when the layout is displayed. Before we do anything too drastic, let’s make the minimum
amount of changes to make sure we didn’t break anyting:
test/assets/js/spec/apps/contacts/list/list_controller.spec.js
17
18 this.controller.listContacts();
19 expect(layout.panelRegion.show).to.have.been.calledWith(panelView).once;
20 expect(layout.contactsRegion.show).to.have.been.calledWith(listView).once;
21 }));
test/assets/js/spec/apps/contacts/list/list_controller.spec.js
1 it("displays panel and list sub-views when it gets shown", sinon.test(function(){
2 it("gets instantiated with sub-views (panel and list)", sinon.test(function(){
3 this.stub(ContactManager.regions.main, "show", function(view){
4 view.trigger("show");
5 });
6 var panelView = new Marionette.Object(),
7 listView = new Marionette.Object();
8 this.stub(ContactManager.ContactsApp.List, "Panel").returns(panelView);
9 this.stub(ContactManager.ContactsApp.List, "Contacts").returns(listView);
10 var layout = new ContactManager.ContactsApp.List.Layout({
11 panelView: panelView,
12 contactsView:listView
13 });
14 this.stub(ContactManager.ContactsApp.List, "Layout").returns(layout);
15 this.stub(layout.panelRegion, "show");
16 this.stub(layout.contactsRegion, "show");
17 this.stub(ContactManager.ContactsApp.List, "Layout");
18
19 this.controller.listContacts();
20 var layoutOptions = {
21 panelView: panelView,
22 contactsView:listView
23 }
24 expect(layout.panelRegion.show).to.have.been.calledWith(panelView).once;
25 expect(layout.contactsRegion.show).to.have.been.calledWith(listView).once;
26 expect(ContactManager.ContactsApp.List.Layout).to.have
27 .been.calledWith(layoutOptions).once;
28 }));
As discussed, we’re now no longer directly testing whether the layout’s sub-views get displayed, but
merely if it gets instantiated with them: whether they’re displayed or not will be tested in the view
tests, as that’s where that functionality now resides.
The Contact App’s “List” Action 126
Now that we’ve got tests for the main layout, we still need to test the sub-views to ensure they
behave as intended. We can start with the panel view:
test/assets/js/spec/apps/contacts/list/list_controller.spec.js
The thing is, we’re needing to stub the filtered collection configuration on line 19 when our test just
wants to know if we filtered a given collection. In addition, the collection we care about really is the
one linked to the panel view. Let’s refactor our controller code:
contact_manager/assets/js/apps/contacts/list/list_controller.js
test/assets/js/spec/apps/contacts/list/list_controller.spec.js
1 describe("Panel", function(){
2 beforeEach(function(){
3 this.panel = new Marionette.Object();
4 this.panel.collection = { filter: sinon.stub() };
5 sinon.stub(ContactManager.ContactsApp.List, "Panel").returns(this.panel);
6 sinon.stub(ContactManager.ContactsApp.List, "Contacts")
7 .returns(new Marionette.Object());
8 });
9
10 afterEach(function(){
11 delete this.panel;
12 ContactManager.ContactsApp.List.Panel.restore();
13 ContactManager.ContactsApp.List.Contacts.restore();
14 });
The Contact App’s “List” Action 128
15
16 describe("if a criterion is provided", function(){
17 it("filters the collection", function(){
18 var collection = { filter: function(){} };
19 this.stub(collection, "filter");
20 this.stub(ContactManager.Entities, "FilteredCollection")
21 .returns(collection);
22
23 this.controller.listContacts("abc");
24 expect(this.panel.collection.filter).to.have.been.calledWith("abc").once;
25 });
26
27 it("triggers method 'set:filter:criterion' (with criterion) on show",
28 sinon.test(function(){
29 this.stub(this.panel, "triggerMethod");
30
31 this.controller.listContacts("abc");
32 this.panel.trigger("show");
33 expect(this.panel.triggerMethod).to.have
34 .been.calledWith("set:filter:criterion", "abc").once;
35 }));
36 });
37 });
test/assets/js/spec/apps/contacts/list/list_controller.spec.js
1 describe("Panel", function(){
2 // edited for brevity
3
4 describe("if a criterion is provided", function(){
5 // edited for brevity
6 });
7
8 describe("events", function(){
9 describe("contacts:filter", function(){
10 it("filters contacts", sinon.test(function(){
11 this.stub(ContactManager.Entities, "FilteredCollection")
12 .returns(this.panel.collection);
13 this.controller.listContacts();
14 this.panel.trigger("contacts:filter", "xyz");
15 expect(this.panel.collection.filter).to.have.been.calledWith("xyz").once;
The Contact App’s “List” Action 129
16 }));
17
18 it("triggers 'contacts:filter on the main app, forwarding the criterion",
19 sinon.test(function(){
20 this.stub(ContactManager, "trigger");
21 this.controller.listContacts();
22 this.panel.trigger("contacts:filter", "xyz");
23 expect(ContactManager.trigger).to.have
24 .been.calledWith("contacts:filter", "xyz").once;
25 }));
26 });
27 });
28 });
Once again, the code on line 11 shouldn’t have to be written: now that our panel view has access to
the contacts collection, we should filter it there.
contact_manager/assets/js/apps/contacts/list/list_controller.js
1 contactsListPanel.on("contacts:filter", function(filterCriterion){
2 filteredContacts.filter(filterCriterion);
3 this.collection.filter(filterCriterion);
4 ContactManager.trigger("contacts:filter", filterCriterion);
5 });
If we rerun our tests, we can see they’re still working: nothing broke so far. Our next move is to
update our tests:
test/assets/js/spec/apps/contacts/list/list_controller.spec.js
1 it("filters contacts", sinon.test(function(){
2 it("filters contacts", function(){
3 this.stub(ContactManager.Entities, "FilteredCollection")
4 .returns(this.panel.collection);
5 this.controller.listContacts();
6 this.panel.trigger("contacts:filter", "xyz");
7 expect(this.panel.collection.filter).to.have.been.calledWith("xyz").once;
8 }));
9 });
OK, with that out of the way, we can move on to testing the “contact:new” event. I’ll spare you the
tediousness of stepping through the app code and determining everything that needs to get stubbed
for our tests to work properly, but this is the end result:
The Contact App’s “List” Action 130
test/assets/js/spec/apps/contacts/list/list_controller.spec.js
1 describe("contact:new", function(){
2 beforeEach(function(){
3 this.newView = _.extend({}, Backbone.Events);
4 sinon.stub(ContactManager.ContactsApp.New, "Contact").returns(this.newView);
5 sinon.stub(ContactManager.regions.dialog, "show");
6 });
7
8 afterEach(function(){
9 ContactManager.ContactsApp.New.Contact.restore();
10 ContactManager.regions.dialog.show.restore();
11 delete this.newView;
12 });
13
14 it("displays a new contact view in the dialog region", function(){
15 this.controller.listContacts();
16 this.panel.trigger("contact:new");
17 expect(ContactManager.regions.dialog.show).to.have
18 .been.calledWith(this.newView).once;
19 });
20
21 describe("successful save of new contact", function(){
22 beforeEach(function(){
23 this.newChildViewStub = { flash: sinon.stub() };
24 this.list.children = { findByModel: sinon.stub() };
25 this.list.children.findByModel.returns(this.newChildViewStub);
26 this.newModel = { save: sinon.stub() };
27 this.newModel.save.returns(true);
28 sinon.stub(ContactManager.Entities, "Contact").returns(this.newModel);
29 });
30
31 afterEach(function(){
32 delete this.newChildViewStub;
33 delete this.list.children;
34 delete this.newModel;
35 ContactManager.Entities.Contact.restore();
36 });
37
38 it("adds the new contact to the contact collection", sinon.test(function(){
39 var collection = new Backbone.Collection();
40 this.stub(collection, "add");
41 this.stub(ContactManager, "request").withArgs("contact:entities")
The Contact App’s “List” Action 131
42 .returns(collection);
43
44 this.controller.listContacts();
45 this.panel.trigger("contact:new");
46 this.newView.trigger("form:submit", {});
47 expect(collection.add).to.have.been.calledWith(this.newModel).once;
48 }));
49
50 it("triggers 'dialog:close' on the new view", sinon.test(function(){
51 this.spy(this.newView, "trigger");
52
53 this.controller.listContacts();
54 this.panel.trigger("contact:new");
55 this.newView.trigger("form:submit", {});
56 expect(this.newView.trigger).to.have.been.calledWith("dialog:close").once;
57 }));
58
59 it("executes the new model's item view's 'flash' method if
60 it is currently displayed", function(){
61 this.controller.listContacts();
62 this.panel.trigger("contact:new");
63 this.newView.trigger("form:submit", {});
64 expect(this.newChildViewStub.flash).to.have
65 .been.calledWith("success").once;
66 });
67 });
68 });
Line 51 uses a spy because using a stub isn’t possible: we need the trigger to work as normal
on line 55.
contact_manager/assets/js/apps/contacts/list/list_controller.js
Our app code to configure our “new” view is tightly coupled to surrounding code. Let’s refactor our
code to get back some measure of sanity. Our first order of business is to move all our panel view
configuration code into a separate function. Let’s write that function now:
The Contact App’s “List” Action 133
contact_manager/assets/js/apps/contacts/list/list_controller.js
1 List.Controller = {
2 _configurePanel: function(contacts, criterion){
3 if(criterion){
4 this.collection.filter(criterion);
5 this.once("show", function(){
6 this.triggerMethod("set:filter:criterion", criterion);
7 });
8 }
9
10 this.on("contacts:filter", function(filterCriterion){
11 this.collection.filter(filterCriterion);
12 ContactManager.trigger("contacts:filter", filterCriterion);
13 });
14
15 this.on("contact:new", function(){
16 var newContact = new ContactManager.Entities.Contact();
17
18 var view = new ContactManager.ContactsApp.New.Contact({
19 model: newContact
20 });
21
22 view.on("form:submit", function(data){
23 if(contacts.length > 0){
24 var highestId = contacts.max(function(c){ return c.id; }).get("id");
25 data.id = highestId + 1;
26 }
27 else{
28 data.id = 1;
29 }
30 if(newContact.save(data)){
31 contacts.add(newContact);
32 view.trigger("dialog:close");
33 var newContactView = contactsListView.children.findByModel(newContact);
34 // check whether the new contact view is displayed (it could be
35 // invisible due to the current filter criterion)
36 if(newContactView){
37 newContactView.flash("success");
38 }
39 }
40 else{
41 view.triggerMethod("form:data:invalid", newContact.validationError);
The Contact App’s “List” Action 134
42 }
43 });
44
45 ContactManager.regions.dialog.show(view);
46 });
47 },
48
49 listContacts: function(criterion){
50 // edited for brevity
All we did here is cut/paste the code into the new function, and replace contactsListPanel
references to this (such as on line 10). Naturally, we now have to call this new function and make
sure that our contactsListPanel is the context for the function. We’ll achieve this using javascript’s
call⁶⁴ which uses the first argument as the context for the function on which it is called:
contact_manager/assets/js/apps/contacts/list/list_controller.js
If we run our tests, we can see there are only 3 failures, all related to the fact that contactsListView
is not defined in the code below:
contact_manager/assets/js/apps/contacts/list/list_controller.js
1 if(newContact.save(data)){
2 contacts.add(newContact);
3 view.trigger("dialog:close");
4 var newContactView = contactsListView.children.findByModel(newContact);
5 // check whether the new contact view is displayed (it could be
6 // invisible due to the current filter criterion)
7 if(newContactView){
8 newContactView.flash("success");
9 }
10 }
⁶⁴https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/call
The Contact App’s “List” Action 135
Our panel view shouldn’t need to have any knowledge about the list view, so let’s fix this code: our
panel view will simply indicate a new contact has been created:
contact_manager/assets/js/apps/contacts/list/list_controller.js
Naturally, we now need to process that event in our controller, where we happily have easy access
to the needed references:
contact_manager/assets/js/apps/contacts/list/list_controller.js
15 if(newContactView){
16 newContactView.flash("success");
17 }
18 });
Now, our tests indicate our refactor is complete: all tests are passing. Let’s continue refactoring:
contact_manager/assets/js/apps/contacts/list/list_controller.js
Unfortunately, given the offline status of our app, we’re more or less stuck with computing
the next available id and therefore need access to the contacts collection. In a normal
application, however, this normally wouldn’t be the case: the server would handle assigning
the model id.
Let’s extract a new view out of this: it will take care of handling new contacts created in a modal
view. Let’s prepare our code for that transition:
contact_manager/assets/js/apps/contacts/list/list_controller.js
• we compute the model’s id outside of the view’s code, because it’s not its responsibility.
Note that moving this code can lead to race condition problems, which we are completely
ignoring: the only reason this code exists in the first place is because of the offline nature of
our application simulating a “normal” server-side storage environment. In normal production
code, you won’t have to deal with this.
• we make our “panel” view listen to the “new” view’s “contact:created” event so it can proxy
it.
• finally, we make our “new” view’s “form:submit” event handling self-sufficient by referring
only to this.
A quick execution of our tests shows us 3 are failing again, because they can’t locate the “new”
view’s model. That’s because we’re stubbing it without a model, so let’s fix that (lines 3 and 5):
test/assets/js/spec/apps/contacts/list/list_controller.spec.js
1 describe("contact:new", function(){
2 beforeEach(function(){
3 this.newModel = { save: sinon.stub() };
4 this.newView = _.extend({}, Backbone.Events);
5 this.newView = _.extend({ model: this.newModel }, Backbone.Events);
6 sinon.stub(ContactManager.ContactsApp.New, "Contact").returns(this.newView);
7 sinon.stub(ContactManager.regions.dialog, "show");
8 });
9
10 afterEach(function(){
11 ContactManager.ContactsApp.New.Contact.restore();
12 ContactManager.regions.dialog.show.restore();
13 delete this.newView;
14 delete this.newModel;
15 });
16
17 it("displays a new contact view in the dialog region", function(){
18 this.controller.listContacts();
19 this.panel.trigger("contact:new");
20 expect(ContactManager.regions.dialog.show).to.have
21 .been.calledWith(this.newView).once;
22 });
23
24 describe("successful save of new contact", function(){
25 beforeEach(function(){
26 this.newChildViewStub = { flash: sinon.stub() };
27 this.list.children = { findByModel: sinon.stub() };
28 this.list.children.findByModel.returns(this.newChildViewStub);
The Contact App’s “List” Action 139
We are now ready to move our view code out into its own object:
contact_manager/assets/js/apps/contacts/list/list_view.js
1 List.Contacts = Marionette.CompositeView.extend({
2 // edited for brevity
3 });
4
5 List.NewModal = ContactManager.ContactsApp.New.Contact.extend({
6 initialize: function(options){
7 this.on("form:submit", function(data){
8 if(this.model.save(data)){
9 this.trigger("dialog:close");
10 this.trigger("contact:created", this.model);
11 }
12 else{
13 this.triggerMethod("form:data:invalid", this.model.validationError);
14 }
15 });
16 }
17 });
Pretty sweet, right? All we had to change was the reference to the object on which we’re attaching
the event handler (line 7). Now, we still have to update our controller code:
The Contact App’s “List” Action 140
contact_manager/assets/js/apps/contacts/list/list_controller.js
1 this.on("contact:new", function(){
2 var nextId = 1;
3 if(contacts.length > 0){
4 nextId = contacts.max(function(c){ return c.id; }).get("id") + 1;
5 }
6 var newContact = new ContactManager.Entities.Contact({ id: nextId });
7
8 var view = new List.NewModal({
9 model: newContact
10 });
11
12 this.listenTo(view, "contact:created", function(newContact){
13 this.trigger("contact:created", newContact);
14 });
15
16 ContactManager.regions.dialog.show(view);
17 });
There’s just one last detail we need to attend to: our new NewModal view extends from the
New.Contact view. Therefore, we need to make sure the file defining it is included in our html
files before we need it:
contact_manager/index.html
1 <script src="./assets/js/apps/contacts/contacts_app.js"></script>
2 <script src="./assets/js/apps/contacts/common/views.js"></script>
3 <script src="./assets/js/apps/contacts/new/new_view.js"></script>
4 <script src="./assets/js/apps/contacts/list/list_view.js"></script>
5 <!-- edited for brevity -->
6 <script src="./assets/js/apps/contacts/edit/edit_controller.js"></script>
7 <script src="./assets/js/apps/contacts/new/new_view.js"></script>
The Contact App’s “List” Action 141
test/test.html
1 <script src="../contact_manager/assets/js/apps/contacts/contacts_app.js">
2 </script>
3 <script src="../contact_manager/assets/js/apps/contacts/common/views.js">
4 </script>
5 <script src="../contact_manager/assets/js/apps/contacts/new/new_view.js">
6 </script>
7 <script src="../contact_manager/assets/js/apps/contacts/list/list_view.js">
8 </script>
9 <!-- edited for brevity -->
10 <script src="../contact_manager/assets/js/apps/contacts/edit/edit_controller.js">
11 </script>
12 <script src="../contact_manager/assets/js/apps/contacts/new/new_view.js">
13 </script>
If we check on our tests now, we’ve got a few failures we need to address.
test/assets/js/spec/apps/contacts/list/list_controller.spec.js
1 describe("contact:new", function(){
2 beforeEach(function(){
3 // edited for brevity
4 this.newView = _.extend({ model: this.newModel }, Backbone.Events);
5 sinon.stub(ContactManager.ContactsApp.New, "Contact").returns(this.newView);
6 sinon.stub(ContactManager.ContactsApp.List, "NewModal")
7 .returns(this.newView);
8 sinon.stub(ContactManager.regions.dialog, "show");
9 });
10
11 afterEach(function(){
12 ContactManager.ContactsApp.List.NewModal.restore();
13 ContactManager.regions.dialog.show.restore();
14 delete this.newView;
15 delete this.newModel;
16 });
17
18 // edited for brevity
19
20 describe("successful save of new contact", function(){
21 it("adds the new contact to the contact collection", sinon.test(function(){
22 var collection = new Backbone.Collection();
23 this.stub(collection, "add");
The Contact App’s “List” Action 142
24 this.stub(ContactManager, "request").withArgs("contact:entities")
25 .returns(collection);
26
27 this.controller.listContacts();
28 this.panel.trigger("contact:new");
29 this.newView.trigger("form:submit", {});
30 this.newView.trigger("contact:created", this.newModel);
31 expect(this.collection.add).to.have.been.calledWith(this.newModel).once;
32 }));
33
34 it("triggers 'dialog:close' on the new view", sinon.test(function(){
35 this.spy(this.newView, "trigger");
36
37 this.newView.trigger("form:submit", {});
38 expect(this.newView.trigger).to.have.been.calledWith("dialog:close").once;
39 }));
40
41 it("executes the new model's item view's 'flash' method if
42 it is currently displayed", function(){
43 this.controller.listContacts();
44 this.panel.trigger("contact:new");
45 this.newView.trigger("form:submit", {});
46 this.newView.trigger("contact:created", this.newModel);
47 expect(this.newChildViewStub.flash).to.have
48 .been.calledWith("success").once;
49 });
50 });
51 });
We’re no longer testing whether our view triggers the “dialog:close” event, because that is no longer
the controller’s responsibility: we’ve moved that code to our new NewModal view.
Why aren’t we testing the _configurePanel function instead of ignoring it? Because tests
shouldn’t have knowledge of internal code function. Otherwise, your tests become too
tightly coupled to your code and any code refactor will mean rewriting tests and wasting
time. Instead, have your tests rely on as little as possible, and they will remain closer to the
“set it and forget it” productivity aids they’re supposed to be.
We’ve done a lot of work and our code is already looking much better! But we’ve still got a ways to
go, so: back in the saddle, and let’s tackle the “contacts” view tests.
The Contact App’s “List” Action 143
test/assets/js/spec/apps/contacts/list/list_controller.spec.js
42
43 expect(this.model.destroy).to.have.been.called.once;
44 }));
45 });
46
47 describe("childview:contact:edit", function(){
48 beforeEach(function(){
49 this.editView = new Marionette.Object();
50 sinon.stub(ContactManager.ContactsApp.Edit, "Contact")
51 .returns(this.editView);
52 sinon.stub(ContactManager.regions.dialog, "show");
53 });
54
55 afterEach(function(){
56 ContactManager.ContactsApp.Edit.Contact.restore();
57 ContactManager.regions.dialog.show.restore();
58 });
59
60 it("displays an edit view (in the dialog region) for the provided model",
61 function(){
62 this.controller.listContacts();
63 var options = { model: this.model };
64 this.view.trigger("childview:contact:edit", null, options);
65 expect(ContactManager.ContactsApp.Edit.Contact).to.have.been
66 .calledWith(options).once;
67 expect(ContactManager.regions.dialog.show).to.have.been
68 .calledWith(this.editView).once;
69 });
70
71 describe("saving the updated contact", function(){
72 describe("success", function(){
73 beforeEach(function(){
74 this.childView = {
75 render: sinon.stub(),
76 flash: sinon.stub()
77 };
78 sinon.stub(this.model, "save").returns(true);
79
80 this.controller.listContacts();
81 this.view.trigger("childview:contact:edit", this.childView,
82 { model: this.model });
83 });
The Contact App’s “List” Action 145
84
85 afterEach(function(){
86 delete this.childView;
87 this.model.save.restore();
88 });
89
90 it("rerenders the childview", function(){
91 this.editView.trigger("form:submit", {});
92 expect(this.childView.render).to.have.been.called.once;
93 });
94
95 it("calls the childview's flash method", function(){
96 this.editView.trigger("form:submit", {});
97 expect(this.childView.flash).to.have.been.called.once;
98 });
99
100 it("triggers 'dialog:close' on the edit view", sinon.test(function(){
101 this.spy(this.editView, "trigger");
102 this.editView.trigger("form:submit", {});
103 expect(this.editView.trigger).to.have.been
104 .calledWith("dialog:close").once;
105 }));
106 });
107
108 describe("failure", function(){
109 beforeEach(function(){
110 var error = { error: "test error" };
111 this.error = error;
112 sinon.stub(this.model, "save", function(){
113 this.validationError = error;
114 return false;
115 });
116
117 this.controller.listContacts();
118 this.view.trigger("childview:contact:edit", this.childView,
119 { model: this.model });
120 });
121
122 afterEach(function(){
123 delete this.error;
124 this.model.save.restore();
125 });
The Contact App’s “List” Action 146
126
127 it("calls the 'form:data:invalid' trigger method on the edit view,
128 sending the errors", sinon.test(function(){
129 this.stub(this.editView, "triggerMethod");
130 this.editView.trigger("form:submit", {});
131 expect(this.editView.triggerMethod).to.have.been
132 .calledWith("form:data:invalid", this.error).once;
133 }));
134 });
135 });
136 });
137 });
138 });
139 });
Not much to remark on here, save maybe the fact that we’re using a Marionette.Object on line
49 instead of an empty object with events, because we’re going to need to use (e.g.) triggerMethod
within tests (e.g. on line 131). Instead of using an empty object and stubbing out everything we’ll
need, it’s quicker to use the simplest object already defining the functionality we’ll need.
This time, we’ve tested the case where the saving fails (lines 108-134). We didn’t before to
avoid overloading you with too much information, and also because after refactoring, that
responsibility gets moved to the view and the test would get removed from the controller
tests anyway.
Your turn to work: now that we have tests in place to make sure you don’t break stuff, refactor the
related code using the following git commit. Take a look at what we’ve done previously, as a lot of
the refactoring will be similar. In fact, your “new” and “edit” views will be so similar they should
likely have some refactoring goodness applied to them also…
⁶⁵https://github.com/davidsulc/marionette-testing/commit/b1d4ac407ae6360aa9701add1056320ce67d564a
The Contact App’s “List” Action 147
Much like we did last time, we’ll start by moving the configuration code out of the way:
contact_manager/assets/js/apps/contacts/list/list_controller.js
1 List.Controller = {
2 _configurePanel: function(contacts, criterion){
3 // edited for brevity
4 },
5
6 _configureList: function(){
7 this.on("childview:contact:show", function(childView, args){
8 ContactManager.trigger("contact:show", args.model.get("id"));
9 });
10
11 this.on("childview:contact:edit", function(childView, args){
12 var model = args.model;
13 var view = new ContactManager.ContactsApp.Edit.Contact({
14 model: model
15 });
16
17 view.on("form:submit", function(data){
18 if(model.save(data)){
19 childView.render();
20 view.trigger("dialog:close");
21 childView.flash("success");
22 }
23 else{
24 view.triggerMethod("form:data:invalid", model.validationError);
25 }
26 });
27
28 ContactManager.regions.dialog.show(view);
29 });
30
31 this.on("childview:contact:delete", function(childView, args){
32 args.model.destroy();
33 });
34 },
35
36 listContacts: function(criterion){
37 var loadingView = new ContactManager.Common.Views.Loading();
38 ContactManager.regions.main.show(loadingView);
39
The Contact App’s “List” Action 148
Again, the only change we made at this point is renaming contactsListView to this within the
body of _configureList. Our tests confirm everything is still working great, so let’s proceed to
prepare our edit view for extraction:
contact_manager/assets/js/apps/contacts/list/list_controller.js
15 view.trigger("dialog:close");
16 childView.flash("success");
17 this.trigger("contact:updated");
18 }
19 else{
20 view.triggerMethod("form:data:invalid", model.validationError);
21 this.triggerMethod("form:data:invalid", this.model.validationError);
22 }
23 });
24
25 ContactManager.regions.dialog.show(view);
26 });
These changes do in fact warrant some updates to our testing code. For starters, we’re now accessing
the view’s model, so we need to assign it (line 4):
test/assets/js/spec/apps/contacts/list/list_controller.spec.js
1 describe("childview:contact:edit", function(){
2 beforeEach(function(){
3 this.editView = new Marionette.Object();
4 this.editView.model = this.model;
5 // edited for brevity
And now we get to the more interesting change, due to line 8 in the application code above: since
render returns the view instance, we can go ahead and chain our call to flash. But that means we
need to improve our testing code: first, we need to make our render stub return another stub for the
flash function, and second, we’ll some means to reference this flash stub to ensure it’s been called.
Here it goes:
test/assets/js/spec/apps/contacts/list/list_controller.spec.js
11 sinon.stub(this.model, "save").returns(true);
12
13 // edited for brevity
test/assets/js/spec/apps/contacts/list/list_controller.spec.js
1 it("calls the childview's flash method", function(){
2 this.editView.trigger("form:submit", {});
3 expect(this.childView.flash).to.have.been.called.once;
4 expect(this.flashStub).to.have.been.called.once;
5 });
And with our tests restored to a healthy state, let’s create that new view, shall we?
contact_manager/assets/js/apps/contacts/list/list_view.js
1 List.NewModal = ContactManager.ContactsApp.New.Contact.extend({
2 // edited for brevity
3 });
4
5 List.EditModal = ContactManager.ContactsApp.Edit.Contact.extend({
6 initialize: function(options){
7 this.on("form:submit", function(data){
8 if(this.model.save(data)){
9 this.trigger("dialog:close");
10 this.trigger("contact:updated");
11 }
12 else{
13 this.triggerMethod("form:data:invalid", this.model.validationError);
14 }
15 });
16 }
17 });
Once again, we need to shuffle our source files around so that our edit sub-app is included before
the list sub-app:
The Contact App’s “List” Action 151
contact_manager/index.html
1 <script src="./assets/js/apps/contacts/new/new_view.js"></script>
2 <script src="./assets/js/apps/contacts/edit/edit_view.js"></script>
3 <script src="./assets/js/apps/contacts/edit/edit_controller.js"></script>
4 <script src="./assets/js/apps/contacts/list/list_view.js"></script>
5 <!-- edited for brevity -->
6 <script src="./assets/js/apps/contacts/edit/edit_view.js"></script>
7 <script src="./assets/js/apps/contacts/edit/edit_controller.js"></script>
test/test.html
1 <script src="../contact_manager/assets/js/apps/contacts/new/new_view.js">
2 </script>
3 <script src="../contact_manager/assets/js/apps/contacts/edit/edit_view.js">
4 </script>
5 <script src="../contact_manager/assets/js/apps/contacts/edit/edit_controller.js">
6 </script>
7 <script src="../contact_manager/assets/js/apps/contacts/list/list_view.js">
8 </script>
9 <!-- edited for brevity -->
10 <script src="../contact_manager/assets/js/apps/contacts/edit/edit_view.js">
11 </script>
12 <script src="../contact_manager/assets/js/apps/contacts/edit/edit_controller.js">
13 </script>
And now, our tests give us some errors we need to address: we’re testing for the wrong type of view.
test/assets/js/spec/apps/contacts/list/list_controller.spec.js
1 describe("childview:contact:edit", function(){
2 beforeEach(function(){
3 this.editView = new Marionette.Object();
4 this.editView.model = this.model;
5 sinon.stub(ContactManager.ContactsApp.Edit, "Contact")
6 .returns(this.editView);
7 sinon.stub(ContactManager.ContactsApp.List, "EditModal")
8 .returns(this.editView);
9 sinon.stub(ContactManager.regions.dialog, "show");
10 });
11
12 afterEach(function(){
The Contact App’s “List” Action 152
13 ContactManager.ContactsApp.Edit.Contact.restore();
14 ContactManager.ContactsApp.List.EditModal.restore();
15 ContactManager.regions.dialog.show.restore();
16 });
17
18 it("displays an edit view (in the dialog region) for the provided model",
19 function(){
20 this.controller.listContacts();
21 var options = { model: this.model };
22 this.view.trigger("childview:contact:edit", null, options);
23 expect(ContactManager.ContactsApp.Edit.Contact).to.have
24 .been.calledWith(options).once;
25 expect(ContactManager.ContactsApp.List.EditModal).to.have
26 .been.calledWith(options).once;
27 expect(ContactManager.regions.dialog.show).to.have
28 .been.calledWith(this.editView).once;
29 });
We’re also triggering the wrong event for our test: “form:submit” is used internally, whereas
“contact:updated” is the event used to inform outside code that a contact has been updated:
test/assets/js/spec/apps/contacts/list/list_controller.spec.js
Next, we need to remove 2 tests whose functionality has been removed from the controller and
placed within the view:
The Contact App’s “List” Action 153
test/assets/js/spec/apps/contacts/list/list_controller.spec.js
42 });
43 });
test/assets/js/spec/apps/contacts/list/list_controller.spec.js
contact_manager/assets/js/apps/contacts/list/list_view.js
1 List.NewModal = ContactManager.ContactsApp.New.Contact.extend({
2 initialize: function(options){
3 this.on("form:submit", function(data){
4 if(this.model.save(data)){
5 this.trigger("dialog:close");
6 this.trigger("contact:created", this.model);
7 }
8 else{
9 this.triggerMethod("form:data:invalid", this.model.validationError);
10 }
11 });
12 }
13 });
14
15 List.EditModal = ContactManager.ContactsApp.Edit.Contact.extend({
16 initialize: function(options){
17 this.on("form:submit", function(data){
18 if(this.model.save(data)){
19 this.trigger("dialog:close");
20 this.trigger("contact:updated");
21 }
22 else{
23 this.triggerMethod("form:data:invalid", this.model.validationError);
24 }
25 });
26 }
27 });
See any duplication? Let’s fix that with a small helper function:
contact_manager/assets/js/apps/contacts/list/list_view.js
10
11 List.NewModal = ContactManager.ContactsApp.New.Contact.extend({
12 initialize: function(options){
13 this.on("form:submit", function(data){
14 processFormSubmit.call(this, data, "contact:created");
15 });
16 }
17 });
18
19 List.EditModal = ContactManager.ContactsApp.Edit.Contact.extend({
20 initialize: function(options){
21 this.on("form:submit", function(data){
22 processFormSubmit.call(this, data, "contact:updated");
23 });
24 }
25 });
Fixing a Bug
If we go into our app and click on an edit button from the list view, this is what we can see:
The modal window’s title isn’t being set properly. Before getting all excited and digging into the
code, let’s create a test that reproduces the problem:
The Contact App’s “List” Action 157
test/assets/js/spec/apps/contacts/list/list_controller.spec.js
1 describe("Contacts", function(){
2 describe("events", function(){
3 beforeEach(function(){
4 this.model = new ContactManager.Entities.Contact({ id: 3 });
5 this.model = new ContactManager.Entities.Contact({
6 id: 3,
7 firstName: "Mary",
8 lastName: "Minnow"
9 });
10 this.view = new ContactManager.ContactsApp.List.Contacts({
11 collection: new ContactManager.Entities.ContactCollection()
12 });
13 sinon.stub(ContactManager.ContactsApp.List, "Contacts").returns(this.view);
14 });
15
16 // edited for brevity
17
18 describe("childview:contact:edit", function(){
19 beforeEach(function(){
20 this.editView = new Marionette.Object();
21 this.editView.model = this.model;
22 this.editView = new ContactManager.ContactsApp.List.EditModal({
23 model: this.model
24 });
25 sinon.stub(ContactManager.ContactsApp.List, "EditModal")
26 .returns(this.editView);
27 sinon.stub(ContactManager.regions.dialog, "show");
28 });
29
30 afterEach(function(){
31 ContactManager.ContactsApp.List.EditModal.restore();
32 ContactManager.regions.dialog.show.restore();
33 });
34
35 describe("edit modal", function(){
36 it("displays an edit view (in the dialog region) for
37 the provided model", function(){
38 it("is displayed in the dialog region", function(){
39 // edited for brevity
40 });
41
The Contact App’s “List” Action 158
Only now can we go ahead and fix it. Having this test in place before doing anything helps us in
several ways:
Let’s fix our problem. Here’s where the title gets set in edit views:
contact_manager/assets/js/apps/contacts/edit/edit_view.js
1 Edit.Contact = ContactManager.ContactsApp.Common.Views.Form.extend({
2 initialize: function(){
3 this.title = "Edit " + this.model.get("firstName") + " " +
4 this.model.get("lastName");
5 },
This means we need to call this initialize function when we’re initializing our modal view. Here
we go:
The Contact App’s “List” Action 159
contact_manager/assets/js/apps/contacts/list/list_view.js
1 List.EditModal = ContactManager.ContactsApp.Edit.Contact.extend({
2 initialize: function(options){
3 var proto = ContactManager.ContactsApp.Edit.Contact.prototype;
4 var parentInitializer = proto.initialize;
5 if(parentInitializer){
6 parentInitializer.call(this);
7 }
8
9 this.on("form:submit", function(data){
10 processFormSubmit.call(this, data, "contact:updated");
11 });
12 }
13 });
We’re testing for specific text to be present. There is a balance to strike: we need to test
that a title is present to ensure the interface is correct for our users, but on the other hand
we really don’t want to have to modify our tests every time we decide to change how text
is displayed in our app. In this case, it would have been possible, for example, to check
that the parent’s initializer function was properly called (with a spy, for example), without
worrying about the actual text output.
With this correction in place, all of our tests are working again!
The process we followed is the one advocated by the testing community, and will serve you well:
Since failing tests are usually red while passing tests are colored green, this loop is often called “red,
green, refactor”.
test/assets/js/spec/apps/contacts/list/list_view.spec.js
1 describe("ContactsApp.List", function(){
2 describe("Layout", function(){
3 it("has a panel region");
4 it("has a contacts region");
5
6 describe("'show' event", function(){
7 it("displays the 'panelView' in the 'panelRegion'");
8 it("displays the 'contactsView' in the 'contactsRegion'");
9 });
10 });
11
12 describe("Panel", function(){
13 it("triggers 'contact:new' when the 'new' button is clicked");
14 it("triggers 'contacts:filter' with the criterion when #filter-form
15 is submitted");
16 it("updates the criterion in the filter form when the 'set:filter:criterion'
17 method is triggered");
18 });
19 });
test/assets/js/spec/apps/contacts/list/list_view.spec.js
1 describe("ContactsApp.List", function(){
2 before(function(){
3 this.$fixture = $("<div>", { id: "fixture" });
4 this.$container = $("#view-test-container");
5 });
6
7 after(function(){
8 delete this.$fixture;
9 this.$container.empty();
10 delete this.$container;
11 });
12
13 beforeEach(function(){
14 this.$fixture.empty().appendTo(this.$container);
15 });
16
17 describe("Layout", function(){
18 beforeEach(function(){
19 this.view = new ContactManager.ContactsApp.List.Layout();
20 });
21
22 afterEach(function(){
23 delete this.view;
24 });
25
26 it("has a panel region", function(){
27 expect(this.view.panelRegion).to.be.ok;
28 });
29
30 it("has a contacts region", function(){
31 expect(this.view.contactsRegion).to.be.ok;
32 });
33
34 describe("'show' event", function(){
35 beforeEach(function(){
36 sinon.stub(this.view.panelRegion, "show");
37 sinon.stub(this.view.contactsRegion, "show");
38 });
39
The Contact App’s “List” Action 162
40 afterEach(function(){
41 this.view.panelRegion.show.restore();
42 this.view.contactsRegion.show.restore();
43 });
44
45 it("displays the 'panelView' in the 'panelRegion'", function(){
46 var panel = {};
47 this.view.panelView = panel;
48
49 this.view.trigger("show");
50 expect(this.view.panelRegion.show).to.have.been.calledWith(panel).once;
51 });
52
53 it("displays the 'contactsView' in the 'contactsRegion'", function(){
54 var contactsView = {};
55 this.view.contactsView = contactsView;
56
57 this.view.trigger("show");
58 expect(this.view.contactsRegion.show).to.have
59 .been.calledWith(contactsView).once;
60 });
61 });
62 });
63 });
On lines 47 and 55, we can simply attach the view properties we want to use (even though they
weren’t provided to the constructor call on line 19) and they will work as expected. This is due to
our application code:
contact_manater/assets/js/apps/contacts/list/list_view.js
1 List.Layout = Marionette.LayoutView.extend({
2 template: "#contact-list-layout",
3
4 initialize: function(options){
5 this.on("show", function(){
6 this.panelRegion.show(this.getOption("panelView"));
7 this.contactsRegion.show(this.getOption("contactsView"));
8 });
9 },
10
11 // edited for brevity
The Contact App’s “List” Action 163
On lines 6-7, we’re using getOption to retrieve the values we want. This will first look for the
attributes within the options provided to the constructor, and if absent, will use the attributes
attached to the object. This works great for us: when we provide the views to use as options to
the constructor (as in our normal application code use), those views get used; whereas within our
tests where we provide no constructor options but instead attach the desired data later as attributes,
the correct views get used for the same pupose.
But let’s continue with our tests:
test/assets/js/spec/apps/contacts/list/list_view.spec.js
1 describe("Layout", function(){
2 // edited for brevity
3 });
4
5 describe("Panel", function(){
6 beforeEach(function(){
7 this.view = new ContactManager.ContactsApp.List.Panel({
8 el: this.$fixture
9 });
10 sinon.spy(this.view, "trigger");
11 });
12
13 afterEach(function(){
14 delete this.view;
15 });
16
17 it("triggers 'contact:new' when the 'new' button is clicked", function(){
18 this.view.once("render", function(){
19 this.$el.find(".js-new").click();
20 expect(this.trigger).to.have.been.calledWith("contact:new").once;
21 });
22 this.view.render();
23 });
24
25 it("triggers 'contacts:filter' with the criterion when #filter-form
26 is submitted", function(){
27 this.view.once("render", function(){
28 this.$el.find(this.ui.criterion).val("abc");
29 this.$el.find("#filter-form button[type=submit]").click();
30 expect(this.trigger).to.have.been
31 .calledWith("contacts:filter", "abc").once;
32 });
33 this.view.render();
The Contact App’s “List” Action 164
34 });
35
36 it("updates the criterion in the filter form when the 'set:filter:criterion'
37 method is triggered", function(){
38 this.view.once("render", function(){
39 expect(this.$el.find(this.ui.criterion).val()).to.equal('');
40 this.triggerMethod("set:filter:criterion", "xyz")
41 expect(this.$el.find(this.ui.criterion).val()).to.equal("xyz");
42 });
43 this.view.render();
44 });
45 });
On line 10, we don’t need to restore our spy on the view’s trigger method. Why is that? Because
after each test, we delete the view (line 14) and create a new one: the spy automatically becomes
irrelevant after each test. In fact, since we’re going to destroy the view after each test, we can go
ahead and alter the view instance for our tests.
What we’d like to use, in order to enhance test readability and reduce duplication, is something like
this:
test/assets/js/spec/apps/contacts/list/list_view.spec.js
In other words, we’d like to be able to set and retrieve the criterion value using view.criterion.value
(e.g. line 4). This might take you a few tries, as the concepts necessary to implement this are more
advanced. Nevertheless, give it a try using these hints:
• you can attach the criterion object directly to this.view: no need to mess around with the
constructor;
• criterion will need to be an object, because it needs to contain the value method;
• the value function will need to forward the received arguments to jQuery’s val: look into
arguments⁶⁷ and apply⁶⁸ for this purpose;
• you’ll need access to the view from within value: an Immediately-Invoked Function Expres-
sion (IIFE) can be of use to achieve this goal (try to use one as practice).
⁶⁷https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/arguments
⁶⁸https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/apply
The Contact App’s “List” Action 166
test/assets/js/spec/apps/contacts/list/list_view.spec.js
1 describe("Panel", function(){
2 beforeEach(function(){
3 this.view = new ContactManager.ContactsApp.List.Panel({
4 el: this.$fixture
5 });
6 sinon.spy(this.view, "trigger");
7 this.view.criterion = {};
8 });
9
10 afterEach(function(){
11 delete this.view;
12 });
13
14 it.only("tests this.view.criterion.value", function(){
15 expect(this.view.criterion).to.be.ok;
16 });
17
18 // edited for brevity
Our first order of business is to get our value function to return something:
test/assets/js/spec/apps/contacts/list/list_view.spec.js
1 describe("Panel", function(){
2 beforeEach(function(){
3 // edited for brevity
4 this.view.criterion = {
5 value: function(){
6 return "test";
7 }
8 };
9 });
10
11 // edited for brevity
12
13 it.only("tests this.view.criterion.value", function(){
14 expect(this.view.criterion.value()).to.equal("test");
15 });
16
17 // edited for brevity
The Contact App’s “List” Action 167
test/assets/js/spec/apps/contacts/list/list_view.spec.js
1 describe("Panel", function(){
2 beforeEach(function(){
3 // edited for brevity
4 this.view.criterion = {
5 value: function(){
6 return this.render;
7 }
8 };
9 });
10
11 // edited for brevity
12
13 it.only("tests this.view.criterion.value", function(){
14 expect(this.view.criterion.value()).to.be.ok;
15 });
16
17 // edited for brevity
This doesn’t work: this on line 6 refers to the criterion object starting on line 4 and ending on
line 8. As you can plainly see, there’s no render property there, and our test fails with “expected
undefined to be truthy”.
One way around this would be to use a closure to gain access to the view:
test/assets/js/spec/apps/contacts/list/list_view.spec.js
1 describe("Panel", function(){
2 beforeEach(function(){
3 // edited for brevity
4 var myView = this.view;
5 this.view.criterion = {
6 value: function(){
7 return myView.render;
8 }
9 };
10 });
11
12 // edited for brevity
13
14 it.only("tests this.view.criterion.value", function(){
The Contact App’s “List” Action 168
15 expect(this.view.criterion.value()).to.be.ok;
16 });
17
18 // edited for brevity
But we’d like to practice using an IIFE and avoid cluttering the scope with an extra variable:
test/assets/js/spec/apps/contacts/list/list_view.spec.js
1 describe("Panel", function(){
2 beforeEach(function(){
3 // edited for brevity
4 this.view.criterion = (function(view){
5 return {
6 value: function(){
7 return view.render;
8 }
9 }
10 })(this.view);
11 });
12
13 // edited for brevity
14
15 it.only("tests this.view.criterion.value", function(){
16 expect(this.view.criterion.value()).to.be.ok;
17 });
18
19 // edited for brevity
We’ve got our IIFE and pass in our view on line 10, and reference it as the view argument named on
line 4.
Now, let’s make sure our function will let us access the criterion:
The Contact App’s “List” Action 169
test/assets/js/spec/apps/contacts/list/list_view.spec.js
1 describe("Panel", function(){
2 beforeEach(function(){
3 // edited for brevity
4 this.view.criterion = {
5 value: function(){
6 return view.ui.criterion.val();
7 }
8 };
9 });
10
11 // edited for brevity
12
13 it.only("tests this.view.criterion.value", function(){
14 this.view.render();
15 this.view.ui.criterion.val("test");
16 expect(this.view.criterion.value()).to.equal("test");
17 });
18
19 // edited for brevity
Since we’re now trying to modify and access data within the displayed view, don’t forget
to render it first (line 14).
test/assets/js/spec/apps/contacts/list/list_view.spec.js
1 describe("Panel", function(){
2 beforeEach(function(){
3 // edited for brevity
4 this.view.criterion = {
5 value: function(newValue){
6 if(newValue){
7 return view.ui.criterion.val(newValue);
8 }
9 else{
10 return view.ui.criterion.val();
11 }
12 }
13 };
14 });
The Contact App’s “List” Action 170
15
16 // edited for brevity
17
18 it.only("tests this.view.criterion.value", function(){
19 this.view.render();
20 this.view.ui.criterion.val("test");
21 this.view.criterion.value("test");
22 expect(this.view.criterion.value()).to.equal("test");
23 });
24
25 // edited for brevity
Checking for an argument (line 6) works well enough for our purposes, but it doesn’t seem like
the most elegant solution. Aside from the nearly identical duplicated code on lines 7 and 10, if
jQuery’s val implementation changes, we’ll need to rewrite this. Wouldn’t it be better to just send
any arguments our function received directly to val without messing around with them?
Javascript has an arguments⁶⁹ object referencing all arguments provided to a function. Let’s start by
refactoring our method to use it:
test/assets/js/spec/apps/contacts/list/list_view.spec.js
1 describe("Panel", function(){
2 beforeEach(function(){
3 // edited for brevity
4 this.view.criterion = {
5 value: function(newValue){
6 value: function(){
7 if(newValue){
8 if(arguments.length > 0){
9 return view.ui.criterion.val(newValue);
10 return view.ui.criterion.val(arguments[0]);
11 }
12 else{
13 return view.ui.criterion.val();
14 }
15 }
16 };
17 });
18
19 // edited for brevity
⁶⁹https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/arguments
The Contact App’s “List” Action 171
We no longer declare any arguments on line 6, because we can access them any way with arguments
(e.g. on line 8). What would be great is to send off arguments directly into val. Sadly, it’s not possible
to convert an array to a list of values yet (but will likely be with ECMAScript 6’s spread operator⁷⁰)
so we need to use apply⁷¹:
test/assets/js/spec/apps/contacts/list/list_view.spec.js
1 describe("Panel", function(){
2 beforeEach(function(){
3 // edited for brevity
4 this.view.criterion = (function(view){
5 return {
6 value: function(){
7 return view.ui.criterion.val.apply(view.ui.criterion, arguments);
8 }
9 };
10 })(this.view);
11 });
12
13 // edited for brevity
test/assets/js/spec/apps/contacts/list/list_view.spec.js
⁷⁰https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_operator
⁷¹https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/apply
The Contact App’s “List” Action 172
17 });
18 this.view.render();
19 });
20
21 it("updates the criterion in the filter form when the 'set:filter:criterion'
22 method is triggered", function(){
23 this.view.once("render", function(){
24 expect(this.criterion.value()).to.equal('');
25 this.triggerMethod("set:filter:criterion", "xyz")
26 expect(this.criterion.value()).to.equal("xyz");
27 });
28 this.view.render();
29 });
test/assets/js/spec/apps/contacts/list/list_view.spec.js
Given the similarities between the last 3 tests, try and write a helper function to implement them.
The Contact App’s “List” Action 173
test/assets/js/spec/apps/contacts/list/list_view.spec.js
For this view, we’ve elected not to test the flash and remove functions as they are purely
visual components (their failure wouldn’t impact application functionality). In addition, the remove
functionality is called by the framework, and therefore is out of scope for our tests.
Let’s move on to our composite view with a simple warmup test:
test/assets/js/spec/apps/contacts/list/list_view.spec.js
1 describe("Contacts", function(){
2 it("displays a message indicating there are no contacts to display when
3 collection is empty", function(){
4 var view = new ContactManager.ContactsApp.List.Contacts({
5 el: this.$fixture,
6 collection: new ContactManager.Entities.ContactCollection()
7 });
8
9 view.once("render", function(){
10 expect(view.$el.text()).to.contain("No contacts to display.");
11 });
12 view.render();
13 });
14 });
With that out of the way, time for a more serious test: our composite view is supposed to render our
collection items in order, and then prepend any new items:
test/assets/js/spec/apps/contacts/list/list_view.spec.js
1 describe("Contacts", function(){
2 beforeEach(function(){
3 this.collection = new ContactManager.Entities.ContactCollection([
4 { id: 1, firstName: "AAA", lastName: "AAA" },
5 { id: 2, firstName: "CCC", lastName: "CCC" },
6 { id: 3, firstName: "BBB", lastName: "BBB" }
7 ]);
8
9 this.view = new ContactManager.ContactsApp.List.Contacts({
10 el: this.$fixture,
11 collection: this.collection
12 });
13 });
The Contact App’s “List” Action 175
14
15 afterEach(function(){
16 delete this.collection;
17 delete this.view;
18 });
19
20 it("displays a message indicating there are no contacts to display when
21 collection is empty", function(){
22 // edited for brevity
23 });
24
25 describe("item view rendering order", function(){
26 it("renders items in order on initial render", function(){
27 this.view.once("render", function(){
28 var $a = $("tr:contains('AAA')").first();
29 var $b = $("tr:contains('BBB')").first();
30 var $c = $("tr:contains('CCC')").first();
31
32 var dataRows = $("tr:has(td)");
33 expect(dataRows.index($a)).to.equal(0);
34 expect(dataRows.index($b)).to.equal(1);
35 expect(dataRows.index($c)).to.equal(2);
36 });
37 this.view.render();
38 });
39
40 it("prepends new item views after initial render", function(){
41 this.view.once("render", function(){
42 var newModel = new ContactManager.Entities.Contact({
43 id: 4, firstName: "DDD", lastName: "DDD"
44 });
45 this.collection.add(newModel);
46 var $a = $("tr:contains('AAA')").first();
47 var $d = $("tr:contains('DDD')").first();
48
49 var dataRows = $("tr:has(td)");
50 expect(dataRows.index($d)).to.equal(0);
51 expect(dataRows.index($a)).to.equal(1);
52 });
53 this.view.render();
54 });
55 });
The Contact App’s “List” Action 176
56 });
Excellent, now it’s time to test our new modal views, so implement the following tests:
test/assets/js/spec/apps/contacts/list/list_view.spec.js
1 describe("NewModal", function(){
2 describe("successful save", function(){
3 it("triggers 'dialog:close' on the view");
4 it("triggers 'contact:created' on the view with the newly created model");
5 });
6
7 describe("save failure", function(){
8 it("triggers method 'form:data:invalid' on the view with
9 the validation error");
10 });
11 });
12
13 describe("EditModal", function(){
14 describe("successful save", function(){
15 it("triggers 'dialog:close' on the view");
16 it("triggers 'contact:updated' on the view with the updated model");
17 });
18
19 describe("save failure", function(){
20 it("triggers method 'form:data:invalid' on the view with
21 the validation error");
22 });
23 });
Although these test groups are very similar, we won’t be implement a higher-level function
to create them. This is because, in my experience, views tend to drift appart in their
use/implementation, and that means you’ll later have to face the task of untangling the
higher-level function to separate out the different tests. In addition, a higher-level function
would probably significantly reduce readability in this case.
The Contact App’s “List” Action 177
test/assets/js/spec/apps/contacts/list/list_view.spec.js
1 describe("NewModal", function(){
2 beforeEach(function(){
3 this.model = new ContactManager.Entities.Contact();
4 this.newView = new ContactManager.ContactsApp.List.NewModal({
5 model: this.model
6 });
7 });
8
9 afterEach(function(){
10 delete this.newView;
11 delete this.model;
12 });
13
14 describe("successful save", function(){
15 beforeEach(function(){
16 sinon.stub(this.model, "save").returns(true);
17 sinon.spy(this.newView, "trigger");
18 });
19
20 afterEach(function(){
21 this.model.save.restore();
22 this.newView.trigger.restore();
23 });
24
25 it("triggers 'dialog:close' on the view", sinon.test(function(){
26 this.newView.trigger("form:submit", {});
27 expect(this.newView.trigger).to.have.been.calledWith("dialog:close").once;
28 }));
29
30 it("triggers 'contact:created' on the view with the newly created model",
31 sinon.test(function(){
32 this.newView.trigger("form:submit", {});
33 expect(this.newView.trigger).to.have.been
34 .calledWith("contact:created", this.model).once;
35 }));
36 });
37
38 describe("save failure", function(){
39 it("triggers method 'form:data:invalid' on the view with
The Contact App’s “List” Action 178
82 this.editView.trigger("form:submit", {});
83 expect(this.editView.trigger).to.have.been.calledWith("dialog:close").once;
84 }));
85
86 it("triggers 'contact:updated' on the view with the updated model",
87 sinon.test(function(){
88 this.editView.trigger("form:submit", {});
89 expect(this.editView.trigger).to.have.been
90 .calledWith("contact:updated", this.model).once;
91 }));
92 });
93
94 describe("save failure", function(){
95 it("triggers method 'form:data:invalid' on the view with
96 the validation error", sinon.test(function(){
97 var error = { error: "test error" };
98 this.stub(this.model, "save", function(){
99 this.validationError = error;
100 return false;
101 });
102 this.spy(this.editView, "triggerMethod");
103
104 this.editView.trigger("form:submit", {});
105 expect(this.editView.triggerMethod).to.have.been
106 .calledWith("form:data:invalid", error).once;
107
108 delete this.model.validationError;
109 }));
110 });
111 });
Git commit with the second portion of our list tests, and view tests:
f6fb0ed0280c80c45c47b8e44842fdd774ac3a05⁷²
⁷²https://github.com/davidsulc/marionette-testing/commit/f6fb0ed0280c80c45c47b8e44842fdd774ac3a05
Improving View Test Resilience
Ensuring Proper Execution
As mentioned previously, we have a test that isn’t quite working properly:
test/assets/js/spec/apps/contacts/show/show_view.spec.js
1 describe("Contact", function(){
2 it("triggers 'contact:edit' with the same model when the
3 edit button is clicked", sinon.test(function(){
4 var model = new ContactManager.Entities.Contact({
5 id: 1,
6 firstName: "John",
7 lastName: "Doe"
8 });
9 var view = new ContactManager.ContactsApp.Show.Contact({ model: model });
10 this.stub(view, "trigger");
11
12 view.once("render", function(){
13 this.$el.find(".js-edit").click();
14 expect(this.trigger).to.have.been.calledWith("contact:edit", model).once;
15 });
16 view.render();
17 }));
18 });
contact_manager/assets/js/apps/contacts/show/show_view.js
1 Show.Contact = Marionette.ItemView.extend({
2 template: "#contact-view",
3
4 // events: {
5 // "click a.js-edit": "editClicked"
6 // },
7
8 // editClicked: function(e){
Improving View Test Resilience 181
9 // e.preventDefault();
10 // this.trigger("contact:edit", this.model);
11 // }
12 });
Quite surprisingly, our test reports itself as having been executed successfully! How is that even
possible? Well, on lines 12-15 of the test code we add an event handler to be run after the view’s
“render” event, but in case the “render” event doesn’t happen our code won’t get tested. So our first
order of business is to ensure we are alerted if our test isn’t working as expected.
To achieve this, we can easily use the asynchronous test mechanism:
test/assets/js/spec/apps/contacts/show/show_view.spec.js
1 describe("Contact", function(){
2 it("triggers 'contact:edit' with the same model when the
3 edit button is clicked", sinon.test(function(){
4 it("triggers 'contact:edit' with the same model when the
5 edit button is clicked", sinon.test(function(done){
6 var model = new ContactManager.Entities.Contact({
7 id: 1,
8 firstName: "John",
9 lastName: "Doe"
10 });
11 var view = new ContactManager.ContactsApp.Show.Contact({ model: model });
12 this.stub(view, "trigger");
13
14 view.once("render", function(){
15 this.$el.find(".js-edit").click();
16 expect(this.trigger).to.have.been.calledWith("contact:edit", model).once;
17 done();
18 });
19 view.render();
20 }));
21 });
That is because we’re now treating our view test as an asynchronous test, and as long as the test
hasn’t been “released” by the function call on line 17, it’s considered to still be in progress. In other
words, our test is currently failing because it never enters the “render” event handler and Mocha
waits for a while before indicating the test has timed out.
So why is our test timing out? Because on line 12 we’re stubbing out the view’s “trigger” method.
Sadly for us, Marionette relies on the trigger to indicate the view has been rendered: since we’ve
stubbed it out, our test is never informed of the rendering status and never gets to execute the code
on lines 14-18.
Luckily, the fix is easy: we just need to replace the stub with a spy. This way, we can still check
whether the trigger method has been called and with which arguments, but it won’t interfere with
other functionality.
test/assets/js/spec/apps/contacts/show/show_view.spec.js
1 describe("Contact", function(){
2 it("triggers 'contact:edit' with the same model when the
3 edit button is clicked", sinon.test(function(done){
4 var model = new ContactManager.Entities.Contact({
5 id: 1,
6 firstName: "John",
7 lastName: "Doe"
8 });
9 var view = new ContactManager.ContactsApp.Show.Contact({ model: model });
10 this.stub(view, "trigger");
11 this.spy(view, "trigger");
12
13 view.once("render", function(){
14 this.$el.find(".js-edit").click();
15 expect(this.trigger).to.have.been.calledWith("contact:edit", model).once;
16 done();
17 });
18 view.render();
19 }));
20 });
Improving View Test Resilience 183
Go ahead and update all view tests relying on a “render” callback to use the asynchronous “done”
indicator. Hint: search for .once("render",
Don’t get confused: you need to add the done argument to the test function (lines 2 and 3
above), not to the callback definition (line 13 above).
test/assets/js/spec/apps/contacts/edit/edit_view.spec.js
For this test, we need to ensure we go through both event handlers. Here’s a simple solution to that
problem:
test/assets/js/spec/apps/contacts/edit/edit_view.spec.js
10 this.view.once("render", function(){
11 var firstName = this.model.get("firstName"),
12 lastName = this.model.get("lastName");
13 expect(this.$el.find("h1").first().text()).to.equal("Edit " + firstName +
14 " " + lastName);
15 if(firstRender){
16 done();
17 }
18 });
19 this.view.render();
20 });
We simply track (line 2) whether the first render has been performed, and will only call done in the
second render if the first render took place. This is fully functional, and works perfectly for this case.
Let’s assume, however, that our test depends on 2 asynchronous events and instead use deferreds to
solve this:
test/assets/js/spec/apps/contacts/edit/edit_view.spec.js
In this alternative, we declare 2 deferred objects (lines 2-3) and resolve one on each render call (lines
6 and 16). Then, on lines 19-21 we execute done() once we know that both render calls have been
made. Don’t confuse the 2 done calls in these lines:
The test termination code can be further simplified, since jQuery’s done expects a reference to a
callback function that it will the automatically execute:
test/assets/js/spec/apps/contacts/edit/edit_view.spec.js
1 this.view.render();
2 $.when(firstRender, secondRender).done(function(){
3 done();
4 });
5 $.when(firstRender, secondRender).done(done);
test/assets/js/spec/apps/contacts/new/new_view.spec.js
1 it("sets the submit button text to 'Create contact'", function(done){
2 this.view.once("render", function(){
3 expect(this.$el.find(".js-submit").text()).to.equal("Create contact");
4 done();
5 });
6 this.view.render();
7 });
On line 3, we’re checking the submit button’s text by directly selecting the DOM attribute to check.
But what happens if we need to change the CSS selector in our app to “.js-create-submit”? We’d
need to change our tests.
As much as possible, we want our tests to be of the “set it and forget it” nature once they’re written:
unless app code changes significantly, our tests should continue working without modification. So
how can we better address this CSS selector issue? By using a view’s ui property.
⁷³https://github.com/davidsulc/marionette-testing/commit/1a25891492fe72913b8eaf0ff4bb9dc6c33a75b2
Improving View Test Resilience 186
contact_manager/assets/js/apps/contacts/new/new_view.js
1 New.Contact = ContactManager.ContactsApp.Common.Views.Form.extend({
2 title: "New Contact",
3
4 ui: {
5 createButton: ".js-submit"
6 },
7
8 onRender: function(){
9 this.$(".js-submit").text("Create contact");
10 this.ui.createButton.text("Create contact");
11 }
12 });
test/assets/js/spec/apps/contacts/new/new_view.spec.js
This will centralize our CSS selector: if it’s changed in our app code (within the ui object), our tests
will continue working as expected without needing to be modified.
But our “new” view inherits from the “common form” view, so let’s update that next:
test/assets/js/spec/apps/contacts/common/view.spec.js
1 Views.Form = Marionette.ItemView.extend({
2 template: "#contact-form",
3
4 ui: {
5 submitButton: "button.js-submit"
6 },
7
8 events: {
Improving View Test Resilience 187
Within our events hash, the only way to access the submitButton UI property is by using the special
“@” reference, which you can consider identical to this. It isn’t possible to use this here, because
it would refer to the events hash and not the view instance.
Using ui elements in the events hash does not work for older versions of Marionette.
Notice that both the “new” and “common form” views both declare a ui hash. What happens? Since
the “new” view declares its own ui hash, it effectively overwrites the one created in the “common
form”. So as it stands, the “submitClicked” event on line 10 won’t fire because ui.submitButton no
longer exists within the “new” view (since its ui hash was overwritten).
In effect, the ui hash (and others) declared within views get replaced by views that extend them.
Then how can we make our “submitForm” fire properly? We have two options (both within the
“new” view):
ui: {
createButton: ".js-submit",
submitButton: ".js-submit"
},
events: {
"click @ui.createButton": "submitClicked"
},
Declaring multiple ui elements on the same DOM selectors is a recipe for unhappiness, so we’ll stick
with the second option. It also has the advantage of being clearer: the event handler is declared and
since it is absent from the current object, the developer knows he’ll need to look for it higher up in
the hierarchy. Here’s our updated “new” view:
Improving View Test Resilience 188
contact_manager/assets/js/apps/contacts/new/new_view.js
1 New.Contact = ContactManager.ContactsApp.Common.Views.Form.extend({
2 title: "New Contact",
3
4 ui: {
5 createButton: ".js-submit"
6 },
7
8 events: {
9 "click @ui.createButton": "submitClicked"
10 },
11
12 onRender: function(){
13 this.ui.createButton.text("Create contact");
14 }
15 });
And since our events hash is now superseding the one from the “common form” view, let’s add a
test verifying that the common form’s submitClicked method get executed when the new form’s
“create” button is clicked, which we test by checking that the view triggers the “form:submit” event
when the button is clicked:
test/assets/js/spec/apps/contacts/new/new_view.spec.js
1 it("inherits from ContactsApp.Common.Views.Form", function(){
2 expect(this.view instanceof ContactManager.ContactsApp.Common.Views.Form).to
3 .be.true;
4 });
5 describe("inheritance", function(){
6 it("inherits from ContactsApp.Common.Views.Form", function(){
7 expect(this.view instanceof ContactManager.ContactsApp.Common.Views.Form).to
8 .be.true;
9 });
10
11 it("triggers 'form:submit' when the form is submitted", sinon.test(function(){
12 this.stub(this.view, "trigger");
13 this.view.render();
14
15 this.view.ui.createButton.click();
16 expect(this.view.trigger).to.have.been.calledWith("form:submit").once;
17 }));
18 });
Improving View Test Resilience 189
Let’s now update the other view inheriting from the common view form: the “edit” view:
contact_manager/assets/js/apps/contacts/edit/edit_view.js
1 Edit.Contact = ContactManager.ContactsApp.Common.Views.Form.extend({
2 // edited for brevity
3
4 ui: {
5 updateButton: ".js-submit"
6 },
7
8 events: {
9 "click @ui.updateButton": "submitClicked"
10 },
11
12 onRender: function(){
13 if(this.options.generateTitle){
14 var $title = $('<h1>', { text: this.title });
15 this.$el.prepend($title);
16 }
17
18 this.$(".js-submit").text("Update contact");
19 this.ui.updateButton.text("Update contact");
20 }
21 });
test/assets/js/spec/apps/contacts/edit/edit_view.spec.js
And, just like we did for the “new” view, we want to check the form submission is processed properly
in the edit view:
Improving View Test Resilience 190
test/assets/js/spec/apps/contacts/edit/edit_view.spec.js
1 it("inherits from ContactsApp.Common.Views.Form", function(){
2 expect(this.view instanceof ContactManager.ContactsApp.Common.Views.Form).to
3 .be.true;
4 });
5 describe("inheritance", function(){
6 it("inherits from ContactsApp.Common.Views.Form", function(){
7 expect(this.view instanceof ContactManager.ContactsApp.Common.Views.Form).to
8 .be.true;
9 });
10
11 it("triggers 'form:submit' when the form is submitted", sinon.test(function(){
12 this.stub(this.view, "trigger");
13 this.view.render();
14
15 this.view.ui.updateButton.click();
16 expect(this.view.trigger).to.have.been.calledWith("form:submit").once;
17 }));
18 });
Naturally, we can’t forget to add a test for the common view (we got side-tracked a bit with the
“new” and “edit” views that inherit from it):
test/assets/js/spec/apps/contacts/list/list_view.spec.js
1 it("triggers 'form:submit' with the form data when the submit button is clicked",
2 function(done){
3 // edited for brevity
4
5 view.once("render", function(){
6 expect(submitSpy.called).to.be.false;
7 modelData.lastName = "Dunn";
8
9 $("#contact-lastName").val(modelData.lastName);
10 view.$el.find(".js-submit").click();
11 view.ui.submitButton.click();
12 expect(submitSpy.calledOnce).to.be.true;
13 expect(submitSpy.firstCall.args[0]).to.deep.equal(modelData);
14 done();
15 });
16 view.render();
17 });
Improving View Test Resilience 191
Why do we bother testing this, if we already have test coverage for it in both the “new”
and “edit” views? Because those tests check the functionality of their related views. The
test above checks the behavior of the “common form” and will ensure that if views inherit
from it without declaring their own ui and events hashes, they will still function properly.
contact_manager/assets/js/apps/contacts/show/show_view.js
1 Show.Contact = Marionette.ItemView.extend({
2 template: "#contact-view",
3
4 ui: {
5 editButton: "a.js-edit"
6 },
7
8 events: {
9 "click a.js-edit": "editClicked"
10 "click @ui.editButton": "editClicked"
11 },
12
13 // edited for brevity
test/assets/js/spec/apps/contacts/show/show_view.spec.js
1 describe("Contact", function(){
2 it("triggers 'contact:edit' with the same model when
3 the edit button is clicked", sinon.test(function(done){
4 // edited for brevity
5
6 view.once("render", function(){
7 this.$el.find(".js-edit").click();
8 this.ui.editButton.click();
9 expect(this.trigger).to.have.been.calledWith("contact:edit", model).once;
10 done();
11 });
12 view.render();
13 }));
14 });
As the contact app’s “list” action has 2 views we need to attend to, we’ll tackle them one after the
other. Here’s the first one:
Improving View Test Resilience 192
contact_manager/assets/js/apps/contacts/list/list_view.js
1 List.Panel = Marionette.ItemView.extend({
2 template: "#contact-list-panel",
3
4 ui: {
5 criterion: "input.js-filter-criterion",
6 filterForm: "#filter-form",
7 filterFormSubmitButton: "#filter-form button[type=submit]",
8 newButton: "button.js-new"
9 },
10
11 triggers: {
12 "click button.js-new": "contact:new"
13 "click @ui.newButton": "contact:new"
14 },
15
16 events: {
17 "submit #filter-form": "filterContacts"
18 "submit @ui.filterForm": "filterContacts"
19 },
20
21 ui: {
22 criterion: "input.js-filter-criterion"
23 },
24
25 filterContacts: function(e){
26 e.preventDefault();
27 var criterion = this.$(".js-filter-criterion").val();
28 this.trigger("contacts:filter", criterion);
29 this.trigger("contacts:filter", this.ui.criterion.val());
30 },
31
32 onSetFilterCriterion: function(criterion){
33 this.ui.criterion.val(criterion);
34 }
35 });
Improving View Test Resilience 193
test/assets/js/spec/apps/contacts/list/list_view.spec.js
1 describe("Panel", function(){
2 // edited for brevity
3
4 it("triggers 'contact:new' when the 'new' button is clicked", function(done){
5 this.view.once("render", function(){
6 this.$el.find(".js-new").click();
7 this.ui.newButton.click();
8 expect(this.trigger).to.have.been.calledWith("contact:new").once;
9 done();
10 });
11 this.view.render();
12 });
13
14 it("triggers 'contacts:filter' with the criterion when
15 #filter-form is submitted", function(done){
16 this.view.once("render", function(){
17 this.criterion.value("abc");
18 this.$el.find("#filter-form button[type=submit]").click();
19 this.ui.filterFormSubmitButton.click();
20 expect(this.trigger).to.have.been
21 .calledWith("contacts:filter", "abc").once;
22 done();
23 });
24 this.view.render();
25 });
26
27 it("updates the criterion in the filter form when the 'set:filter:criterion'
28 method is triggered", function(done){
29 // edited for brevity
30 });
31 });
contact_manager/assets/js/apps/contacts/list/list_view.js
1 List.Contact = Marionette.ItemView.extend({
2 tagName: "tr",
3 template: "#contact-list-item",
4
5 ui: {
6 deleteButton: "td button.js-delete",
7 editButton: "td a.js-edit",
8 showButton: "td a.js-show"
9 },
10
11 triggers: {
12 "click td a.js-show": "contact:show",
13 "click @ui.showButton": "contact:show",
14 "click td a.js-edit": "contact:edit",
15 "click @ui.editButton": "contact:edit",
16 "click button.js-delete": "contact:delete"
17 "click @ui.deleteButton": "contact:delete"
18 },
19
20 // edited for brevity
21 });
test/assets/js/spec/apps/contacts/list/list_view.spec.js
17 })();
18 };
19
20 describe("triggers", function(){
21 testButton("show");
22 testButton("edit");
23 testButton("delete");
24 });
25 });
⁷⁴https://github.com/davidsulc/marionette-testing/commit/91603fca1c2c15f4b31be10716e5f4f3d2be4ac6
The Header Sub-App
Go ahead: test and refactor the “header” sub-app, and turn the page.
The Header Sub-App 197
test/assets/js/spec/apps/header/list/list_controller.spec.js
1 describe("HeaderApp.List.Controller", function(){
2 before(function(){
3 ContactManager._configureRegions();
4 this.controller = ContactManager.HeaderApp.List.Controller;
5 });
6
7 after(function(){
8 delete this.controller;
9 });
10
11 beforeEach(function(){
12 sinon.stub(ContactManager.regions.header, "show");
13 });
14
15 afterEach(function(){
16 ContactManager.regions.header.show.restore();
17 });
18
19 describe("listHeader", function(){
20 beforeEach(function(){
21 this.headerView = _.extend({}, Backbone.Events);
22 sinon.stub(ContactManager.HeaderApp.List, "Headers")
23 .returns(this.headerView);
24 });
25
26 afterEach(function(){
27 delete this.headerView;
28 ContactManager.HeaderApp.List.Headers.restore();
29 });
30
31 it("displays the headers in the header region", function(){
32 this.controller.listHeader();
33 expect(ContactManager.regions.header.show).to
34 .have.been.calledWith(this.headerView).once;
35 });
36
37 describe("triggers", function(){
38 beforeEach(function(){
39 sinon.stub(ContactManager, "trigger");
40 });
41
The Header Sub-App 198
42 afterEach(function(){
43 ContactManager.trigger.restore();
44 });
45
46 it("triggers 'contacts:list' when the view triggers 'brand:clicked'",
47 function(){
48 this.controller.listHeader();
49 expect(ContactManager.trigger).to.not.have.been.called;
50 this.headerView.trigger("brand:clicked");
51 expect(ContactManager.trigger).to.have.been.calledWith("contacts:list");
52 });
53
54 it("triggers the model's navigation trigger when the view
55 triggers 'childview:navigate'", function(){
56 var header = new Backbone.Model({ navigationTrigger: "nav:trig" });
57
58 this.controller.listHeader();
59 expect(ContactManager.trigger).to.not.have.been.called;
60 this.headerView.trigger("childview:navigate", undefined, header);
61 expect(ContactManager.trigger).to.have
62 .been.calledWith(header.get("navigationTrigger"));
63 });
64 });
65 });
66
67 describe("setActiveHeader", function(){
68 it("sets the active header according to its URL", sinon.test(function(){
69 var headers = ContactManager.Entities._initializeHeaders();
70 var getSelectedModel = function(){
71 return (headers.filter(function(m){ return m.selected == true; }))[0];
72 };
73 this.stub(ContactManager, "request").withArgs("header:entities")
74 .returns(headers);
75 var firstModel = headers.first(),
76 lastModel = headers.last();
77
78 this.controller.setActiveHeader(firstModel.get("url"));
79 expect(getSelectedModel()).to.equal(firstModel);
80 this.controller.setActiveHeader(lastModel.get("url"));
81 expect(getSelectedModel()).to.equal(lastModel);
82 }));
83 });
The Header Sub-App 199
84 });
In the test starting on line 68, it’s particularly important to stub the headers. Otherwise, we’ll run
into issues due to our app code:
contact_manager/assets/js/apps/header/list/list_controller.js
1 var headers;
2 var API = {
3 getHeaders: function(){
4 if(headers === undefined){
5 headers = Entities._initializeHeaders();
6 }
7 return headers;
8 }
9 };
Our headers test file (test/assets/js/spec/entities/header.spec.js) has executed this code, and the
headers value is therefore not null. This means that the getHeaders API function would return
the headers regardless of what functions we had stubbed.
var headers;
Entities._headersInitialized = function(){
return headers !== undefined;
};
var API = {
getHeaders: function(){
if( ! Entities._headersInitialized()){
headers = Entities._initializeHeaders();
}
return headers;
}
};
This allows us to stub _headersInitialized to return the value we choose, therefore controlling
whether the headers get initialized or not. With this new possibility, let’s update our header tests
somewhat. Here’s a test we currently have:
The Header Sub-App 200
test/assets/js/spec/entities/header.spec.js
The reality is, we’re not really testing that our header entities can be fetched by a request: we’re
testing that the request will return initialized headers. This will only be correct if we can guarantee
the headers to be initialized each time this test is run. As we’ve seen above, that isn’t currently done:
if another test is executed before and initializes the headers, this test will fail. So let’s force our app
to initialize the headers every time we run the test:
test/assets/js/spec/entities/header.spec.js
With the addition of lines 3-4, we know that our headers will systematically be initialized every
time our test is run.
contact_manager/assets/js/apps/header/list/list_view.js
1 List.Header = Marionette.ItemView.extend({
2 template: "#header-link",
3 tagName: "li",
4
5 ui: {
6 link: "a"
7 },
8
9 events: {
10 "click a": "navigate",
11 "click @ui.link": "navigate"
12 },
13
14 // edited for brevity
15 });
16
17 List.Headers = Marionette.CompositeView.extend({
18 // edited for brevity
19 childViewContainer: "ul",
20
21 ui: {
22 brand: "a.brand"
23 },
24
25 triggers: {
26 "click @ui.brand": "brand:clicked"
27 }
28
29 events: {
30 "click a.brand": "brandClicked"
31 },
32
33 brandClicked: function(e){
34 e.preventDefault();
35 this.trigger("brand:clicked");
36 }
37 });
test/assets/js/spec/apps/header/list/list_view.spec.js
1 describe("HeaderApp.List", function(){
2 before(function(){
3 this.$fixture = $("<div>", { id: "fixture" });
4 this.$container = $("#view-test-container");
5 });
6
7 after(function(){
8 delete this.$fixture;
9 this.$container.empty();
10 delete this.$container;
11 });
12
13 beforeEach(function(){
14 this.$fixture.empty().appendTo(this.$container);
15 });
16
17 describe("Header", function(){
18 beforeEach(function(){
19 this.header = new ContactManager.Entities.Header({
20 name: "Contacts",
21 url: "contacts",
22 navigationTrigger: "contacts:list"
23 });
24
25 this.view = new ContactManager.HeaderApp.List.Header({
26 el: this.$fixture,
27 model: this.header
28 });
29 });
30
31 afterEach(function(){
32 delete this.header;
33 delete this.view;
34 });
35
36 it("displays the header name in the navbar", function(){
37 this.view.render();
38 var linkText = $.trim(this.view.ui.link.text());
39 expect(linkText).to.equal(this.header.get("name"));
40 });
41
The Header Sub-App 203
test/assets/js/spec/apps/header/header_app.spec.js
1 describe("HeaderApp", function(){
2 it("lists the headers when started", sinon.test(function(){
3 this.stub(ContactManager.HeaderApp.List.Controller, "listHeader");
4 expect(ContactManager.HeaderApp.List.Controller.listHeader).to
5 .not.have.been.called;
6 ContactManager.HeaderApp.start();
7 expect(ContactManager.HeaderApp.List.Controller.listHeader).to
8 .have.been.called.once;
9 }));
10
11 it("sets a command handler for 'set:active:header'", sinon.test(function(){
12 this.stub(ContactManager.HeaderApp.List.Controller, "setActiveHeader");
13 expect(ContactManager.HeaderApp.List.Controller.setActiveHeader).to
14 .not.have.been.called;
15 ContactManager.execute("set:active:header", "test");
16 expect(ContactManager.HeaderApp.List.Controller.setActiveHeader).to
17 .have.been.calledWith("test").once;
18 }));
19 });
⁷⁵https://github.com/davidsulc/marionette-testing/commit/52a564974cb9350c4239c9a9161b8becf12bd4b4
The Main Application
We’ll start with a few easy tests for our main app, so we can warm up:
test/assets/js/spec/app.spec.js
1 describe("ContactManager", function(){
2 describe("navigate", function(){
3 beforeEach(function(){
4 sinon.stub(Backbone.history, "navigate");
5 });
6
7 afterEach(function(){
8 Backbone.history.navigate.restore();
9 });
10
11 it("proxies Backbone.history.navigate", function(){
12 var options = { test: "value" };
13 ContactManager.navigate("testRoute", options);
14 expect(Backbone.history.navigate).to.have.been
15 .calledWith("testRoute", options).once;
16 });
17
18 it("uses an empty object as the 'options' value if none is provided",
19 function(){
20 ContactManager.navigate("testRoute");
21 expect(Backbone.history.navigate).to.have.been
22 .calledWith("testRoute", {}).once;
23 });
24 });
25
26 describe("getCurrentRoute", function(){
27 beforeEach(function(){
28 Backbone.history.start();
29 });
30
31 afterEach(function(){
32 Backbone.history.navigate("");
33 Backbone.history.stop();
34 });
The Main Application 206
35
36 it("returns the current history fragment", sinon.test(function(){
37 Backbone.history.navigate("testFragment");
38
39 var fragment = ContactManager.getCurrentRoute();
40 expect(Backbone.history.fragment).to.be.ok;
41 expect(fragment).to.equal(Backbone.history.fragment);
42 }));
43 });
44 });
45
46 describe("RegionContainer", function(){
47 beforeEach(function(){
48 this.view = new ContactManager.RegionContainer();
49 });
50
51 afterEach(function(){
52 delete this.view;
53 });
54
55 it("has a header region", function(){
56 expect(this.view.getRegion('header')).to.be.ok;
57 });
58
59 it("has a main region", function(){
60 expect(this.view.getRegion('main')).to.be.ok;
61 });
62
63 it("has a dialog region", function(){
64 expect(this.view.getRegion('dialog')).to.be.ok;
65 });
66 });
test/assets/js/spec/app.spec.js
1 describe("events", function(){
2 describe("before:start", function(){
3 beforeEach(function(){
4 this.view = new ContactManager.RegionContainer();
5 sinon.stub(ContactManager, "RegionContainer").returns(this.view);
6 });
7
8 afterEach(function(){
9 ContactManager.RegionContainer.restore();
10 delete this.view;
11 });
12
13 it("assigns a region container to ContactManager.regions",
14 sinon.test(function(){
15 ContactManager.trigger("before:start");
16 expect(ContactManager.regions).to.equal(this.view);
17 }));
18
19 describe("onShow configuration", function(){
20 beforeEach(function(){
21 this._origEl = this.view.dialog.$el;
22 this.view.dialog.$el = { dialog: sinon.stub() };
23 ContactManager.trigger("before:start");
24 });
25
26 afterEach(function(){
27 this.view.dialog.$el = this._origEl;
28 delete this._origEl;
29 });
30
31 it("configures an onShow function for the dialog region", function(){
32 expect(ContactManager.regions.dialog.onShow).to.be.ok;
33 expect(ContactManager.regions.dialog.onShow).to
34 .not.equal(Marionette.LayoutView.prototype.onShow);
35 });
36
37 it("configures a listener for the 'dialog:close' event",
38 sinon.test(function(){
39 this.stub(this.view.dialog, "listenTo");
40 var displayedView = new Marionette.View();
41
The Main Application 208
42 ContactManager.regions.dialog.triggerMethod("show", displayedView);
43 expect(this.view.dialog.listenTo).to.have.been.called.once;
44 var listenerConfig = this.view.dialog.listenTo.firstCall.args;
45 expect(listenerConfig[0]).to.equal(displayedView);
46 expect(listenerConfig[1]).to.equal("dialog:close");
47 }));
48
49 it("executes the shown view's $el's `dialog` method",
50 sinon.test(function(){
51 var displayedView = new Marionette.View();
52
53 ContactManager.regions.dialog.triggerMethod("show", displayedView);
54 expect(this.view.dialog.$el.dialog).to.have.been.called.once;
55 }));
56 });
57 });
58 });
There are a few things of interest to note in the above code. First, we want to replace the dialog
region’s $el with our own object containing a stubbed dialog method. To achieve this, we save a
reference to the original implementation (line 21) before replacing it (line 22). Then, in our teardown
function, we restore the original value (line 27).
Second, on lines 42 and 53, we’re using triggerMethod to make sure our onShow code gets run: this
achieves our goal, without having to deal with stubbing the region’s show method while ensuring
the onShow code gets run. Since Marionette is responsible for executing the same triggerMethod call
anytime the region’s show method is executed, this test implementation is good enough for us.
Finally, we want to check some of the arguments provided to the “dialog:close” event listener. Here’s
the relevant app code:
contact_manager/assets/js/app.js
1 configureDialogRegion: function(){
2 this.dialog.onShow = function(view){
3 var self = this;
4 var closeDialog = function(){
5 self.stopListening();
6 self.empty();
7 self.$el.dialog("destroy");
8 };
9
10 this.listenTo(view, "dialog:close", closeDialog);
11
The Main Application 209
We configure the event listener on line 10 with 3 arguments; the last one, however, is locally-scoped
and therefore won’t be accessible for comparaison within our test code. But since our test focuses
on an event listener being registered (and not what the function is), testing the first 2 arguments is
good enough. And here’s how we do that in our test code:
test/assets/js/spec/app.spec.js
1 ContactManager.regions.dialog.triggerMethod("show", displayedView);
2 expect(this.view.dialog.listenTo).to.have.been.called.once;
3 var listenerConfig = this.view.dialog.listenTo.firstCall.args;
4 expect(listenerConfig[0]).to.equal(displayedView);
5 expect(listenerConfig[1]).to.equal("dialog:close");
On line 3, we access the first call to the listenTo method. Once that’s done, all we’ve got left to
do is to verify that the first argument provided was our view, and the second one was the event for
which we’re checking the configuration.
Let’s got ahead and refactor our code somewhat:
contact_manager/assets/js/app.js
1 ContactManager.RegionContainer = Marionette.LayoutView.extend({
2 el: "#app-container",
3
4 regions: {
5 header: "#header-region",
6 main: "#main-region",
7 dialog: "#dialog-region"
8 }
9 },
10
11 configureDialogRegion: function(){
12 this.dialog.onShow = function(view){
13 var self = this;
14 var closeDialog = function(){
15 self.stopListening();
16 self.empty();
17 self.$el.dialog("destroy");
18 };
The Main Application 210
19
20 this.listenTo(view, "dialog:close", closeDialog);
21
22 this.$el.dialog({
23 modal: true,
24 title: view.title,
25 width: "auto",
26 close: function(e, ui){
27 closeDialog();
28 }
29 });
30 };
31 }
32 });
33
34 ContactManager._configureRegions = function(){
35 this.regions = new ContactManager.RegionContainer();
36 this.regions.configureDialogRegion();
37 };
38
39 ContactManager.on("before:start", function(){
40 ContactManager._configureRegions();
41 ContactManager.regions.dialog.onShow = function(view){
42 var self = this;
43 var closeDialog = function(){
44 self.stopListening();
45 self.empty();
46 self.$el.dialog("destroy");
47 };
48
49 this.listenTo(view, "dialog:close", closeDialog);
50
51 this.$el.dialog({
52 modal: true,
53 title: view.title,
54 width: "auto",
55 close: function(e, ui){
56 closeDialog();
57 }
58 });
59 };
60 });
The Main Application 211
test/assets/js/spec/app.spec.js
1 describe("events", function(){
2 describe("before:start", function(){
3 // edited for brevity
4 });
5
6 describe("start", function(){
7 beforeEach(function(){
8 sinon.stub(Backbone.history, "start");
9 });
10
11 afterEach(function(){
12 Backbone.history.start.restore();
13 });
14
15 it("starts Backbone.history", sinon.test(function(){
16 this.stub(ContactManager, "getCurrentRoute").returns("notEmpty");
17
18 ContactManager.trigger("start");
19 expect(Backbone.history.start).to.have.been.called.once;
20 }));
21
22 it("triggers 'contacts:list' if the current URL fragment is empty",
23 sinon.test(function(){
24 this.stub(ContactManager, "getCurrentRoute").returns("");
25 this.spy(ContactManager, "trigger");
26 this.stub(ContactManager.ContactsApp.List.Controller, "listContacts");
27
28 ContactManager.trigger("start");
29 expect(ContactManager.trigger).to.have.been
30 .calledWith("contacts:list").once;
31 }));
32 });
33 });
It’s worth discussing our last test. Here’s the code it refers to:
The Main Application 212
contact_manager/assets/js/app.js
1 ContactManager.on("start", function(){
2 if(Backbone.history){
3 Backbone.history.start();
4
5 if(this.getCurrentRoute() === ""){
6 ContactManager.trigger("contacts:list");
7 }
8 }
9 });
Our app code is listening for the “start” event. To trigger this event, we need ContactMan-
ager.trigger to retain it’s normal functionality. But if we don’t stub that method, line 6 above
will lead to controller code in the “contacts” app to being executed; so we need to stub that out on
line 26.
But what if we could use the same technique as before and leverage triggerMethod instead? Then
we could stub out the trigger method and our test code would still work fine. In addition, it would
make our test clearer, because you wouldn’t need knowledge about what code is listening for the
“contacts:list” event: that code might need to be stubbed out.
So let’s improve our code:
contact_manager/assets/js/app.js
1 ContactManager.on("before:start", function(){
2 ContactManager.onBeforeStart = function(){
3 ContactManager._configureRegions();
4 });
5 };
6
7 ContactManager.on("start", function(){
8 ContactManager.onStart = function(){
9 // edited for brevity
10 });
11 };
The Main Application 213
test/assets/js/spec/app.spec.js
42 expect(Backbone.history.start).to.have.been.called.once;
43 }));
44
45 it("triggers 'contacts:list' if the current URL fragment is empty",
46 sinon.test(function(){
47 this.stub(ContactManager, "getCurrentRoute").returns("");
48 this.stub(ContactManager, "trigger");
49
50 ContactManager.trigger("start");
51 ContactManager.triggerMethod("start");
52 expect(ContactManager.trigger).to.have.been.calledWith("contacts:list").once;
53 }));
54 });
contact_manager/assets/js/apps/contacts/contacts_app.js
1 ContactsApp.onStart = function(){
2 new ContactsApp.Router({
3 controller: ContactsApp._API
4 });
5 };
contact_manager/assets/js/apps/about/about_app.js
1 AboutApp.onStart = function(){
2 new AboutApp.Router({
3 controller: AboutApp._API
4 });
5 };
contact_manager/assets/js/apps/header/header_app.js
1 Header.onStart = function(){
2 API.listHeader();
3 };
Why are we changing the “start” event listeners, but not others such as the contact
app’s “contacts:list”? Well, it pretty much boils down to personal preference… To me,
they’re different: “start” is an internal method execution we want to hook into, whereas
“contacts:list” is an application-level event we triggered. Therefore, our code handles the
“contacts:list” event with an event listener.
The Main Application 215
⁷⁶https://github.com/davidsulc/marionette-testing/commit/30ca550466482539f09f5fcfe7caec8ee392b654
Running Tests from the Command
Line
Running our tests from a web page to view their status is great while developing, but less useful
within a continuous integration⁷⁷ environment where tests need to be run constantly and in an
automated manner.
What would be great is to be able to run our test suite from the command line while retaining the
possibility to run it within a web browser. In this chapter, we’ll do exactly that.
We’re going to rely on PhantomJS⁷⁸ to provide us with a headless browser we can use to run our test
suite from the command line. We are, however, going to leverage PhantomJS through the mocha-
phantomjs project⁷⁹. This project relies on Node.js, so start by installing it (download here⁸⁰).
With Node installed, we can now install the mocha-phantomjs package globally⁸¹ so it’s available
for use through the commande line. Execute the following command (you might need to do so as
your operating system’s super user):
Congratulations! You can now run your test suite from the command line. Try this command from
the project’s root directory:
mocha-phantomjs test/test.html
Hmmm… Not the result we were expecting: we’re told “Failed to start mocha: Init timeout”. That’s
because we need to use the Mocha instance provided by PhantomJS so it can control test execution.
The modification is quite straightforward: we’ll have our code try to use PhantomJS’s Mocha value
and, if it’s undefined, fallback to the Mocha instance we’ve already been using:
⁷⁷http://en.wikipedia.org/wiki/Continuous_integration
⁷⁸http://phantomjs.org/
⁷⁹https://github.com/metaskills/mocha-phantomjs
⁸⁰https://nodejs.org/download/
⁸¹https://docs.npmjs.com/getting-started/installing-npm-packages-globally
Running Tests from the Command Line 217
test/test.html
1 <script type="text/javascript">
2 mocha.setup("bdd");
3 window.expect = chai.expect;
4 window.onload = function () {
5 mocha.run();
6 (window.mochaPhantomJS || mocha).run();
7 };
8 </script>
mocha-phantomjs test/test.html
Header entity
Model
✓ defines (de)selection functions
✓ is selectable
✓ is not 'selected' by default
Collection
✓ is for Header models
✓ contains one model per navigation menu item
✓ is single selectable
✓ can be fetched with a 'header:entities' request
✓ is a singleton when obtained by request
Naturally, the most convenient way to execute this is from within a script that will run all you tests.
Within a script, you can then check your operating system’s exit code variable to verify whether all
tests passed:
⁸²https://github.com/davidsulc/marionette-testing/commit/5d75d3c9359f0a0de35d223b4c31a0fe3fefbfdd
A Word on Mocks
We’ve performed all of our testing using stubs and spies, checking if various functions were behaving
as expected. This is not the only way to test your code, however: mocks are frequently used, and
you will no doubt come across them sooner or later.
What’s the difference between testing with stubs or mocks? Essentially, testing with stubs relies on
checking state after the tested code has run: did the application end up in the expected state? Testing
with mocks, on the other hand checks behavior: did the tested object behave as anticipated (number
of method calls, etc.)?
Let’s take a look at how we could alter one of our tests to use mocks instead:
test/assets/js/spec/apps/contacts/list/list_controller.spec.js
1 beforeEach(function(){
2 sinon.stub(ContactManager.regions.main, "show");
3 // sinon.stub(ContactManager.regions.main, "show");
4 });
5
6 afterEach(function(){
7 ContactManager.regions.main.show.restore();
8 // ContactManager.regions.main.show.restore();
9 });
10
11 describe("listContacts", function(){
12 it("shows a loading view before loading the contacts", sinon.test(function(){
13 it.only("shows a loading view before loading the contacts",
14 sinon.test(function(){
15 var mainRegionMock = sinon.mock(ContactManager.regions.main);
16 var loadingView = {};
17 this.stub(ContactManager.Common.Views, "Loading").returns(loadingView);
18 mainRegionMock.expects("show").withArgs(loadingView);
19
20 this.controller.listContacts();
21 expect(ContactManager.regions.main.show).to.have.been.calledWith(view).once;
22 mainRegionMock.verify();
23 }));
24
25 // edited for brevity
A Word on Mocks 220
• we can’t mock the show action if it’s stubbed, so we need to comment those lines;
• since our other tests depend on the show method being stubbed, we’ll be running this test with
only (line 13).
We mock the “main region” object on line 15, and configure the expectations we have for its behavior
on line 18. After executing the test, we verify the mock’s behavior (line 22). Verifying the mock
will check all the expectations we’ve configured on the mock and will raise an exception if any
expectation is not satisfied. In addition, calling verify will restore the mocked methods.
If we run this test as it is now, we’ll get an error: “Unexpected call: show […]”. That’s because
according to our code, we’re only expecting a single call to show (per line 18). Our app code, however,
calls show twice: once for the loading view, then another time for the main layout view. Hence, our
test fails.
The fix is simple: we can tell our mock we expect another call with the layout view:
test/assets/js/spec/apps/contacts/list/list_controller.spec.js
1 // edited for brevity
2
3 describe("listContacts", function(){
4 it.only("shows a loading view before loading the contacts",
5 sinon.test(function(){
6 var mainRegionMock = sinon.mock(ContactManager.regions.main);
7 var loadingView = {},
8 mainLayout = {};
9 this.stub(ContactManager.Common.Views, "Loading").returns(loadingView);
10 this.stub(ContactManager.ContactsApp.List, "Layout").returns(mainLayout);
11 mainRegionMock.expects("show").withArgs(loadingView);
12 mainRegionMock.expects("show").withArgs(mainLayout);
13
14 this.controller.listContacts();
15 mainRegionMock.verify();
16 }));
17
18 // edited for brevity
As you can see, with mocks we configure our expected behavior before the test, and verify it
after the test has run. There is a risk, however, that our tests become too tightly coupled with
our application code implementation. This could lead our tests to become brittle and needing to
be modified frequently as the inner implementation of our application evolves.
Let’s have a go at another example:
A Word on Mocks 221
test/assets/js/spec/apps/contacts/list/list_controller.spec.js
1 beforeEach(function(){
2 sinon.stub(ContactManager.regions.main, "show");
3 });
4
5 afterEach(function(){
6 ContactManager.regions.main.show.restore();
7 });
8
9 describe("listContacts", function(){
10 it("shows a loading view before loading the contacts", sinon.test(function(){
11 // edited for brevity
12 }));
13
14 describe("main layout", function(){
15 // edited for brevity
16 });
17
18 describe("sub-view behavior", function(){
19 describe("Panel", function(){
20 beforeEach(function(){
21 this.panel = new Marionette.Object();
22 this.panel.collection = { filter: sinon.stub() };
23 this.panel.collection = { filter: function(){} };
24 sinon.stub(ContactManager.ContactsApp.List, "Panel").returns(this.panel);
25 this.list = new Marionette.Object();
26 sinon.stub(ContactManager.ContactsApp.List, "Contacts")
27 .returns(this.list);
28 });
29
30 afterEach(function(){
31 // edited for brevity
32 });
33
34 describe("if a criterion is provided", function(){
35 it.only("filters the collection", function(){
36 var collectionMock = sinon.mock(this.panel.collection);
37 collectionMock.expects("filter").withArgs("abc").once();
38 this.controller.listContacts("abc");
39 expect(this.panel.collection.filter).to.have.
40 been.calledWith("abc").once;
41 collectionMock.verify();
A Word on Mocks 222
42 });
43
44 // edited for brevity
Don’t forget to remove the only call on the previous test so this one will be run instead. In
addition, you’ll need to uncomment the lines stubbing the show method (lines 1-7).
Note that you should only be mocking the object under test, and should therefore never have more
than one mock in any given test. For a more in-depth discussion on mocking, take a look here⁸³.
⁸³http://martinfowler.com/articles/mocksArentStubs.html
Closing Thoughts
We’ve covered a lot of ground in this book, and hopefully you’ve seen the benefits that testing
can provide with regards to productivity, maintainability, and refactoring. Read up on the various
libraries and you’ll no doubt uncover many more useful testing functionalities to help you in your
quest to produce bug-free code that’s a pleasure to work with.
If you’ve enjoyed the book, it would be immensely helpful if you could take a few minutes to write
your opinion on the book’s review page⁸⁴. Help others determine if the book is right for them!
Would you like me to cover another subject in an upcoming book? Let me know by email at
[email protected] or on Twitter (@davidsulc). In the meantime, see the next chapter for my
current list of books.
Thanks for reading this book, it’s been great having you along!
Keeping in Touch
I plan to release more books in the future, where each will teach a new skill (like in this book) or
help you improve an existing skill and take it to the next level.
If you’d like to be notified when I release a new book, receive discounts, etc., sign up to my mailing
list at davidsulc.com/mailing_list⁸⁵. No spam, I promise!
You can also follow me on Twitter: @davidsulc
⁸⁴https://leanpub.com/marionette-testing/feedback
⁸⁵http://davidsulc.com/mailing_list
Other Books I’ve Written
Presumably, you’re already comfortable with how Marionette works. If that isn’t quite the case,
take a look the book where the Contact Manager application is originally developed: Back-
bone.Marionette.js: A Gentle Introduction⁸⁶.
If you’re interested in adding RequireJS to the mix, check out my next book: Structuring Backbone
Code with RequireJS and Marionette Modules⁸⁷. It takes the original Contact Manager application
we’ve started with, and rewrites it to use RequireJS:
• manage dependencies;
• load templates from separate files (templates are no longer in index.html!);
• explains typical errors and how you can approach debugging them;
• how to produce a single, optimized, and minifed javascript file of your application that is
ready for production.
And best of all, it is written in an exercise style: each chapter introduces what you need to develop,
and points out the various things to keep in mind (dependencies, loading order, etc.). You then add
the new module to the application by yourself, and can check your answer by reading the step-by-
step explanation that follows in the chapter.
And if you’re looking to learn more advanced Marionette concepts, make sure you read Marionette:
A Serious Progression⁸⁸! It introduces advanced techniques for you to use on your apps:
⁸⁶https://leanpub.com/marionette-gentle-introduction
⁸⁷https://leanpub.com/structuring-backbone-with-requirejs-and-marionette
⁸⁸https://leanpub.com/marionette-serious-progression