Wiring ES7 decorators to Chai Spies for declarative contracts on class methods

A metaprogramming case study

What spies are for

Spies allow probing functions for specific call patterns, for example, given an instance of a class:

const foo = new (class Foo {

bar () { }

})

We can hook spy to the bar method:

foo.bar = chai.spy (foo.bar)

Then generate some calls:

foo.bar ('qux')

And finally, ensure it was called in a proper way:

foo.bar.should.have.been.called.with ('qux').once

So far looks good. But when you have lots of tests with plenty of classes and methods, this explicit method overriding kinda gets in the way…

Going declarative

It would be definitely cool, if it could be written like this:

const foo = new (class Foo { @will.have.been.called.with (42).once

bar () { } @will.have.been.called.twice

zap () { }

}) foo.bar (42)

foo.zap (); foo.zap ()

In this article, I’ll show you how to implement this with newest ECMAScript metaprogramming features: decorators, Proxies and Symbols.

Implementation

We will target Mocha, hooking up to its `it` function, gathering decorator calls and executing spy checks after a test completes.

Here’s how a regular test looks like:

import 'chai-spies-decorators' describe ('chai-spies-decorator', () => { it ('works', () => { /* Here's our test */

})

})

The basic idea is to modify the `it` function so it wraps our test within following:

checks = gatherChecks ()

/* Here's our test */

checks.forEach (check => check ())

All magic is hidden within the gatherChecks. It defines a global `will` property:

Object.defineProperty (global, 'will', { configurable: true, get () { ... }

Which, upon access, will generate a Proxy object that captures a chain of property accesses and function calls:

will.have.been.called.with (42).once // captures ['have', 'been', 'called', 'with', [42], 'once']

When this chain is applied to a class method, as decorator…

@will.have.been.called.with (42).once

method () { ... }

…it outputs a property accessor, in which we generate (and memoize) a “spied” version of that method, and also a check callback which ensures that the method have been called according to a contract:

const memo = Symbol () // for referencing the hidden state property return { get () { if (this[memo]) { return this[memo] }



else { const spied = chai.spy (method) checks.push (() => { // executes the captured ['have', 'been', ...]

// on a spied method

}) return (this[memo] = spied)

}

}

}

The check is executed after a test completes, generating a Chai assertion chain from it’s captured array representation. This way we can translate API calls without any prior knowledge of them.

Full code

The implementation is available at GitHub:

As NPM package: