Testing
Learn how to test CanJS applications.
This guide will show you how to set up and write tests for different pieces of CanJS applications. It will also show techniques that can be used to test things that would otherwise be difficult to test. Not all of these techniques will be needed for every application.
This guide does not focus on how to write applications in a maintainable, testable way. That is covered in the Logic Guide.
Note: All of the examples in this guide use the Mocha test framework and Chai assertion library, but none of the examples are specific to Mocha/Chai and should work with any setup.
Observables
Observables contain a majority of the logic in CanJS applications, so it is very important that they are well-tested. Since CanJS observables act mostly like normal JavaScript objects, testing them usually works just like working with normal objects—set a property (or call a function) then check the value of other properties. This setup is shown below, followed by a few techniques for making it easier to test more complex observables.
Note: The examples below show how to test an ObservableObject, but the same techniques also work with an ObservableArray.
Basic setup
The basic setup for testing an observable is:
- Create an instance of the observable
- Test default values of the observable’s properties
- Set properties (or call functions) on the observable
- Test values of the observable’s properties
- Repeat 3 & 4
<div id="mocha"></div>
<link rel="stylesheet" href="//unpkg.com/mocha@6/mocha.css" />
<script src="//unpkg.com/mocha@6/mocha.js" type="text/javascript"></script>
<script src="//unpkg.com/chai@4/chai.js" type="text/javascript"></script>
<script type="module">
import { ObservableObject } from "can";
// Mocha / Chai Setup
mocha.setup("bdd");
const assert = chai.assert;
class Person extends ObservableObject {
static props = {
first: String,
last: String,
get name() {
return `${this.first || ""} ${this.last || ""}`.trim();
}
};
setName(val) {
const parts = val.split(" ");
this.first = parts[0];
this.last = parts[1];
}
}
describe("Person", () => {
it("name", () => {
// 1. Create an instance of the ObservableObject
const vm = new Person({});
// 2. Test values of the ObservableObject’s default values
assert.equal(vm.name, "", "default `name` is correct");
// 3. Set ObservableObject properties (or call ObservableObject functions)
vm.first = "Kevin";
// 4. Test values of the ObservableObject’s properties
assert.equal(
vm.name,
"Kevin",
"setting `first` updates `name` correctly"
);
// 3. Set ObservableObject properties (or call ObservableObject functions)
vm.last = "McCallister";
// 4. Test values of the ObservableObject’s properties
assert.equal(
vm.name,
"Kevin McCallister",
"setting `last` updates `name` correctly"
);
// 3. Set ObservableObject properties (or call ObservableObject functions)
vm.setName("Marv Merchants");
// 4. Test values of the ObservableObject’s properties
assert.equal(vm.first, "Marv", "`setName` updates `first` correctly");
assert.equal(vm.last, "Merchants", "`setName` updates `last` correctly");
});
});
// start Mocha
mocha.run();
</script>
Asynchronous behavior
Asynchronous behavior is one of the toughest things to test in JavaScript. There are a few techniques that can be used to make it a little easier in CanJS applications.
The following example uses listenTo
to capture the value whenever you type into the <input>
element, but it only updates the value of the text
property when nothing has been typed for 500ms:
The difficulty in testing this observable is knowing when to run assertions. One approach to testing this code is:
- Set the
text
property - Wait 500ms
- Test that the value of the
text
property is correct
This might work initially, but different browsers will not handle this 500ms delay in exactly the same way. Tests using setTimeout
like this become very brittle and prone to break as browsers and test environments change. It is very frustrating to write a test and have it start failing six months down the road even though nothing in the code has changed.
This brittleness can be avoided by using an event listener instead of setTimeout
. Using this technique, the test approach is:
- Set the
text
property - Wait for the
text
property to change - Test that the value of the
text
property is correct
Since the event listener needs to be set up before the property is changed, in practice this approach becomes:
- Create an event listener for when the
text
property changes - Set the
text
property - When the event listener is triggered, test that the value of the
text
property is correct
Here is how this is done for this example:
<div id="mocha"></div>
<link rel="stylesheet" href="//unpkg.com/mocha@6/mocha.css" />
<script src="//unpkg.com/mocha@6/mocha.js" type="text/javascript"></script>
<script src="//unpkg.com/chai@4/chai.js" type="text/javascript"></script>
<script type="module">
import { ObservableObject } from "can";
// Mocha / Chai Setup
mocha.setup("bdd");
const assert = chai.assert;
class ThrottledText extends ObservableObject {
static props = {
text: {
value({ lastSet, listenTo, resolve }) {
let latest = "";
let timeoutId = null;
listenTo(lastSet, val => {
latest = val;
timeoutId = clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
resolve(latest);
}, 500);
});
}
}
};
}
describe("ThrottledText", () => {
it("text", done => {
const throttled = new ThrottledText({});
throttled.listenTo("text", () => {
assert.equal(throttled.text, "Hi there!", "text updated correctly");
done();
});
throttled.text = "Hi there!";
});
});
// start Mocha
mocha.run();
</script>
Note: When using Mocha, testing asynchronous code is accomplished by calling the
done
callback to indicate the test is complete. Different testing frameworks might have slightly different solutions.
Properties derived from asynchronous behavior
It is often useful to use an asynchronous property to load data from a model or service layer. It can be difficult to test this without also testing the model. The async property might look something like this:
class Todos extends ObservableObject {
static props = {
todoCount: {
async(resolve) {
todoConnection.getList({}).then(response => {
resolve(response.metadata.count);
});
}
}
};
}
The primary logic in this code is responsible for reading the metadata.count
property from the service layer response and setting it as the todoCount
property on the observable. The way this code is written makes it very difficult to test this logic.
In order to make it easier, first split this property into two properties:
- the count property itself
- the promise returned by the Model
class Todos extends ObservableObject {
static props = {
todoCount: {
async(resolve) {
this.todoCountPromise.then(response => {
resolve(response.metadata.count);
});
}
},
todoCountPromise: {
get(lastSet) {
return todoConnection.getList({});
}
}
};
}
Next, make it possible to override the todoCountPromise
property by utilizing lastSet:
<div id="mocha"></div>
<link rel="stylesheet" href="//unpkg.com/mocha@6/mocha.css" />
<script src="//unpkg.com/mocha@6/mocha.js" type="text/javascript"></script>
<script src="//unpkg.com/chai@4/chai.js" type="text/javascript"></script>
<script type="module">
import { ObservableObject } from "can";
// Mocha / Chai Setup
mocha.setup("bdd");
const assert = chai.assert;
class Todos extends ObservableObject {
static props = {
todoCount: {
async(resolve, lastSet) {
this.todoCountPromise.then(response => {
resolve(response.metadata.count);
});
}
},
todoCountPromise: {
get(lastSet) {
if (lastSet) {
return lastSet;
}
return todoConnection.getList({});
}
}
};
}
describe("Todos", () => {
it("todoCount", done => {
const todoResponse = {
metadata: { count: 150 },
data: []
};
const todos = new Todos({
todoCountPromise: Promise.resolve(todoResponse)
});
todos.listenTo("todoCount", () => {
assert.equal(todos.todoCount, 150, "`todoCount` === 150");
done();
});
});
});
// start Mocha
mocha.run();
</script>
Now this can be tested by setting the default value of todoCountPromise
to a promise that resolves with test data:
<div id="mocha"></div>
<link rel="stylesheet" href="//unpkg.com/mocha@6/mocha.css" />
<script src="//unpkg.com/mocha@6/mocha.js" type="text/javascript"></script>
<script src="//unpkg.com/chai@4/chai.js" type="text/javascript"></script>
<script type="module">
import { ObservableObject } from "can";
// Mocha / Chai Setup
mocha.setup("bdd");
const assert = chai.assert;
class Todos extends ObservableObject {
static props = {
todoCount: {
async(resolve, lastSet) {
this.todoCountPromise.then(response => {
resolve(response.metadata.count);
});
}
},
todoCountPromise: {
get(lastSet) {
if (lastSet) {
return lastSet;
}
return todoConnection.getList({});
}
}
};
}
describe("Todos", () => {
it("todoCount", done => {
const todoResponse = {
metadata: { count: 150 },
data: []
};
const todos = new Todos({
todoCountPromise: Promise.resolve(todoResponse)
});
todos.listenTo("todoCount", () => {
assert.equal(todos.todoCount, 150, "`todoCount` === 150");
done();
});
});
});
// start Mocha
mocha.run();
</script>
Since this is a default value, the actual model’s getList
method will never be called. The todoCount
property can then be tested like any other asynchronous behavior.
<div id="mocha"></div>
<link rel="stylesheet" href="//unpkg.com/mocha@6/mocha.css" />
<script src="//unpkg.com/mocha@6/mocha.js" type="text/javascript"></script>
<script src="//unpkg.com/chai@4/chai.js" type="text/javascript"></script>
<script type="module">
import { ObservableObject } from "can";
// Mocha / Chai Setup
mocha.setup("bdd");
const assert = chai.assert;
class Todos extends ObservableObject {
static props = {
todoCount: {
async(resolve, lastSet) {
this.todoCountPromise.then(response => {
resolve(response.metadata.count);
});
}
},
todoCountPromise: {
get(lastSet) {
if (lastSet) {
return lastSet;
}
return todoConnection.getList({});
}
}
};
}
describe("Todos", () => {
it("todoCount", done => {
const todoResponse = {
metadata: { count: 150 },
data: []
};
const todos = new Todos({
todoCountPromise: Promise.resolve(todoResponse)
});
todos.listenTo("todoCount", () => {
assert.equal(todos.todoCount, 150, "`todoCount` === 150");
done();
});
});
});
// start Mocha
mocha.run();
</script>
It is also possible to test this synchronously by setting todoCountPromise
to a normal object that has the same methods as a Promise, but “resolves” synchronously. This might look like:
const testTodoCountPromise = {
then(resolve) {
resolve(todoResponse);
}
};
const todos = new Todos({
todoCountPromise: testTodoCountPromise
});
With this approach, the assertions can be made outside of the listenTo
callback and there is no need to call done()
since this test is now synchronous.
Note: Even with this approach,
listenTo
still needs to be called; without this, CanJS will not provide theresolve
function to the asynchronous getter. This is done to prevent memory leaks.
<div id="mocha"></div>
<link rel="stylesheet" href="//unpkg.com/mocha@6/mocha.css" />
<script src="//unpkg.com/mocha@6/mocha.js" type="text/javascript"></script>
<script src="//unpkg.com/chai@4/chai.js" type="text/javascript"></script>
<script type="module">
import { ObservableObject } from "can";
// Mocha / Chai Setup
mocha.setup("bdd");
const assert = chai.assert;
class Todos extends ObservableObject {
static props = {
todoCount: {
async(resolve, lastSet) {
this.todoCountPromise.then(response => {
resolve(response.metadata.count);
});
}
},
todoCountPromise: {
get(lastSet) {
if (lastSet) {
return lastSet;
}
return todoConnection.getList({});
}
}
};
}
describe("Todos", () => {
it("todoCount", () => {
const todoResponse = {
metadata: { count: 150 },
data: []
};
const testTodoCountPromise = {
then(resolve) {
resolve(todoResponse);
}
};
const todos = new Todos({
todoCountPromise: testTodoCountPromise
});
todos.listenTo("todoCount", () => {});
assert.equal(todos.todoCount, 150, "`todoCount` === 150");
});
});
// start Mocha
mocha.run();
</script>
Properties derived from models (or any imported module)
The previous example shows how to test logic that is dependent on a promise returned by a getList call. That example did not show how to test that the Model is used correctly.
Specifically, we did not test:
todoCountPromise
callstodoConnection.getList
todoCountPromise
is the return value oftodoConnection.getList
import todoConnection from "models/todo";
class Todos extends ObservableObject {
static props = {
todoCountPromise: {
get(lastSet) {
return todoConnection.getList({});
}
}
};
}
This could be tested using can-fixture, but doing this would also test any logic in the todoConnection
itself. A unit test of the observable should just test the code in the observable; testing the model should be handled by tests specifically created to test the model and/or in integration tests. Both of these will be discussed later in this guide.
To test the todoCountPromise
, you can store the todoConnection
as a property on the observable and then use this.todoConnection
instead of the todoConnection
that was imported:
import todoConnection from "models/todo";
class Todos extends ObservableObject {
static props = {
todoConnection: {
get default() {
return todoConnection;
}
},
todoCountPromise: {
get(lastSet) {
return this.todoConnection.getList({});
}
}
};
}
Using this technique allows you to set a new value of todoConnection
by passing it as a default value to the ObservableObject constructor. You can then test that the getList
function was called (as well as test the arguments passed to it) and also test that the getter returned the correct value.
<div id="mocha"></div>
<link rel="stylesheet" href="//unpkg.com/mocha@6/mocha.css" />
<script src="//unpkg.com/mocha@6/mocha.js" type="text/javascript"></script>
<script src="//unpkg.com/chai@4/chai.js" type="text/javascript"></script>
<script type="module">
import { ObservableObject } from "can";
// Mocha / Chai Setup
mocha.setup("bdd");
const assert = chai.assert;
class Todos extends ObservableObject {
static props = {
completeFilter: Boolean,
todoConnection: {
get default() {
return todoConnection;
}
},
todoCountPromise: {
get() {
const complete = this.completeFilter;
const req = {};
if (complete != null) {
req.complete = complete;
}
return this.todoConnection.getList(req);
}
}
};
}
describe("Todos", () => {
it("todoCountPromise", () => {
let getListOptions = null;
const testPromise = new Promise((res, rej) => {});
const testTodoConnection = {
getList(options) {
getListOptions = options;
return testPromise;
}
};
const todos = new Todos({
todoConnection: testTodoConnection
});
todos.listenTo("todoCountPromise", () => {});
assert.equal(
todos.todoCountPromise,
testPromise,
"todoCountPromise is the promise returned by getList"
);
todos.completeFilter = true;
assert.equal(
getListOptions.complete,
true,
"completeFilter: true is passed to getList"
);
todos.completeFilter = false;
assert.equal(
getListOptions.complete,
false,
"completeFilter: false is passed to getList"
);
});
});
// start Mocha
mocha.run();
</script>
This technique is useful for testing code using models, but it can be used to test any code that uses a function or property exported directly from another module.
Components
Components are the glue that holds CanJS applications together—connecting observables to the DOM, handling events triggered by user interaction, interfacing with third-party libraries, and many other things.
There are different challenges to testing each of these responsibilities. These are discussed in the sections below.
Properties
All of the techniques described in Testing Observables can be used for testing a component’s properties by creating an instance of the component:
<div id="mocha"></div>
<link rel="stylesheet" href="//unpkg.com/mocha@6/mocha.css" />
<script src="//unpkg.com/mocha@6/mocha.js" type="text/javascript"></script>
<script src="//unpkg.com/chai@4/chai.js" type="text/javascript"></script>
<script type="module">
import { StacheElement } from "can";
// Mocha / Chai Setup
mocha.setup("bdd");
const assert = chai.assert;
class NameForm extends StacheElement {
static view = `
<div>
<label>
First: <input value:bind="this.first">
</label>
<label>
Last: <input value:bind="this.last">
</label>
<p>
<button on:click="this.setName('Kevin McCallister')">Pick Random Name</button>
</p>
<p>Name: {{ this.name }}</p>
</div>
`;
static props = {
first: String,
last: String,
get name() {
return `${this.first || ""} ${this.last || ""}`.trim();
}
};
setName(val) {
const parts = val.split(" ");
this.first = parts[0];
this.last = parts[1];
}
}
customElements.define("name-form", NameForm);
describe("NameForm component", () => {
it("name", () => {
// 1. Create an instance of the component
const nameForm = new NameForm();
// 2. Test the component’s default values
assert.equal(nameForm.name, "", "default `name` is correct");
// 3. Set properties (or call functions) on the component
nameForm.first = "Kevin";
// 4. Test the component’s property values
assert.equal(
nameForm.name,
"Kevin",
"setting `first` updates `name` correctly"
);
// 3. Set properties (or call functions) on the component
nameForm.last = "McCallister";
// 4. Test the component’s property values
assert.equal(
nameForm.name,
"Kevin McCallister",
"setting `first` updates `name` correctly"
);
// 3. Set component properties (or call component functions)
nameForm.setName("Marv Merchants");
// 4. Test values of the component’s properties
assert.equal(
nameForm.first,
"Marv",
"`setName` updates `first` correctly"
);
assert.equal(
nameForm.last,
"Merchants",
"`setName` updates `last` correctly"
);
});
});
// start Mocha
mocha.run();
</script>
DOM Events
DOM events handled through can-stache-bindings, like value:bind="first"
, can be tested through the component directly as shown in Testing Observables. However, they can also be tested by:
- Creating an instance of the component
- Calling render on the instance to render the component’s view into its
innerHTML
- Finding the event target through the component
- Using domEvents.dispatch to dispatch the event
Note: Tests like this will work even if the component is not in the document.
<div id="mocha"></div>
<link rel="stylesheet" href="//unpkg.com/mocha@6/mocha.css" />
<script src="//unpkg.com/mocha@6/mocha.js" type="text/javascript"></script>
<script src="//unpkg.com/chai@4/chai.js" type="text/javascript"></script>
<script type="module">
import { domEvents, StacheElement } from "can";
// Mocha / Chai Setup
mocha.setup("bdd");
const assert = chai.assert;
class NameForm extends StacheElement {
static view = `
<div>
<label>
First: <input class="first" value:bind="this.first">
</label>
<label>
Last: <input class="last" value:bind="this.last">
</label>
<p>
<button on:click="this.setName('Kevin McCallister')">Pick Random Name</button>
</p>
<p>Name: {{ this.name }}</p>
</div>
`;
static props = {
first: String,
last: String,
get name() {
return `${this.first || ""} ${this.last || ""}`.trim();
}
};
setName(val) {
const parts = val.split(" ");
this.first = parts[0];
this.last = parts[1];
}
}
customElements.define("name-form", NameForm);
describe("NameForm component DOM events", () => {
it("first name updated when user types in <input>", () => {
// 1. Creating an instance of the Component
const nameForm = new NameForm();
// 2. Calling render() on the instance
nameForm.render();
// 3. Finding the event target through the component
const input = nameForm.querySelector("input.first");
// 4. Using domEvents.dispatch to dispatch the event
input.value = "Marv";
domEvents.dispatch(input, "change"); // bindings are updated on "change" by default
assert.equal(nameForm.first, "Marv", "first set correctly");
});
});
// start Mocha
mocha.run();
</script>
This strategy can also be used to test events using listenTo
in a value behavior (or a Map’s listenTo method):
<div id="mocha"></div>
<link rel="stylesheet" href="//unpkg.com/mocha@6/mocha.css" />
<script src="//unpkg.com/mocha@6/mocha.js" type="text/javascript"></script>
<script src="//unpkg.com/chai@4/chai.js" type="text/javascript"></script>
<script type="module">
import { domEvents, StacheElement } from "can";
// Mocha / Chai Setup
mocha.setup("bdd");
const assert = chai.assert;
class Modal extends StacheElement {
static view = `
{{# if(this.showing) }}
<div class="modal">
This is the modal
</div>
{{/ if }}
`;
static props = {
showing: {
value({ lastSet, listenTo, resolve }) {
listenTo(lastSet, resolve);
listenTo(window, "click", () => {
resolve(false);
});
}
}
};
}
customElements.define("my-modal", Modal);
describe("MyModal Component Events", () => {
it("clicking on the window should close the modal", () => {
const modal = new Modal().render();
modal.showing = true;
domEvents.dispatch(window, "click");
assert.equal(
modal.showing,
false,
"modal hidden when user clicks on the window"
);
});
});
// start Mocha
mocha.run();
</script>
Another place you might use listenTo
is in connect. The same testing procedure can be used in this scenario, but you need to make sure connect
is called, which is discussed in the next section.
connected
The connected hook is a good place to put code that is expected to run once a component is in the document. To test this code, the connect method needs to be called. One way to do this is to call it manually:
<div id="mocha"></div>
<link rel="stylesheet" href="//unpkg.com/mocha@6/mocha.css" />
<script src="//unpkg.com/mocha@6/mocha.js" type="text/javascript"></script>
<script src="//unpkg.com/chai@4/chai.js" type="text/javascript"></script>
<script type="module">
import { StacheElement } from "can";
// Mocha / Chai Setup
mocha.setup("bdd");
const assert = chai.assert;
function DatePicker(el) {
this.el = el;
el.classList.add("date-picker");
}
DatePicker.prototype.teardown = function() {
this.el.classList.remove("date-picker");
};
class DateRange extends StacheElement {
static view = `<p class="start-date">This is the Date Picker</p>`;
connected() {
const startDate = new DatePicker(this.querySelector(".start-date"));
return () => {
startDate.teardown();
};
}
}
customElements.define("date-range", DateRange);
describe("DateRange component connected hook", () => {
it("should set up DatePicker", () => {
const dateRange = new DateRange();
dateRange.connect();
const startDate = dateRange.querySelector(".start-date");
assert.ok(
startDate.classList.contains("date-picker"),
"start date DatePicker set up"
);
});
});
// start Mocha
mocha.run();
</script>
If the code relies on the element actually being in the document, you can add the element to the page using appendChild:
<div id="mocha"></div>
<link rel="stylesheet" href="//unpkg.com/mocha@6/mocha.css" />
<script src="//unpkg.com/mocha@6/mocha.js" type="text/javascript"></script>
<script src="//unpkg.com/chai@4/chai.js" type="text/javascript"></script>
<script type="module">
import { StacheElement } from "can";
// Mocha / Chai Setup
mocha.setup("bdd");
const assert = chai.assert;
function DatePicker(el) {
this.el = el;
el.classList.add("date-picker");
}
DatePicker.prototype.teardown = function() {
this.el.classList.remove("date-picker");
};
class DateRange extends StacheElement {
static view = `<p class="start-date">This is the Date Picker</p>`;
connected() {
const startDate = new DatePicker(this.querySelector(".start-date"));
return () => {
startDate.teardown();
};
}
}
customElements.define("date-range", DateRange);
describe("DateRange Component connectedCallback", () => {
it("should set up DatePicker", () => {
const dateRange = new DateRange();
document.body.appendChild(dateRange);
const startDate = dateRange.querySelector(".start-date");
assert.ok(
startDate.classList.contains("date-picker"),
"start date DatePicker set up"
);
// clean up element
document.body.removeChild(document.querySelector("date-range"));
});
});
// start Mocha
mocha.run();
</script>
Note: Some test frameworks like QUnit have special test areas that you insert elements into for your tests. These are automatically cleaned up after each test, so you do not have to worry about a test causing problems for other tests. If the framework you’re using doesn’t have this, make sure to clean up after the test yourself.
Routing
Routing in CanJS applications has three primary responsibilities:
- Connecting a component to can-route
- Displaying the corrent component based on the route
- Passing data to the displayed component
Separating these into three separate properties on the component means that they can each be tested independently. This will be shown in the following sections.
Route data
CanJS’s router uses the observable key-value object can-route.data to bind the URL to a StacheElement. To make this observable available to the StacheElement, you can make a property that returns route.data
its default value:
class Application extends StacheElement {
static props = {
routeData: {
get default() {
return route.data;
}
}
};
}
Most applications also set up “pretty” routes by calling route.register. This can also be done in the default value definition before calling start:
<div id="mocha"></div>
<link rel="stylesheet" href="//unpkg.com/mocha@6/mocha.css" />
<script src="//unpkg.com/mocha@6/mocha.js" type="text/javascript"></script>
<script src="//unpkg.com/chai@4/chai.js" type="text/javascript"></script>
<script type="module">
import { route, RouteMock, StacheElement } from "can/ecosystem";
// Mocha / Chai Setup
mocha.setup("bdd");
const assert = chai.assert;
class Application extends StacheElement {
static view = `{{ this.elementToShow }}`;
static props = {
routeData: {
get default() {
route.register("{page}", { page: "home" });
route.register("list/{id}", { page: "list" });
route.start();
return route.data;
}
}
};
}
customElements.define("app-component", Application);
describe("Application", () => {
it("routeData updates when URL changes", () => {
const routeMock = new RouteMock();
route.urlData = routeMock;
const vm = new Application();
assert.equal(vm.routeData.page, "home", "`page` defaults to 'home'");
routeMock.value = "#!list/5";
assert.equal(vm.routeData.page, "list", "#!list/5 sets `page` to 'list'");
assert.equal(vm.routeData.id, 5, "#!list/5 sets `id` to 5");
});
it("URL updates when routeData changes", done => {
const routeMock = new RouteMock();
route.urlData = routeMock;
const vm = new Application();
assert.equal(routeMock.value, "");
routeMock.on(() => {
assert.equal(routeMock.value, "list/10");
done();
});
vm.routeData.update({
page: "list",
id: 10
});
});
});
// start Mocha
mocha.run();
</script>
Testing this can be difficult because changes to routeData
will also cause changes to the URL. This can cause big problems: if the URL suddenly changes to /list/5
in the middle of running the tests, the test page is no longer going to be functional.
To avoid this, CanJS provides RouteMock so that you can interact with route.data
without actually changing the URL.
After setting urlData to an instance of RouteMock
, you can make changes to the value
of the RouteMock
instance to simulate changes to the URL and then verify that the StacheElement’s routeData
property updates correctly:
<div id="mocha"></div>
<link rel="stylesheet" href="//unpkg.com/mocha@6/mocha.css" />
<script src="//unpkg.com/mocha@6/mocha.js" type="text/javascript"></script>
<script src="//unpkg.com/chai@4/chai.js" type="text/javascript"></script>
<script type="module">
import { route, RouteMock, StacheElement } from "can/ecosystem";
// Mocha / Chai Setup
mocha.setup("bdd");
const assert = chai.assert;
class Application extends StacheElement {
static view = `{{ this.elementToShow }}`;
static props = {
routeData: {
get default() {
route.register("{page}", { page: "home" });
route.register("list/{id}", { page: "list" });
route.start();
return route.data;
}
}
};
}
customElements.define("app-component", Application);
describe("Application", () => {
it("routeData updates when URL changes", () => {
const routeMock = new RouteMock();
route.urlData = routeMock;
const vm = new Application();
assert.equal(vm.routeData.page, "home", "`page` defaults to 'home'");
routeMock.value = "#!list/5";
assert.equal(vm.routeData.page, "list", "#!list/5 sets `page` to 'list'");
assert.equal(vm.routeData.id, 5, "#!list/5 sets `id` to 5");
});
it("URL updates when routeData changes", done => {
const routeMock = new RouteMock();
route.urlData = routeMock;
const vm = new Application();
assert.equal(routeMock.value, "");
routeMock.on(() => {
assert.equal(routeMock.value, "list/10");
done();
});
vm.routeData.update({
page: "list",
id: 10
});
});
});
// start Mocha
mocha.run();
</script>
You can also make changes to the routeData
and check that the URL is updated correctly by verifying the value
of the RouteMock
instance. In CanJS, the URL is changed asynchronously, so you will need to use an asynchronous test that uses routeMock.on
to determine when to run assertions:
<div id="mocha"></div>
<link rel="stylesheet" href="//unpkg.com/mocha@6/mocha.css" />
<script src="//unpkg.com/mocha@6/mocha.js" type="text/javascript"></script>
<script src="//unpkg.com/chai@4/chai.js" type="text/javascript"></script>
<script type="module">
import { route, RouteMock, StacheElement } from "can/ecosystem";
// Mocha / Chai Setup
mocha.setup("bdd");
const assert = chai.assert;
class Application extends StacheElement {
static view = `{{ this.elementToShow }}`;
static props = {
routeData: {
get default() {
route.register("{page}", { page: "home" });
route.register("list/{id}", { page: "list" });
route.start();
return route.data;
}
}
};
}
customElements.define("app-component", Application);
describe("Application", () => {
it("routeData updates when URL changes", () => {
const routeMock = new RouteMock();
route.urlData = routeMock;
const vm = new Application();
assert.equal(vm.routeData.page, "home", "`page` defaults to 'home'");
routeMock.value = "#!list/5";
assert.equal(vm.routeData.page, "list", "#!list/5 sets `page` to 'list'");
assert.equal(vm.routeData.id, 5, "#!list/5 sets `id` to 5");
});
it("URL updates when routeData changes", done => {
const routeMock = new RouteMock();
route.urlData = routeMock;
const vm = new Application();
assert.equal(routeMock.value, "");
routeMock.on(() => {
assert.equal(routeMock.value, "list/10");
done();
});
vm.routeData.update({
page: "list",
id: 10
});
});
});
// start Mocha
mocha.run();
</script>
Displaying the correct component
Testing that the correct component is displayed based on the routeData
can be done completely independently from can-route
when routeData
is defined as a default value as shown above.
The component can be defined using a getter that reads routeData
and creates an instance of the correct type of component:
<div id="mocha"></div>
<link rel="stylesheet" href="//unpkg.com/mocha@6/mocha.css" />
<script src="//unpkg.com/mocha@6/mocha.js" type="text/javascript"></script>
<script src="//unpkg.com/chai@4/chai.js" type="text/javascript"></script>
<script type="module">
import { ObservableObject, route, StacheElement } from "can";
// Mocha / Chai Setup
mocha.setup("bdd");
const assert = chai.assert;
class HomePage extends StacheElement {
static view = `<h2>Home Page</h2>`;
}
customElements.define("home-page", HomePage);
class ListPage extends StacheElement {
static view = `<h2>List Page</h2>`;
}
customElements.define("list-page", ListPage);
class Application extends StacheElement {
static view = `{{ this.elementToShow }}`;
static props = {
get elementToShow() {
if (this.routeData.page === "home") {
return new HomePage();
} else if (this.routeData.page === "list") {
return new ListPage();
}
},
routeData: {
get default() {
route.register("{page}", { page: "home" });
route.register("list/{id}", { page: "list" });
route.start();
return route.data;
}
}
};
}
customElements.define("app-component", Application);
describe("Application", () => {
it("elementToShow", () => {
const routeData = new ObservableObject({
id: null,
page: "home"
});
const app = new Application().initialize({
routeData: routeData
});
assert.ok(
app.elementToShow instanceof HomePage,
"ListPage shown when routeData.page === 'home'"
);
routeData.page = "list";
assert.ok(
app.elementToShow instanceof ListPage,
"ListPage shown when routeData.page === 'list'"
);
});
});
// start Mocha
mocha.run();
</script>
In order to test this, create an observable and pass it to the ObservableObject constructor as the routeData
property:
<div id="mocha"></div>
<link rel="stylesheet" href="//unpkg.com/mocha@6/mocha.css" />
<script src="//unpkg.com/mocha@6/mocha.js" type="text/javascript"></script>
<script src="//unpkg.com/chai@4/chai.js" type="text/javascript"></script>
<script type="module">
import { ObservableObject, route, StacheElement } from "can";
// Mocha / Chai Setup
mocha.setup("bdd");
const assert = chai.assert;
class HomePage extends StacheElement {
static view = `<h2>Home Page</h2>`;
}
customElements.define("home-page", HomePage);
class ListPage extends StacheElement {
static view = `<h2>List Page</h2>`;
}
customElements.define("list-page", ListPage);
class Application extends StacheElement {
static view = `{{ this.elementToShow }}`;
static props = {
get elementToShow() {
if (this.routeData.page === "home") {
return new HomePage();
} else if (this.routeData.page === "list") {
return new ListPage();
}
},
routeData: {
get default() {
route.register("{page}", { page: "home" });
route.register("list/{id}", { page: "list" });
route.start();
return route.data;
}
}
};
}
customElements.define("app-component", Application);
describe("Application", () => {
it("elementToShow", () => {
const routeData = new ObservableObject({
id: null,
page: "home"
});
const app = new Application().initialize({
routeData: routeData
});
assert.ok(
app.elementToShow instanceof HomePage,
"ListPage shown when routeData.page === 'home'"
);
routeData.page = "list";
assert.ok(
app.elementToShow instanceof ListPage,
"ListPage shown when routeData.page === 'list'"
);
});
});
// start Mocha
mocha.run();
</script>
This will override what is set up in the default() {}
and allow you to make changes to the routeData
object and verify that the correct component is created:
<div id="mocha"></div>
<link rel="stylesheet" href="//unpkg.com/mocha@6/mocha.css" />
<script src="//unpkg.com/mocha@6/mocha.js" type="text/javascript"></script>
<script src="//unpkg.com/chai@4/chai.js" type="text/javascript"></script>
<script type="module">
import { ObservableObject, route, StacheElement } from "can";
// Mocha / Chai Setup
mocha.setup("bdd");
const assert = chai.assert;
class HomePage extends StacheElement {
static view = `<h2>Home Page</h2>`;
}
customElements.define("home-page", HomePage);
class ListPage extends StacheElement {
static view = `<h2>List Page</h2>`;
}
customElements.define("list-page", ListPage);
class Application extends StacheElement {
static view = `{{ this.elementToShow }}`;
static props = {
get elementToShow() {
if (this.routeData.page === "home") {
return new HomePage();
} else if (this.routeData.page === "list") {
return new ListPage();
}
},
routeData: {
get default() {
route.register("{page}", { page: "home" });
route.register("list/{id}", { page: "list" });
route.start();
return route.data;
}
}
};
}
customElements.define("app-component", Application);
describe("Application", () => {
it("elementToShow", () => {
const routeData = new ObservableObject({
id: null,
page: "home"
});
const app = new Application().initialize({
routeData: routeData
});
assert.ok(
app.elementToShow instanceof HomePage,
"ListPage shown when routeData.page === 'home'"
);
routeData.page = "list";
assert.ok(
app.elementToShow instanceof ListPage,
"ListPage shown when routeData.page === 'list'"
);
});
});
// start Mocha
mocha.run();
</script>
Passing data to the component
Data that needs to be passed to the component being displayed can also be tested independently if it is created as a separate property on the ObservableObject that is derived from the routeData
property:
<div id="mocha"></div>
<link rel="stylesheet" href="//unpkg.com/mocha@6/mocha.css" />
<script src="//unpkg.com/mocha@6/mocha.js" type="text/javascript"></script>
<script src="//unpkg.com/chai@4/chai.js" type="text/javascript"></script>
<script type="module">
import { ObservableObject, route, StacheElement, type, value } from "can";
// Mocha / Chai Setup
mocha.setup("bdd");
const assert = chai.assert;
class HomePage extends StacheElement {
static view = `<h2>Home Page</h2>`;
}
customElements.define("home-page", HomePage);
class ListPage extends StacheElement {
static view = `
<h2>List Page</h2>
<p>{{ this.id }}</p>
`;
static props = {
id: Number
};
}
customElements.define("list-page", ListPage);
class Application extends StacheElement {
static view = `{{ this.elementToShow }}`;
static props = {
get elementToShow() {
if (this.routeData.page === "home") {
return new HomePage();
} else if (this.routeData.page === "list") {
return new ListPage().bindings(this.elementToShowBindings);
}
},
get elementToShowBindings() {
const appData = {};
if (this.routeData.page === "list") {
appData.id = value.bind(this.routeData, "id");
}
return appData;
},
routeData: {
get default() {
route.register("{page}", { page: "home" });
route.register("list/{id}", { page: "list" });
route.start();
return route.data;
}
}
};
}
customElements.define("app-component", Application);
describe("Application", () => {
it("elementToShow component", () => {
const routeData = new ObservableObject({
id: null,
page: "home"
});
const app = new Application().initialize({
routeData: routeData
});
assert.deepEqual(
app.elementToShowBindings,
{},
"elementToShowBindings defaults to empty object"
);
routeData.update({
id: 10,
page: "list"
});
const idBinding = app.elementToShowBindings.id;
assert.equal(
idBinding.value,
10,
"routeData.id is passed to elementToShow component"
);
routeData.id = 20;
assert.equal(
idBinding.value,
20,
"setting routeData.id updates the elementToShowBindings.id"
);
idBinding.value = 30;
assert.equal(
routeData.id,
30,
"setting elementToShowBindings.id updates routeData.id"
);
});
});
// start Mocha
mocha.run();
</script>
With the component data set up like this, you can make changes to routeData
and confirm that the child component will get the correct values by verifying the value
of the observable passed through the elementToShowBindings
:
<div id="mocha"></div>
<link rel="stylesheet" href="//unpkg.com/mocha@6/mocha.css" />
<script src="//unpkg.com/mocha@6/mocha.js" type="text/javascript"></script>
<script src="//unpkg.com/chai@4/chai.js" type="text/javascript"></script>
<script type="module">
import { ObservableObject, route, StacheElement, type, value } from "can";
// Mocha / Chai Setup
mocha.setup("bdd");
const assert = chai.assert;
class HomePage extends StacheElement {
static view = `<h2>Home Page</h2>`;
}
customElements.define("home-page", HomePage);
class ListPage extends StacheElement {
static view = `
<h2>List Page</h2>
<p>{{ this.id }}</p>
`;
static props = {
id: Number
};
}
customElements.define("list-page", ListPage);
class Application extends StacheElement {
static view = `{{ this.elementToShow }}`;
static props = {
get elementToShow() {
if (this.routeData.page === "home") {
return new HomePage();
} else if (this.routeData.page === "list") {
return new ListPage().bindings(this.elementToShowBindings);
}
},
get elementToShowBindings() {
const appData = {};
if (this.routeData.page === "list") {
appData.id = value.bind(this.routeData, "id");
}
return appData;
},
routeData: {
get default() {
route.register("{page}", { page: "home" });
route.register("list/{id}", { page: "list" });
route.start();
return route.data;
}
}
};
}
customElements.define("app-component", Application);
describe("Application", () => {
it("elementToShow component", () => {
const routeData = new ObservableObject({
id: null,
page: "home"
});
const app = new Application().initialize({
routeData: routeData
});
assert.deepEqual(
app.elementToShowBindings,
{},
"elementToShowBindings defaults to empty object"
);
routeData.update({
id: 10,
page: "list"
});
const idBinding = app.elementToShowBindings.id;
assert.equal(
idBinding.value,
10,
"routeData.id is passed to elementToShow component"
);
routeData.id = 20;
assert.equal(
idBinding.value,
20,
"setting routeData.id updates the elementToShowBindings.id"
);
idBinding.value = 30;
assert.equal(
routeData.id,
30,
"setting elementToShowBindings.id updates routeData.id"
);
});
});
// start Mocha
mocha.run();
</script>
You can also set the value
of the properties of elementToShowBindings
and verify that the routeData
is updated correctly:
<div id="mocha"></div>
<link rel="stylesheet" href="//unpkg.com/mocha@6/mocha.css" />
<script src="//unpkg.com/mocha@6/mocha.js" type="text/javascript"></script>
<script src="//unpkg.com/chai@4/chai.js" type="text/javascript"></script>
<script type="module">
import { ObservableObject, route, StacheElement, type, value } from "can";
// Mocha / Chai Setup
mocha.setup("bdd");
const assert = chai.assert;
class HomePage extends StacheElement {
static view = `<h2>Home Page</h2>`;
}
customElements.define("home-page", HomePage);
class ListPage extends StacheElement {
static view = `
<h2>List Page</h2>
<p>{{ this.id }}</p>
`;
static props = {
id: Number
};
}
customElements.define("list-page", ListPage);
class Application extends StacheElement {
static view = `{{ this.elementToShow }}`;
static props = {
get elementToShow() {
if (this.routeData.page === "home") {
return new HomePage();
} else if (this.routeData.page === "list") {
return new ListPage().bindings(this.elementToShowBindings);
}
},
get elementToShowBindings() {
const appData = {};
if (this.routeData.page === "list") {
appData.id = value.bind(this.routeData, "id");
}
return appData;
},
routeData: {
get default() {
route.register("{page}", { page: "home" });
route.register("list/{id}", { page: "list" });
route.start();
return route.data;
}
}
};
}
customElements.define("app-component", Application);
describe("Application", () => {
it("elementToShow component", () => {
const routeData = new ObservableObject({
id: null,
page: "home"
});
const app = new Application().initialize({
routeData: routeData
});
assert.deepEqual(
app.elementToShowBindings,
{},
"elementToShowBindings defaults to empty object"
);
routeData.update({
id: 10,
page: "list"
});
const idBinding = app.elementToShowBindings.id;
assert.equal(
idBinding.value,
10,
"routeData.id is passed to elementToShow component"
);
routeData.id = 20;
assert.equal(
idBinding.value,
20,
"setting routeData.id updates the elementToShowBindings.id"
);
idBinding.value = 30;
assert.equal(
routeData.id,
30,
"setting elementToShowBindings.id updates routeData.id"
);
});
});
// start Mocha
mocha.run();
</script>
Models
CanJS models like can-rest-model and can-realtime-rest-model allow you to connect an observable to a service layer. They also provide caching and real-time behavior using can-query-logic. The following sections will show how to test that these models are set up correctly to work with the application’s service layer.
Connections
CanJS models work as mixins to add methods like get and getList to CanJS observables. You can use can-fixture to test these methods without making real requests to your service layer; can-fixture
will intercept the request made by the connection and simulate a response using the data given by the fixture.
A basic test setup using this approach looks like:
- Create sample data
- Create a fixture to return sample data for a specific URL
- Call model function to request data from that URL
- Verify the model returned the sample data
Here is an example:
<div id="mocha"></div>
<link rel="stylesheet" href="//unpkg.com/mocha@6/mocha.css" />
<script src="//unpkg.com/mocha@6/mocha.js" type="text/javascript"></script>
<script src="//unpkg.com/chai@4/chai.js" type="text/javascript"></script>
<script type="module">
import {
fixture,
ObservableArray,
ObservableObject,
restModel,
type
} from "can";
// Mocha / Chai Setup
mocha.setup("bdd");
const assert = chai.assert;
class Todo extends ObservableObject {
static props = {
id: Number,
complete: Boolean,
name: String
};
}
class TodoList extends ObservableArray {
static items = type.convert(Todo);
}
Todo.connection = restModel({
ArrayType: TodoList,
ObjectType: Todo,
url: "/api/todos/{id}"
});
describe("TodoModel", () => {
it("getList", done => {
// 1. Create sample data
const todos = [{ id: 1, complete: false, name: "do dishes" }];
// 2. Create a fixture to return sample data for a specific URL
fixture({ url: "/api/todos" }, todos);
// 3. Call model function to request data from that URL
Todo.getList().then(todosList => {
// 4. Verify the model returned the sample data
assert.deepEqual(todosList.serialize(), todos);
done();
});
});
});
// start Mocha
mocha.run();
</script>
QueryLogic
CanJS model mixins internally use can-query-logic to perform queries of your service layer data and compare different queries against each other. It uses the logic of these queries to understand how to cache data and provide real-time behavior.
It can be useful to test this logic to ensure that it will work correctly when used for these other behaviors. It is also very useful to add tests like this when you run into an issue with your model not working as expected.
One useful way to do this is to use filterMembers to verify that a specific query will correctly filter an array of data:
<div id="mocha"></div>
<link rel="stylesheet" href="//unpkg.com/mocha@6/mocha.css" />
<script src="//unpkg.com/mocha@6/mocha.js" type="text/javascript"></script>
<script src="//unpkg.com/chai@4/chai.js" type="text/javascript"></script>
<script type="module">
import {
ObservableArray,
ObservableObject,
QueryLogic,
restModel,
type
} from "can";
// Mocha / Chai Setup
mocha.setup("bdd");
const assert = chai.assert;
class Todo extends ObservableObject {
static props = {
id: Number,
complete: Boolean,
name: String
};
}
class TodoList extends ObservableArray {
static items = type.convert(Todo);
}
Todo.connection = restModel({
ArrayType: TodoList,
ObjectType: Todo,
url: "/api/todos/{id}"
});
describe("TodoModel query logic", () => {
it("filterMembers", () => {
const todoQueryLogic = new QueryLogic(Todo);
const completeTodos = [{ id: 2, name: "mow lawn", complete: true }];
const incompleteTodos = [{ id: 1, name: "do dishes", complete: false }];
const allTodos = [...completeTodos, ...incompleteTodos];
const completeTodosFilter = { filter: { complete: false } };
const queryLogicIncompleteTodos = todoQueryLogic.filterMembers(
completeTodosFilter,
allTodos
);
assert.deepEqual(queryLogicIncompleteTodos, incompleteTodos);
});
it("isMember", () => {
const todoQueryLogic = new QueryLogic(Todo);
const completeTodosFilter = { filter: { complete: false } };
const becomingAnAstronautIsIncomplete = todoQueryLogic.isMember(
completeTodosFilter,
{ id: 5, name: "become an astronaut", complete: false }
);
assert.ok(becomingAnAstronautIsIncomplete);
});
});
// start Mocha
mocha.run();
</script>
It can also be useful to use isMember to verify that a specific record is contained within the results of a query:
<div id="mocha"></div>
<link rel="stylesheet" href="//unpkg.com/mocha@6/mocha.css" />
<script src="//unpkg.com/mocha@6/mocha.js" type="text/javascript"></script>
<script src="//unpkg.com/chai@4/chai.js" type="text/javascript"></script>
<script type="module">
import {
ObservableArray,
ObservableObject,
QueryLogic,
restModel,
type
} from "can";
// Mocha / Chai Setup
mocha.setup("bdd");
const assert = chai.assert;
class Todo extends ObservableObject {
static props = {
id: Number,
complete: Boolean,
name: String
};
}
class TodoList extends ObservableArray {
static items = type.convert(Todo);
}
Todo.connection = restModel({
ArrayType: TodoList,
ObjectType: Todo,
url: "/api/todos/{id}"
});
describe("TodoModel query logic", () => {
it("filterMembers", () => {
const todoQueryLogic = new QueryLogic(Todo);
const completeTodos = [{ id: 2, name: "mow lawn", complete: true }];
const incompleteTodos = [{ id: 1, name: "do dishes", complete: false }];
const allTodos = [...completeTodos, ...incompleteTodos];
const completeTodosFilter = { filter: { complete: false } };
const queryLogicIncompleteTodos = todoQueryLogic.filterMembers(
completeTodosFilter,
allTodos
);
assert.deepEqual(queryLogicIncompleteTodos, incompleteTodos);
});
it("isMember", () => {
const todoQueryLogic = new QueryLogic(Todo);
const completeTodosFilter = { filter: { complete: false } };
const becomingAnAstronautIsIncomplete = todoQueryLogic.isMember(
completeTodosFilter,
{ id: 5, name: "become an astronaut", complete: false }
);
assert.ok(becomingAnAstronautIsIncomplete);
});
});
// start Mocha
mocha.run();
</script>
Integration Testing
Integration testing is designed to test multiple units of an application to make sure they work together.
There are a few things that make writing and maintaining integration tests more costly than unit tests:
- Functional tests usually take longer to write because they require an understanding of a larger portion of the application
- Functional tests take longer to run because of the time it takes to render and interact with the DOM
- Functional tests often need to be updated when the structure of an application’s HTML and CSS changes
For these reasons, you may not want to write integration tests for every feature of an application. That being said, integration tests of an application’s most important functionality are very valuable. Also, for applications with no tests at all, adding integration tests before making big changes (like large upgrades, etc) can make it much easier to verify that the app is still functioning after the changes are in place.
No matter the purpose of the integration test, they generally follow the same pattern:
- Render an application
- Verify that the application rendered correctly
- Simulate user interaction
- Verify that the application responds correctly
- Clean up
Note: The test below is written using FuncUnit but it would also work with cypress.io, dom-testing-library, or whatever integration testing setup you prefer.
<div id="mocha"></div>
<link rel="stylesheet" href="//unpkg.com/mocha@6/mocha.css" />
<script src="//unpkg.com/mocha@6/mocha.js" type="text/javascript"></script>
<script src="//unpkg.com/chai@4/chai.js" type="text/javascript"></script>
<script src="//unpkg.com/jquery@3/dist/jquery.js"></script>
<script src="//unpkg.com/funcunit@3/dist/funcunit.js"></script>
<script type="module">
import {
domEvents,
enterEvent,
fixture,
ObservableArray,
ObservableObject,
realtimeRestModel,
route,
StacheElement,
type
} from "can/ecosystem";
// Mocha / Chai / Funcunit Setup
mocha.setup("bdd");
const assert = chai.assert;
domEvents.addEvent(enterEvent);
class Todo extends ObservableObject {
static props = {
id: { type: Number, identity: true },
name: String,
complete: { type: Boolean, default: false }
};
}
const todoStore = fixture.store(
[
{ name: "Learn CanJS", complete: true, id: 7 },
{ name: "Write tests", complete: false, id: 8 }
],
Todo
);
fixture("/api/todos", todoStore);
fixture.delay = 500;
class TodoListModel extends ObservableArray {
static items = type.convert(Todo);
static props = {
get active() {
return this.filter({ complete: false });
},
get allComplete() {
return this.length === this.complete.length;
},
get complete() {
return this.filter({ complete: true });
},
get saving() {
return this.filter(todo => {
return todo.isSaving();
});
}
};
destroyComplete() {
this.complete.forEach(todo => {
todo.destroy();
});
}
updateCompleteTo(value) {
this.forEach(todo => {
todo.complete = value;
todo.save();
});
}
}
Todo.connection = realtimeRestModel({
ArrayType: TodoListModel,
ObjectType: Todo,
url: "/api/todos"
});
class TodoCreate extends StacheElement {
static view = `
<input id="new-todo"
placeholder="What needs to be done?"
value:bind="this.todo.name"
on:enter="this.createTodo()" />
`;
static props = {
todo: {
get default() {
return new Todo();
}
}
};
createTodo() {
this.todo.save().then(() => {
this.todo = new Todo();
});
}
}
customElements.define("todo-create", TodoCreate);
class TodoList extends StacheElement {
static view = `
<ul id="todo-list">
{{# for(todo of this.todos) }}
<li class="todo {{# if(todo.complete) }}completed{{/ if }}
{{# if( todo.isDestroying() )}}destroying{{/ if }}
{{# if( this.isEditing(todo) ) }}editing{{/ if }}">
<div class="view">
<input class="toggle" type="checkbox" checked:bind="todo.complete">
<label on:dblclick="this.edit(todo)">{{ todo.name }}</label>
<button class="destroy" on:click="todo.destroy()"></button>
</div>
<input class="edit" type="text"
default:bind="todo.name"
on:enter="this.updateName()"
focused:from="this.isEditing(todo)"
on:blur="this.cancelEdit()" />
</li>
{{/ for }}
</ul>
`;
static props = {
backupName: String,
editing: Todo,
todos: TodoListModel
};
cancelEdit() {
if (this.editing) {
this.editing.name = this.backupName;
}
this.editing = null;
}
edit(todo) {
this.backupName = todo.name;
this.editing = todo;
}
isEditing(todo) {
return todo === this.editing;
}
updateName() {
this.editing.save();
this.editing = null;
}
}
customElements.define("todo-list", TodoList);
class TodoMvc extends StacheElement {
static view = `
<section id="todoapp">
<header id="header">
<h1>todos</h1>
<todo-create />
</header>
<section id="main">
<input id="toggle-all" type="checkbox"
checked:bind="this.allChecked"
disabled:from="this.todosList.saving.length" />
<label for="toggle-all">Mark all as complete</label>
{{# if(this.todosPromise.isResolved) }}
<todo-list todos:from="todosPromise.value" />
{{/ if }}
</section>
{{# if(this.todosPromise.isResolved) }}
<footer id="footer">
<span id="todo-count">
<strong>{{ this.todosPromise.value.active.length }}</strong> items left
</span>
<ul id="filters">
<li>
<a href="{{ routeUrl(filter=undefined) }}"
{{# routeCurrent(filter=undefined) }}class='selected'{{/ routeCurrent }}>All</a>
</li>
<li>
<a href="{{ routeUrl(filter='active') }}"
{{# routeCurrent(filter='active') }}class='selected'{{/ routeCurrent }}>Active</a>
</li>
<li>
<a href="{{ routeUrl(filter='complete') }}"
{{# routeCurrent(filter='complete') }}class='selected'{{/ routeCurrent }}>Completed</a>
</li>
</ul>
<button id="clear-completed"
on:click="this.todosList.destroyComplete()">
Clear completed ({{ this.todosPromise.value.complete.length }})
</button>
</footer>
{{/if}}
</section>
`;
static props = {
get allChecked() {
return this.todosList && this.todosList.allComplete;
},
set allChecked(newVal) {
this.todosList && this.todosList.updateCompleteTo(newVal);
},
routeData: {
get default() {
route.register("{filter}");
route.start();
return route.data;
}
},
todosArrayType: {
async: function(resolve, lastSetValue) {
this.todosPromise.then(resolve);
}
},
get todosPromise() {
if (!this.routeData.filter) {
return Todo.getList({});
} else {
return Todo.getList({
filter: { complete: this.routeData.filter === "complete" }
});
}
}
};
}
customElements.define("todo-mvc", TodoMvc);
describe("Application Integration Tests", () => {
let app = null;
beforeEach(() => {
// 1. Render an application
app = document.createElement("todo-mvc");
document.body.appendChild(app);
});
afterEach(() => {
// 5. Clean up
document.body.removeChild(app);
localStorage.clear();
});
it("Todo list", done => {
// 2. Verify that the application rendered correctly
F("todo-mvc li.todo").size(2, "one todo loaded from server");
// 3. Simulate user interaction(s)
F("todo-mvc #new-todo").type("Profit\r");
// 4. Verify that the application responds correctly
F("todo-mvc li.todo").size(3, "new todo added");
// 3. Simulate user interaction(s)
F("todo-mvc #clear-completed").click();
// 4. Verify that the application responds correctly
F("todo-mvc #todo-count strong")
.text(2, "completed todos cleared")
.then(() => done());
}).timeout(10000);
});
// start Mocha
mocha.run();
</script>
<style>
html,
body {
margin: 0;
padding: 0;
}
button {
margin: 0;
padding: 0;
border: 0;
background: none;
font-size: 100%;
vertical-align: baseline;
font-family: inherit;
color: inherit;
-webkit-appearance: none;
/*-moz-appearance: none;*/
-ms-appearance: none;
-o-appearance: none;
appearance: none;
}
todo-mvc {
font: 14px "Helvetica Neue", Helvetica, Arial, sans-serif;
line-height: 1.4em;
background: #eaeaea url("../../experiment/todomvc/bg.png");
color: #4d4d4d;
width: 550px;
margin: 0 auto;
-webkit-font-smoothing: antialiased;
-moz-font-smoothing: antialiased;
-ms-font-smoothing: antialiased;
-o-font-smoothing: antialiased;
font-smoothing: antialiased;
}
#todoapp {
background: #fff;
background: rgba(255, 255, 255, 0.9);
margin: 130px 0 40px 0;
border: 1px solid #ccc;
position: relative;
border-top-left-radius: 2px;
border-top-right-radius: 2px;
box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.2),
0 25px 50px 0 rgba(0, 0, 0, 0.15);
}
#todoapp:before {
content: "";
border-left: 1px solid #f5d6d6;
border-right: 1px solid #f5d6d6;
width: 2px;
position: absolute;
top: 0;
left: 40px;
height: 100%;
}
#todoapp input::-webkit-input-placeholder {
font-style: italic;
}
#todoapp input::-moz-placeholder {
font-style: italic;
color: #a9a9a9;
}
#todoapp h1 {
position: absolute;
top: -120px;
width: 100%;
font-size: 70px;
font-weight: bold;
text-align: center;
color: #b3b3b3;
color: rgba(255, 255, 255, 0.3);
text-shadow: -1px -1px rgba(0, 0, 0, 0.2);
-webkit-text-rendering: optimizeLegibility;
-moz-text-rendering: optimizeLegibility;
-ms-text-rendering: optimizeLegibility;
-o-text-rendering: optimizeLegibility;
text-rendering: optimizeLegibility;
}
#header {
padding-top: 15px;
border-radius: inherit;
}
#header:before {
content: "";
position: absolute;
top: 0;
right: 0;
left: 0;
height: 15px;
z-index: 2;
border-bottom: 1px solid #6c615c;
background: #8d7d77;
background: -webkit-gradient(
linear,
left top,
left bottom,
from(rgba(132, 110, 100, 0.8)),
to(rgba(101, 84, 76, 0.8))
);
background: -webkit-linear-gradient(
top,
rgba(132, 110, 100, 0.8),
rgba(101, 84, 76, 0.8)
);
background: -moz-linear-gradient(
top,
rgba(132, 110, 100, 0.8),
rgba(101, 84, 76, 0.8)
);
background: -o-linear-gradient(
top,
rgba(132, 110, 100, 0.8),
rgba(101, 84, 76, 0.8)
);
background: -ms-linear-gradient(
top,
rgba(132, 110, 100, 0.8),
rgba(101, 84, 76, 0.8)
);
background: linear-gradient(
top,
rgba(132, 110, 100, 0.8),
rgba(101, 84, 76, 0.8)
);
filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr='#9d8b83', EndColorStr='#847670');
border-top-left-radius: 1px;
border-top-right-radius: 1px;
}
#new-todo,
.edit {
position: relative;
margin: 0;
width: 100%;
font-size: 24px;
font-family: inherit;
line-height: 1.4em;
border: 0;
outline: none;
color: inherit;
padding: 6px;
border: 1px solid #999;
box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
-ms-box-sizing: border-box;
-o-box-sizing: border-box;
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-font-smoothing: antialiased;
-ms-font-smoothing: antialiased;
-o-font-smoothing: antialiased;
font-smoothing: antialiased;
}
#new-todo {
padding: 16px 16px 16px 60px;
border: none;
background: rgba(0, 0, 0, 0.02);
z-index: 2;
box-shadow: none;
}
#main {
position: relative;
z-index: 2;
border-top: 1px dotted #adadad;
}
label[for="toggle-all"] {
display: none;
}
#toggle-all {
position: absolute;
top: -42px;
left: -4px;
width: 40px;
text-align: center;
border: none; /* Mobile Safari */
}
#toggle-all:before {
content: ">";
font-size: 28px;
color: #d9d9d9;
padding: 0 25px 7px;
}
#toggle-all:checked:before {
color: #737373;
}
#todo-list {
margin: 0;
padding: 0;
list-style: none;
}
#todo-list li {
position: relative;
font-size: 24px;
border-bottom: 1px dotted #ccc;
}
#todo-list li:last-child {
border-bottom: none;
}
#todo-list li.saving {
font-style: italic;
}
#todoapp #todo-list li.destroying label {
font-style: italic;
color: #a88a8a;
}
#todo-list li.editing {
border-bottom: none;
padding: 0;
}
#todo-list li.editing .edit {
display: block;
width: 506px;
padding: 13px 17px 12px 17px;
margin: 0 0 0 43px;
}
#todo-list li.editing .view {
display: none;
}
#todo-list li .toggle {
text-align: center;
width: 40px;
/* auto, since non-WebKit browsers doesn’t support input styling */
height: auto;
position: absolute;
top: 0;
bottom: 0;
margin: auto 0;
border: none; /* Mobile Safari */
-webkit-appearance: none;
/*-moz-appearance: none;*/
-ms-appearance: none;
-o-appearance: none;
appearance: none;
}
#todo-list li .toggle:after {
content: "\2713";
line-height: 43px; /* 40 + a couple of pixels visual adjustment */
font-size: 20px;
color: #d9d9d9;
text-shadow: 0 -1px 0 #bfbfbf;
}
#todo-list li .toggle:checked:after {
color: #85ada7;
text-shadow: 0 1px 0 #669991;
bottom: 1px;
position: relative;
}
#todo-list li label {
word-break: break-word;
padding: 15px;
margin-left: 45px;
display: block;
line-height: 1.2;
-webkit-transition: color 0.4s;
-moz-transition: color 0.4s;
-ms-transition: color 0.4s;
-o-transition: color 0.4s;
transition: color 0.4s;
}
#todo-list li.completed label {
color: #a9a9a9;
text-decoration: line-through;
}
#todo-list li .destroy {
display: none;
position: absolute;
top: 0;
right: 10px;
bottom: 0;
width: 40px;
height: 40px;
margin: auto 0;
font-size: 22px;
color: #a88a8a;
-webkit-transition: all 0.2s;
-moz-transition: all 0.2s;
-ms-transition: all 0.2s;
-o-transition: all 0.2s;
transition: all 0.2s;
}
#todo-list li .destroy:hover {
text-shadow: 0 0 1px #000, 0 0 10px rgba(199, 107, 107, 0.8);
-webkit-transform: scale(1.3);
-moz-transform: scale(1.3);
-ms-transform: scale(1.3);
-o-transform: scale(1.3);
transform: scale(1.3);
}
#todo-list li .destroy:after {
content: "x";
}
#todo-list li:hover .destroy {
display: block;
}
#todo-list li .edit {
display: none;
}
#todo-list li.editing:last-child {
margin-bottom: -1px;
}
#footer {
color: #777;
padding: 0 15px;
position: absolute;
right: 0;
bottom: -31px;
left: 0;
height: 20px;
z-index: 1;
text-align: center;
}
#footer:before {
content: "";
position: absolute;
right: 0;
bottom: 31px;
left: 0;
height: 50px;
z-index: -1;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.3),
0 6px 0 -3px rgba(255, 255, 255, 0.8), 0 7px 1px -3px rgba(0, 0, 0, 0.3),
0 43px 0 -6px rgba(255, 255, 255, 0.8), 0 44px 2px -6px rgba(0, 0, 0, 0.2);
}
#todo-count {
float: left;
text-align: left;
}
#filters {
margin: 0;
padding: 0;
list-style: none;
position: absolute;
right: 0;
left: 0;
}
#filters li {
display: inline;
}
#filters li a {
color: #83756f;
margin: 2px;
text-decoration: none;
}
#filters li a.selected {
font-weight: bold;
}
#clear-completed {
float: right;
position: relative;
line-height: 20px;
text-decoration: none;
background: rgba(0, 0, 0, 0.1);
font-size: 11px;
padding: 0 10px;
border-radius: 3px;
box-shadow: 0 -1px 0 0 rgba(0, 0, 0, 0.2);
}
#clear-completed:hover {
background: rgba(0, 0, 0, 0.15);
box-shadow: 0 -1px 0 0 rgba(0, 0, 0, 0.3);
}
#info {
margin: 65px auto 0;
color: #a6a6a6;
font-size: 12px;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.7);
text-align: center;
}
#info a {
color: inherit;
}
/*
Hack to remove background from Mobile Safari.
Can’t use it globally since it destroys checkboxes in Firefox and Opera
*/
@media screen and (-webkit-min-device-pixel-ratio: 0) {
#toggle-all,
#todo-list li .toggle {
background: none;
}
#todo-list li .toggle {
height: 40px;
}
#toggle-all {
top: -56px;
left: -15px;
width: 65px;
height: 41px;
-webkit-transform: rotate(90deg);
transform: rotate(90deg);
-webkit-appearance: none;
appearance: none;
}
}
.hidden {
display: none;
}
hr {
margin: 20px 0;
border: 0;
border-top: 1px dashed #c5c5c5;
border-bottom: 1px dashed #f7f7f7;
}
.learn a {
font-weight: normal;
text-decoration: none;
color: #b83f45;
}
.learn a:hover {
text-decoration: underline;
color: #787e7e;
}
.learn h3,
.learn h4,
.learn h5 {
margin: 10px 0;
font-weight: 500;
line-height: 1.2;
color: #000;
}
.learn h3 {
font-size: 24px;
}
.learn h4 {
font-size: 18px;
}
.learn h5 {
margin-bottom: 0;
font-size: 14px;
}
.learn ul {
padding: 0;
margin: 0 0 30px 25px;
}
.learn li {
line-height: 20px;
}
.learn p {
font-size: 15px;
font-weight: 300;
line-height: 1.3;
margin-top: 0;
margin-bottom: 0;
}
.quote {
border: none;
margin: 20px 0 60px 0;
}
.quote p {
font-style: italic;
}
.quote p:before {
content: "“";
font-size: 50px;
opacity: 0.15;
position: absolute;
top: -20px;
left: 3px;
}
.quote p:after {
content: "”";
font-size: 50px;
opacity: 0.15;
position: absolute;
bottom: -42px;
right: 3px;
}
.quote footer {
position: absolute;
bottom: -40px;
right: 0;
}
.quote footer img {
border-radius: 3px;
}
.quote footer a {
margin-left: 5px;
vertical-align: middle;
}
.speech-bubble {
position: relative;
padding: 10px;
background: rgba(0, 0, 0, 0.04);
border-radius: 5px;
}
.speech-bubble:after {
content: "";
position: absolute;
top: 100%;
right: 30px;
border: 13px solid transparent;
border-top-color: rgba(0, 0, 0, 0.04);
}
/**body*/
.learn-bar > .learn {
position: absolute;
width: 272px;
top: 8px;
left: -300px;
padding: 10px;
border-radius: 5px;
background-color: rgba(255, 255, 255, 0.6);
transition-property: left;
transition-duration: 500ms;
}
@media (min-width: 899px) {
/**body*/
.learn-bar {
width: auto;
margin: 0 0 0 300px;
}
/**body*/
.learn-bar > .learn {
left: 8px;
}
/**body*/
.learn-bar #todoapp {
width: 550px;
margin: 130px auto 40px auto;
}
}
</style>