Skip to content
End To End Tester

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

javascript
// 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) });
  };
}
javascript
// 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

javascript
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:

javascript
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

javascript
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:

  1. Extract the calculations into plain functions — no observables, no this. Test those first; it is free and it covers the rules.
  2. Inject the services. Replace direct $.ajax calls with a data service passed to the constructor. Now the view model is testable.
  3. Test the view model as above.
  4. 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 ↗
  • TypeScripttypescript/src/web-frameworks/knockoutjs-testing

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?