Testing Knockout.js Applications — ViewModel and Binding Tests
In this tutorial, you will learn about Testing Knockout.js Applications. We cover key concepts, practical examples, and best practices to help you master this topic.
Testing Knockout.js applications involves Unit Testing ViewModel logic in isolation, verifying computed dependencies, testing custom bindings with real DOM, and validating integration between components.
What You'll Learn
- Unit testing ViewModels and observables
- Testing computed dependencies
- Testing custom binding handlers
- Mocking dependencies and services
- Automating tests with Jasmine and Karma
Why It Matters
Knockout's declarative bindings make UI development fast, but bugs in observables, computed logic, or custom bindings can cause subtle UI failures. Automated tests catch regressions and document expected behavior.
Real-World Use
A CI pipeline that runs 200+ ViewModel tests on every Pull Request, verifying that computed totals are correct, validation rules fire properly, and custom bindings initialize and clean up correctly.
Testing Pyramid
flowchart TD
A[Test Coverage] --> B[Unit Tests: ViewModels]
A --> C[Integration: Bindings]
A --> D[E2E: Full App]
B --> E[Observables]
B --> F[Computeds]
B --> G[Methods]
C --> H[Custom Bindings]
C --> I[Component Rendering]
D --> J[User Flow]
style B fill:#e6f3ff,stroke:#4a90d9,stroke-width:2px
Setting Up the Test Environment
npm install --save-dev jasmine karma karma-jasmine karma-chrome-launcher
Create a test bootstrapper:
// test/bootstrapper.js
var ko = require('knockout');
global.ko = ko;
Unit Testing a ViewModel
// src/viewmodels/counter.js
function CounterViewModel() {
var self = this;
self.count = ko.observable(0);
self.increment = function() {
self.count(self.count() + 1);
};
self.decrement = function() {
if (self.count() > 0) {
self.count(self.count() - 1);
}
};
self.reset = function() {
self.count(0);
};
}
// test/viewmodels/counter.spec.js
describe('CounterViewModel', function() {
var vm;
beforeEach(function() {
vm = new CounterViewModel();
});
it('starts with count 0', function() {
expect(vm.count()).toBe(0);
});
it('increments count by 1', function() {
vm.increment();
expect(vm.count()).toBe(1);
});
it('does not decrement below 0', function() {
vm.decrement();
expect(vm.count()).toBe(0);
});
it('resets to 0', function() {
vm.increment();
vm.increment();
vm.reset();
expect(vm.count()).toBe(0);
});
it('increments multiple times correctly', function() {
vm.increment();
vm.increment();
vm.increment();
expect(vm.count()).toBe(3);
});
});
Expected output: All five tests pass. The ViewModel is tested in complete isolation without any DOM.
Testing Computed Dependencies
// src/viewmodels/cart.js
function CartItem(name, price, quantity) {
this.name = ko.observable(name);
this.price = ko.observable(price);
this.quantity = ko.observable(quantity);
this.total = ko.pureComputed(function() {
return this.price() * this.quantity();
}, this);
}
function CartViewModel() {
var self = this;
self.items = ko.observableArray([]);
self.subtotal = ko.pureComputed(function() {
var total = 0;
self.items().forEach(function(item) {
total += item.total();
});
return total;
});
self.tax = ko.pureComputed(function() {
return self.subtotal() * 0.08;
});
self.total = ko.pureComputed(function() {
return self.subtotal() + self.tax();
});
}
// test/viewmodels/cart.spec.js
describe('CartViewModel', function() {
var cart;
beforeEach(function() {
cart = new CartViewModel();
});
it('starts empty with zero totals', function() {
expect(cart.items().length).toBe(0);
expect(cart.subtotal()).toBe(0);
expect(cart.tax()).toBe(0);
expect(cart.total()).toBe(0);
});
it('calculates subtotal from items', function() {
cart.items.push(new CartItem('Laptop', 1000, 1));
cart.items.push(new CartItem('Mouse', 25, 2));
expect(cart.subtotal()).toBe(1050); // 1000 + 50
});
it('calculates tax as 8% of subtotal', function() {
cart.items.push(new CartItem('Book', 100, 1));
expect(cart.tax()).toBe(8);
});
it('calculates total as subtotal plus tax', function() {
cart.items.push(new CartItem('Item', 200, 1));
expect(cart.total()).toBe(216); // 200 + 16
});
it('updates totals when item quantity changes', function() {
cart.items.push(new CartItem('Item', 50, 1));
expect(cart.subtotal()).toBe(50);
cart.items()[0].quantity(3);
expect(cart.subtotal()).toBe(150);
});
});
Testing Custom Bindings
// src/bindings/textColor.js
ko.bindingHandlers.textColor = {
update: function(element, valueAccessor) {
var color = ko.unwrap(valueAccessor());
element.style.color = color;
}
};
// test/bindings/textColor.spec.js
describe('textColor binding', function() {
var testNode;
beforeEach(function() {
testNode = document.createElement('div');
document.body.appendChild(testNode);
});
afterEach(function() {
document.body.removeChild(testNode);
});
it('sets the text color on the element', function() {
var color = ko.observable('red');
testNode.innerHTML = '<span data-bind="textColor: color">Text</span>';
ko.applyBindings({ color: color }, testNode);
var span = testNode.querySelector('span');
expect(span.style.color).toBe('red');
});
it('updates color when observable changes', function() {
var color = ko.observable('blue');
testNode.innerHTML = '<span data-bind="textColor: color">Text</span>';
ko.applyBindings({ color: color }, testNode);
color('green');
var span = testNode.querySelector('span');
expect(span.style.color).toBe('green');
});
it('handles non-observable values', function() {
testNode.innerHTML = '<span data-bind="textColor: \'purple\'">Text</span>';
ko.applyBindings({}, testNode);
var span = testNode.querySelector('span');
expect(span.style.color).toBe('purple');
});
});
Testing Form Validation
describe('RegistrationViewModel validation', function() {
var vm;
beforeEach(function() {
vm = new RegistrationViewModel();
});
it('has errors when fields are empty', function() {
expect(vm.username.hasError()).toBe(true);
expect(vm.email.hasError()).toBe(true);
expect(vm.formIsValid()).toBe(false);
});
it('becomes valid when all fields are correct', function() {
vm.username('testuser');
vm.email('test@example.com');
vm.password('password123');
vm.confirmPassword('password123');
expect(vm.formIsValid()).toBe(true);
});
it('detects email format errors', function() {
vm.email('invalid-email');
expect(vm.email.hasError()).toBe(true);
expect(vm.email.validationMessage()).toContain('valid email');
});
it('detects password mismatch', function() {
vm.password('password123');
vm.confirmPassword('different');
expect(vm.confirmPassword.hasError()).toBe(true);
});
});
Mocking Dependencies
// Mocking an API service
function mockApiService() {
return {
saveUser: jasmine.createSpy('saveUser').and.returnValue(
Promise.resolve({ id: 1 })
),
checkUsername: jasmine.createSpy('checkUsername').and.returnValue(
Promise.resolve({ available: true })
)
};
}
describe('UserService ViewModel', function() {
var vm, api;
beforeEach(function() {
api = mockApiService();
vm = new UserViewModel(api);
});
it('calls saveUser API on submit', function() {
vm.username('newuser');
vm.email('new@example.com');
vm.submitForm();
expect(api.saveUser).toHaveBeenCalledWith({
username: 'newuser',
email: 'new@example.com'
});
});
});
Common Mistakes
Not resetting the test DOM - Custom binding tests modify the DOM. Always clean up test nodes in
afterEachto prevent cross-test contamination.Testing Knockout internals instead of behavior - Test that the UI updates correctly, not that
subscribewas called. Test the ViewModel's public API and computed values.Forgetting to call ko.cleanNode - After each binding test, call
ko.cleanNode(testNode)to prevent memory leaks and stale event handlers.Testing with global ko settings - If
ko.options.deferUpdatesis set in a test, it affects all subsequent tests. Reset to default inafterEach.Not testing edge cases - Empty arrays, null values, and boundary conditions are common sources of bugs. Test them explicitly.
Practice Questions
- Why should you test ViewModels in isolation from the DOM?
- How do you verify that a computed depends on specific observables?
- What cleanup steps are necessary after testing custom bindings?
- How do you mock an API service for ViewModel tests?
- Why is it important to test the initial state of a ViewModel?
Challenge: Write a complete test suite for a Todo List ViewModel with add, toggle, remove, and clearCompleted methods. Include tests for edge cases (empty todo, adding duplicate items, toggling already-completed items).
FAQ
Mini Project
Write a test suite for a search ViewModel that includes: unit tests for the ViewModel (search query, results array, loading state), tests for a custom debounce extender, and tests for a custom highlight binding that wraps matching text in <mark> tags.
What's Next
Ensure your applications run smoothly. Learn performance optimization techniques for large lists, complex computations, and memory management in Knockout.js.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro