Skip to content

Instantly share code, notes, and snippets.

@customcommander
Last active October 6, 2021 17:51
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save customcommander/eaa9711e5bf53c8d054d47662640dadf to your computer and use it in GitHub Desktop.
Save customcommander/eaa9711e5bf53c8d054d47662640dadf to your computer and use it in GitHub Desktop.
Testdouble.js Cheatsheet & Cookbook

Testdouble.js Cheatsheet & Cookbook

Why use testdouble.js?

Suppose we need to test that a function calls another function:

function call(fn) {
  fn();
}

Here's an example without testdouble.js:

test('When you apply call to fn it invokes fn', t => {
  let called = false;
  call(() => called = true);
  t.true(called);
  t.end();
});

And here's an example with testdouble.js:

test('When you apply call to fn it invokes fn', t => {
  const fn = td.func();
  call(fn);
  td.verify(fn()); // <- will throw if fn() didn't occur
  t.pass()
  t.end();
});

With one extra line the testdouble.js version doesn't seem to be a significant improvement but it really is!

How?

Suppose we refactor call to accept a second parameter n (the number of times it will invoke fn). The developer inadvertently passed i to fn and nobody spotted this during code review:

function call(fn, n=1) {
  for (let i=0; i<n; i++) fn(i);
  //                         ^
  //                         Bug!
}

The first test will still pass but the testdouble.js version will throw this error:

Error: Unsatisfied verification on test double.

  Wanted:
    - called with `()`.

  All calls of the test double, in order were:
    - called with `(0)`.

The key to understand that behaviour is here:

td.verify(fn()); // <- will throw if fn() didn't occur

This isn't verifying that fn was simply called. It is verifying that fn was called without arguments.

Why care about that extra argument at all?

We simply cannot predict how any function fn will behave with unexpected arguments. Suppose we need to convert an array of number-like strings into an array of numbers:

['1','2','3'].map(parseInt);
//=> [1, NaN, NaN]

If it is not obvious to you as to why this doesn't work you just proved my point ;)

What if we need to test that…

  • A function was called exactly n times?
  • A function was called with x, y and z?
  • A function was called with x, y and z exactly n times?
  • A function threw an error when called with just x?
  • A function invoked a callback?

You could definitely do all those things without testdouble.js but I would expect that your tests will become bigger and more complex which would probably incur a non-trivial maintenance cost and cause your focus to be split between your tests and the code supporting them. When things get complicated there is always a risk of causing false negatives or false positives which you definitely don't want to see in your tests.

Stub 101

How to create a stub?

This is far from being the only option available to you but it's good to know the basics. Creating a stub can be as simple as this:

const td = require('testdouble');

const stub = td.func(); // <- aka "test double"

How to make a stub return y when called with x?

The API for this is almost the same as saying "when called with x then return y":

td.when(stub(1)).thenReturn('one');
td.when(stub(2)).thenReturn('two');
td.when(stub(3)).thenReturn('three');

We can now use our stub function:

stub(1); //=> 'one'
stub(2); //=> 'two'
stub(3); //=> 'three'

When a stub is called in a way that wasn't expected it simply returns undefined.

stub(4);   //=> undefined
stub("1"); //=> undefined

That behaviour may seem odd at first but practice will tell you that this is definitely what you want.

How to verify that a stub has been called?

We don't have to "train" a stub as shown above to verify a particular call. Even though we didn't configure any return value for stub(4) and stub("1") we can still verify that these calls occurred:

td.verify(stub(4));
td.verify(stub("1"));

The td.verify function will throw an error if a particular stub call didn't occur at all:

td.verify(stub('Hello World!'));
Error: Unsatisfied verification on test double.

  Wanted:
    - called with `("Hello World!")`.

  All calls of the test double, in order were:
    - called with `(1)`.
    - called with `(2)`.
    - called with `(3)`.
    - called with `(4)`.
    - called with `("1")`.

How to test that a function calls another function?

Suppose we need to test call_mult:

const test = require('tape');

function call_mult(a, b, mult) {
  return mult(a, b);
}

test('`call_mult(a, b, mult)` returns `mult(a, b)`', t => {
  const mult = (a, b) => a * b;
  t.equal(42, call_mult(6, 7, mult));
  t.end();
});
TAP version 13
# `call_mult(a, b, mult)` returns `mult(a, b)`
ok 1 should be strictly equal

1..1
# tests 1
# pass  1

