Search code examples
javascriptknockout.jsknockout-3.0

Force Knockout computed to re-evaluate after replacement of observable inside


A co-worker ran into the problem that a computed he wanted to test was not returning the expected output. This happens because we want to stub other computeds (which again are dependent on other computeds). After stubbing there are 0 observables left in the computed and the computed keeps returning the cached result.

How can we force a computed to re-evaluate which no no longer has the original observables inside?

const ViewModel = function() {
  this.otherComputed = ko.computed(() => true);
  this.computedUnderTest = ko.computed(() => this.otherComputed());
};

const vm = new ViewModel();

function expect(expected) {
  console.log(vm.computedUnderTest() === expected);
}

// Init
expect(true);

// Stub dependent computed
vm.otherComputed = () => false;

// Computed no longer receives updates :(
expect(false);

// Can we force re-evaluation?
// vm.computedUnderTest.forceReEval()
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.2/knockout-min.js"></script>


Solution

  • The only solution I can think of that doesn't involve changing the code of ViewModel, is to stub ko.computed first...

    In the example below I replace ko.computed by an extended version. The extension exposes a property, .stub, that allows you to write a custom function. When this function is set, the computed will re-evaluate using the provided logic.

    In your test file, you'd need to be able to replace the global reference to ko.computed in your preparation code, before instantiating a ViewModel instance.

    // Extender that allows us to change a computed's main value getter
    // method *after* creation
    ko.extenders.canBeStubbed = (target, value) => {
      if (!value) return target;
      
      const stub = ko.observable(null);
      const comp =  ko.pureComputed(() => {
        const fn = stub();
        
        return fn ? fn() : target();
      });
      
      comp.stub = stub;
      
      return comp;
    }
    
    // Mess with the default to ensure we always extend
    const { computed } = ko;
    ko.computed = (...args) => 
      computed(...args).extend({ canBeStubbed: true });
    
    // Create the view model with changed computed refs
    const ViewModel = function() {
      this.otherComputed = ko.computed(() => true);
      this.computedUnderTest = ko.computed(() => this.otherComputed());
    };
    
    const vm = new ViewModel();
    
    function expect(expected) {
      console.log("Test succeeded:", vm.computedUnderTest() === expected);
    }
    
    
    expect(true);
    
    // Replace the `otherComputed`'s code by another function
    vm.otherComputed.stub(() => false);
    
    expect(false);
    <script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.2/knockout-min.js"></script>

    In my own projects, I tend to use a completely different approach for testing my computeds which focuses on separating the logic from the dependencies. Let me know if the example above doesn't work for you. (I'm not going to write another answer if this already satisfies your needs)