Skip to the code
cf.

Build a spy

MediumFunctions

Test libraries replace functions with spies that record how they were used. Write createSpy(impl). It returns a function, the spy, that records each call and then calls impl, if one was given, with the same this and arguments, returning what impl returns. Without impl, the spy returns undefined.

spy.calls is an array with one entry per call, in order: { args, this }, where args is an array of the arguments.

spy.results has one entry per call, in the same order: { type: "return", value } with the returned value, or { type: "throw", value } with the error when impl throws. A call that throws is still recorded, and the error is thrown on to the caller.

spy.callCount is the number of calls so far. spy.calledWith(...args) returns true if some call had exactly these arguments: the same number of them, each compared with Object.is. spy.reset() forgets every call and result.

const add = createSpy((a, b) => a + b);
add(1, 2);            // 3
add(5, 5);            // 10
add.callCount;        // 2
add.calls[0].args;    // [1, 2]
add.results[1];       // { type: "return", value: 10 }
add.calledWith(5, 5); // true
add.calledWith(5);    // false
add.reset();
add.callCount;        // 0

What the tests check

  1. records the arguments of each call, in order
    const fake = createSpy();
    fake(1, 2);
    fake("a");
    fake();
    expect(fake.calls.map((call) => call.args)).toEqual([[1, 2], ["a"], []]);
    
  2. records the this of each call
    const fake = createSpy();
    const obj = { method: fake };
    const other = {};
    obj.method();
    fake.call(other);
    expect(fake.calls[0].this).toBe(obj);
    expect(fake.calls[1].this).toBe(other);
    
  3. calls impl with the same this and arguments and returns its result
    const add = createSpy(function (a, b) {
      return this.base + a + b;
    });
    const obj = { base: 10, add };
    expect(obj.add(1, 2)).toBe(13);
    
  4. records each result
    const double = createSpy((x) => x * 2);
    double(1);
    double(5);
    expect(double.results).toEqual([
      { type: "return", value: 2 },
      { type: "return", value: 10 },
    ]);
    
  5. records a thrown error and throws it on
    const error = new Error("boom");
    const broken = createSpy(() => {
      throw error;
    });
    expect(() => broken(1)).toThrow("boom");
    expect(broken.calls.map((call) => call.args)).toEqual([[1]]);
    expect(broken.results).toEqual([{ type: "throw", value: error }]);
    
  6. callCount counts the calls
    const fake = createSpy();
    expect(fake.callCount).toBe(0);
    fake();
    fake();
    expect(fake.callCount).toBe(2);
    
  7. calledWith compares each argument with Object.is
    const fake = createSpy();
    const obj = {};
    fake(1, "a");
    fake(obj);
    fake(NaN);
    expect(fake.calledWith(1, "a")).toBe(true);
    expect(fake.calledWith(obj)).toBe(true);
    expect(fake.calledWith(NaN)).toBe(true);
    expect(fake.calledWith({})).toBe(false);
    expect(fake.calledWith("1", "a")).toBe(false);
    
  8. calledWith needs the same number of arguments
    const fake = createSpy();
    fake(1, 2);
    expect(fake.calledWith(1)).toBe(false);
    expect(fake.calledWith(1, 2, undefined)).toBe(false);
    expect(fake.calledWith()).toBe(false);
    
  9. reset() forgets every call and result
    const fake = createSpy((x) => x);
    fake(1);
    fake(2);
    fake.reset();
    expect(fake.callCount).toBe(0);
    expect(fake.calls).toEqual([]);
    expect(fake.results).toEqual([]);
    expect(fake.calledWith(1)).toBe(false);
    fake(3);
    expect(fake.calls.map((call) => call.args)).toEqual([[3]]);
    expect(fake.results).toEqual([{ type: "return", value: 3 }]);
    

Learn more

solution.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).