# ok

How confident are you that mult is called at all?

In this refactor call_mult completely ignores mult yet the test still pass:

 const test = require('tape');
 
 function call_mult(a, b, mult) {
-  return mult(a, b);
+  return a * b;
 }
 
 test('`call_mult(a, b, mult)` returns `mult(a, b)`', t => {
   const mult = (a, b) => a * b;
   t.equal(42, call_mult(6, 7, mult));
   t.end();
 });
TAP version 13
# `call_mult(a, b, mult)` returns `mult(a, b)`
ok 1 should be strictly equal

1..1
# tests 1
# pass  1

# ok

Let's make sure that mult is called

We introduce testdouble.js to create a stub function and verify that it was called:

 const test = require('tape');
+const td = require('testdouble');
 
 function call_mult(a, b, mult) {
   return a * b;
 }
 
 test('`call_mult(a, b, mult)` returns `mult(a, b)`', t => {
-  const mult = (a, b) => a * b;
-  t.equal(42, call_mult(6, 7, mult));
+  const mult = td.func(); //<- test double
+  call_mult(6, 7, mult);
+  td.verify(mult(6, 7));
+  t.pass();
   t.end();
 });

The test is now able to tell that mult wasn't called at all:

TAP version 13
# `call_mult(a, b, mult)` returns `mult(a, b)`

Error: Unsatisfied verification on test double.

  Wanted:
    - called with `(6, 7)`.

  But there were no invocations of the test double.

Let's fix the implementation:

 const test = require('tape');
 const td = require('testdouble');
 
 function call_mult(a, b, mult) {
-  return a * b;
+  return mult(a, b);
 }
 
 test('`call_mult(a, b, mult)` returns `mult(a, b)`', t => {
   const mult = td.func(); //<- test double
   call_mult(6, 7, mult);
   td.verify(mult(6, 7));
   t.pass();
   t.end();
 });
TAP version 13
# `call_mult(a, b, mult)` returns `mult(a, b)`
ok 1 (unnamed assert)

1..1
# tests 1
# pass  1

# ok

It is important to note that td.verify will make sure that mult was called exactly as intended.

Let's introduce a bug where one parameter gets coerced into a string:

 const test = require('tape');
 const td = require('testdouble');
 
 function call_mult(a, b, mult) {
-  return mult(a, b);
+  return mult(String(a), b);
 }
 
 test('`call_mult(a, b, mult)` returns `mult(a, b)`', t => {
   const mult = td.func(); //<- test double
   call_mult(6, 7, mult);
   td.verify(mult(6, 7));
   t.pass();
   t.end();
 });

The test is able to detect that mult wasn't called as intended:

TAP version 13
# `call_mult(a, b, mult)` returns `mult(a, b)`

Error: Unsatisfied verification on test double.

  Wanted:
    - called with `(6, 7)`.

  All calls of the test double, in order were:
    - called with `("6", 7)`.

Or what if we passed extra arguments?

 const test = require('tape');
 const td = require('testdouble');
 
 function call_mult(a, b, mult) {
-  return mult(String(a), b);
+  return mult(a, b, a, b);
 }
 
 test('`call_mult(a, b, mult)` returns `mult(a, b)`', t => {
   const mult = td.func(); //<- test double
   call_mult(6, 7, mult);
   td.verify(mult(6, 7));
   t.pass();
   t.end();
 });
TAP version 13
# `call_mult(a, b, mult)` returns `mult(a, b)`

Error: Unsatisfied verification on test double.

  Wanted:
    - called with `(6, 7)`.

  All calls of the test double, in order were:
    - called with `(6, 7, 6, 7)`.

Let's fix the implementation:

 const test = require('tape');
 const td = require('testdouble');
 
 function call_mult(a, b, mult) {
-  return mult(a, b, a, b);
+  return mult(a, b);
 }
 
 test('`call_mult(a, b, mult)` returns `mult(a, b)`', t => {
   const mult = td.func(); //<- test double
   call_mult(6, 7, mult);
   td.verify(mult(6, 7));
   t.pass();
   t.end();
 });
TAP version 13
# `call_mult(a, b, mult)` returns `mult(a, b)`
ok 1 (unnamed assert)

1..1
# tests 1
# pass  1

# ok

How confident are you that call_mult returns anything at all?

