Testing Knockout.js
Knockout view models are plain objects and observables are plain functions, which makes most of a Knockout application testable with no DOM at all.
2 min read · updated 19 September 2026
Knockout is old and still runs a lot of production software. It is also, for testing purposes, one of the friendliest front-ends there is: a view model is a plain constructor function, an observable is a function you call to read and call with an argument to write, and none of it requires a DOM.
#View models need no framework
// basket-view-model.js
function BasketViewModel(pricingService) {
var self = this;
self.lines = ko.observableArray([]);
self.discountCode = ko.observable('');
self.subtotalCents = ko.computed(function () {
return self.lines().reduce(function (sum, line) {
return sum + line.unitCents * line.quantity();
}, 0);
});
self.discountCents = ko.computed(function () {
return pricingService.discountFor(self.subtotalCents(), self.discountCode());
});
self.totalCents = ko.computed(function () {
return self.subtotalCents() - self.discountCents();
});
self.addLine = function (sku, unitCents, quantity) {
self.lines.push({ sku: sku, unitCents: unitCents, quantity: ko.observable(quantity) });
};
}// basket-view-model.test.js — no DOM, no mounting, microseconds.
describe('BasketViewModel', function () {
var pricing;
beforeEach(function () {
pricing = { discountFor: function () { return 0; } };
});
it('totals the lines', function () {
var vm = new BasketViewModel(pricing);
vm.addLine('book-1', 1200, 2);
vm.addLine('pen-2', 500, 1);
expect(vm.subtotalCents()).toBe(2900);
});
it('recomputes when a quantity changes', function () {
var vm = new BasketViewModel(pricing);
vm.addLine('book-1', 1200, 1);
vm.lines()[0].quantity(3); // writing an observable
expect(vm.subtotalCents()).toBe(3600);
});
it('applies the discount the pricing service returns', function () {
pricing.discountFor = function (subtotal, code) {
return code === 'SUMMER' ? Math.round(subtotal * 0.1) : 0;
};
var vm = new BasketViewModel(pricing);
vm.addLine('book-1', 1000, 2);
vm.discountCode('SUMMER');
expect(vm.discountCents()).toBe(200);
expect(vm.totalCents()).toBe(1800);
});
});Note pricingService is injected. That is the one design change that turns
a hard-to-test Knockout codebase into an easy one — see
dependency injection. A view model that
calls $.ajax directly needs HTTP interception; one that takes a service
needs a two-line stub.
#Subscriptions
it('notifies subscribers when the total changes', function () {
var vm = new BasketViewModel(pricing);
var seen = [];
var subscription = vm.totalCents.subscribe(function (value) { seen.push(value); });
vm.addLine('book-1', 1200, 1);
vm.addLine('pen-2', 500, 1);
expect(seen).toEqual([1200, 1700]);
subscription.dispose(); // always dispose, or the test leaks
});Undisposed subscriptions are the main way a Knockout suite acquires cross-test interference.
#Bindings and the DOM
When the binding itself is what you are testing, jsdom is enough:
it('renders one row per line', function () {
document.body.innerHTML =
'<table><tbody data-bind="foreach: lines">' +
' <tr><td data-bind="text: sku"></td></tr>' +
'</tbody></table>';
var vm = new BasketViewModel(pricing);
vm.addLine('book-1', 1200, 1);
vm.addLine('pen-2', 500, 1);
ko.applyBindings(vm, document.body);
expect(document.querySelectorAll('tbody tr').length).toBe(2);
ko.cleanNode(document.body); // required, or bindings leak into the next test
});ko.cleanNode in teardown is not optional. Without it, the previous test's
bindings remain attached and the next applyBindings throws "You cannot
apply bindings multiple times to the same element".
#Custom binding handlers
ko.bindingHandlers.money = {
update: function (element, valueAccessor) {
var cents = ko.unwrap(valueAccessor());
element.textContent = '£' + (cents / 100).toFixed(2);
}
};
it('formats cents as pounds', function () {
document.body.innerHTML = '<span data-bind="money: totalCents"></span>';
var vm = { totalCents: ko.observable(2795) };
ko.applyBindings(vm, document.body);
expect(document.querySelector('span').textContent).toBe('£27.95');
vm.totalCents(100);
expect(document.querySelector('span').textContent).toBe('£1.00');
ko.cleanNode(document.body);
});#Getting a legacy Knockout app under test
The usual starting position is a view model that does everything: ajax, DOM manipulation, navigation, business rules. The sequence that works:
- Extract the calculations into plain functions — no observables, no
this. Test those first; it is free and it covers the rules. - Inject the services. Replace direct
$.ajaxcalls with a data service passed to the constructor. Now the view model is testable. - Test the view model as above.
- Leave the bindings mostly alone. Cover the handful of custom binding handlers and the one or two screens that matter with a browser test.
That order gets most of the value in the first two steps, which is the general shape of writing testable code applied to a codebase you did not write.
#If you are migrating
Tests written against the rendered DOM — roles, labels, text — survive a migration to React or Vue. Tests written against view models do not. If a migration is in the plan, invest in DOM-level tests for the journeys you must not break, and treat the view-model tests as scaffolding for the work rather than an asset to carry across.
Common questions
- Is Knockout still worth writing tests for?
- If it is running a system that matters, yes — and it is unusually cheap to test, because view models are plain constructor functions with no framework harness required. A Knockout codebase is often the easiest legacy front-end to get under test.
- Do I need a DOM to test Knockout?
- Not for view models, which is where most of the logic is. You need one only for bindings, custom binding handlers and components, and jsdom is sufficient for all three.
- How do I test a computed observable that depends on an async call?
- Inject the data source into the view model rather than calling ajax directly inside it. Then the test supplies a resolved promise or a stub function, and no HTTP interception is needed at all.
Runnable samples for this page
last test results ↗- TypeScript
typescript/src/web-frameworks/knockoutjs-testing
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- DOM TestingTesting the layer every front-end framework produces — queries by role and accessible name, event simulation, jsdom's limits, and why selector choice decides maintainability.
- Testing LibraryThe query API shared across React, Vue, Angular and Svelte — its guiding principle, the queries, user-event, and the mistakes it is designed to prevent.
- Unit TestingWhat a unit actually is, what belongs in a unit test and what does not, and the properties that separate a unit suite people run from one they skip.
- Writing Testable CodeThe properties that make code easy to test — pure cores, injected edges, no hidden state — and the specific smells that make it hard.