Knowing that mult is called correctly doesn't guarantee that call_mult will actually use its return value:

 const test = require('tape');
 const td = require('testdouble');
 
 function call_mult(a, b, mult) {
-  return mult(a, b);
+  mult(a, b);
 }
 
 test('`call_mult(a, b, mult)` returns `mult(a, b)`', t => {
   const mult = td.func(); //<- test double
   call_mult(6, 7, mult);
   td.verify(mult(6, 7));
   t.pass();
   t.end();
 });

Unfortunately our test can't see that:

TAP version 13
# `call_mult(a, b, mult)` returns `mult(a, b)`
ok 1 (unnamed assert)

1..1
# tests 1
# pass  1

# ok

Let's make sure that call_mult returns the correct value

We're now telling our stub to return a specific value when called with specific parameters:

 const test = require('tape');
 const td = require('testdouble');
 
 function call_mult(a, b, mult) {
   mult(a, b);
 }
 
 test('`call_mult(a, b, mult)` returns `mult(a, b)`', t => {
   const mult = td.func(); //<- test double
-  call_mult(6, 7, mult);
-  td.verify(mult(6, 7));
-  t.pass();
+  td.when(mult(6, 7)).thenReturn(42);
+  t.equal(42, call_mult(6, 7, mult));
   t.end();
 });

Our test is now able to tell that nothing was returned:

TAP version 13
# `call_mult(a, b, mult)` returns `mult(a, b)`
not ok 1 should be strictly equal
  ---
    operator: equal
    expected: undefined
    actual:   42
  ...

1..1
# tests 1
# pass  0
# fail  1

Let's fix the implementation:

 const test = require('tape');
 const td = require('testdouble');
 
 function call_mult(a, b, mult) {
-  mult(a, b);
+  return mult(a, b);
 }
 
 test('`call_mult(a, b, mult)` returns `mult(a, b)`', t => {
   const mult = td.func(); //<- test double
   td.when(mult(6, 7)).thenReturn(42);
   t.equal(42, call_mult(6, 7, mult));
   t.end();
 });
TAP version 13
# `call_mult(a, b, mult)` returns `mult(a, b)`
ok 1 should be strictly equal

1..1
# tests 1
# pass  1

# ok

How confident are you really that call_mult does return mult(a, b)?

We're back to the same issue: call_mult can completely bypass mult and our test can't detect it:

 const test = require('tape');
 const td = require('testdouble');
 
 function call_mult(a, b, mult) {
-  return mult(a, b);
+  return a * b;
 }
 
 test('`call_mult(a, b, mult)` returns `mult(a, b)`', t => {
   const mult = td.func(); //<- test double
   td.when(mult(6, 7)).thenReturn(42);
   t.equal(42, call_mult(6, 7, mult));
   t.end();
 });
TAP version 13
# `call_mult(a, b, mult)` returns `mult(a, b)`
ok 1 should be strictly equal

1..1
# tests 1
# pass  1

# ok

Let's make sure that call_mult does return mult(a, b) once and for all

The key is to make sure that mult(a, b) returns a value that call_mult cannot possibly return:

 const test = require('tape');
 const td = require('testdouble');
 
 function call_mult(a, b, mult) {
   return a * b;
 }
 
 test('`call_mult(a, b, mult)` returns `mult(a, b)`', t => {
   const mult = td.func(); //<- test double
-  td.when(mult(6, 7)).thenReturn(42);
-  t.equal(42, call_mult(6, 7, mult));
+  td.when(mult(6, 7)).thenReturn('🐋');
+  t.equal('🐋', call_mult(6, 7, mult));
   t.end();
 });
TAP version 13
# `call_mult(a, b, mult)` returns `mult(a, b)`
not ok 1 should be strictly equal
  ---
    operator: equal
    expected: 42
    actual:   '🐋'
  ...

1..1
# tests 1
# pass  0
# fail  1

Let's fix the implementation:

 const test = require('tape');
 const td = require('testdouble');
 
 function call_mult(a, b, mult) {
-  return a * b;
+  return mult(a, b);
 }
 
 test('`call_mult(a, b, mult)` returns `mult(a, b)`', t => {
   const mult = td.func(); //<- test double
   td.when(mult(6, 7)).thenReturn('🐋');
   t.equal('🐋', call_mult(6, 7, mult));
   t.end();
 });
TAP version 13
# `call_mult(a, b, mult)` returns `mult(a, b)`
ok 1 should be strictly equal

1..1
# tests 1
# pass  1

# ok
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment