Casual Testing in ES6
Casual testing
Sometimes you use ready made frameworks because they’re robust and well tested and you just have to get stuff done. And that’s fine. Sometimes you write code because it’s new and you’re really doing something novel. That’s super cool too. And then sometimes you write code just so you have something that fits your weird way of thinking, so you know it inside and out, and so you’ve gained the experience of writing it. This last category seems like it should be called “casual programming”. That’s probably not a term that would benefit from a rigorous definition, but suffice it to say it’s not the kind of programming anyone usually has the luxury of doing at work where you need to get-stuff-done or provide-value-to-the-customer.
Jest is an open source JavaScript testing framework from Facebook. It’s a neat system with some interesting applications of functional programming ideas. But if you’re filled with the do-it-yourself spirit and not totally focused on raw productivity, then maybe the ideas are more interesting than the thing itself. So that’s what this post is about: having some fun stealing the two best ideas from Jest to build a tiny test framework.
The expect
function
Conventional testing would organize a bunch of testing procedures where each procedure checks whether or not some code produces the right results. For example, a testing procedure for an add
function might check if add(1, 1)
returns 2
. In Python you can write your tests with the unittest
module, which provides a bunch of methods with names like assertTrue
or assertEqual
, so a test of the add function might be written self.assertEqual(add(1, 1), 2)
.
The expect
function serves the same purpose as these assert methods but adds a monad-like flavor, which of course makes it extremely hip and extremely cool. Using the expect
function, the same test would be written expect(add(1, 1)).equals(2)
. This also puts the arguments into a nice ordering where the verb (equals
) appears between the grammatical subject (the result of add(1, 1)
) and the grammatical object (2
).
To implement expect
, all we need is a class that holds the value passed to the expect
function that also implements an equals
method. I called this class Expectation
, which seemed like the obvious choice.
export const expect = (value) => {
return new Expectation(value);
}
class Expectation {
constructor(value) {
this.value = value;
}
equals(other) {
if(this.value != other) {
throw new Error(`${this.value} != ${other}`);
}
}
}
Adding more “verbs” is a matter of adding more methods to Expectation
. If the burden of verbs is getting unwieldy, the methods could be refactored into sub-classes that specialize for the type passed to expect
so that if the value
in expect(value)
is an instance of Array
, then an ArrayExpectation
might have a special method like contains
and a NumericExpectation
might have a method like isGreaterThan
. If you’re into chaining, expectation methods could also return this
from each method to support tests such as:
expect(add(1, 1))
.isGreaterThan(1)
.isLessThan(3)
.equals(2)
A masque
or a mock function
Jest uses the name ‘mock functions’ to describe how they test higher order functions—functions that take functions as arguments. In a test, you would pass a mock function to a callback, trigger the condition that executes the callback, and then check with the mock function to see if it has been executed as expected. For example, if we want to test that the Window
object is dispatching a custom event, we could do this manually:
var gotCall = false;
window.addEventListener('custom', (event) => {
gotCall = true;
});
window.dispatchEvent(new CustomEvent('custom'))
expect(gotCall).equals(true);
But wouldn’t it be nice if we could be more concise and pass a magic handler that handled setting up this gotCall
variable for us? Something like this:
window.addEventListener('custom', magic);
window.dispatchEvent(new CustomEvent('custom'));
expect(magic).wasCalled();
So what would this magic
thing have to look like in order to work in this code snippet? It would have to be callable as a function, and it would also have to somehow report back whether or not it had ever been called.
You could do this with a Proxy
handler that implements the apply
method (see here). Proxies are pretty verbose, and I’ve never liked the triplicate nature of the proxy system with the proxy, target, and handler being three distinct things.
Instead, this is a perfect opportunity to use a closure. I was inspired by the procedural implementations of a linked list in The Structure and Interpretation of Computer Programs (in the last fifteen minutes of this video). In idiomatic ES6, you can make a linked list out of closures and higher order functions without using any underlying arrays or objects for data storage:
const pair = (head, tail) => {
return (pick) => {
if(pick)
return head;
else
return tail;
}
}
const head = (list) => {
return list(true);
}
const tail = (list) => {
return list(false);
}
Thus head(pair('a', 'b')) == 'a'
and tail(pair('a', 'b')) == 'b'
. Chaining together pairs lets you create a list: var x = pair('a', pair('b', 'c'))
. Here head(x) == 'a'
and tail(tail(x)) == 'c'
.
Now we have a data structure with no visible underlying means of storing data: it’s all just functions. But really the variables head
and tail
are in fact stored in the function’s environment, which we call a closure. Closures are pretty much just objects that store the names of the variables in the current scope associated to their memory location. But unlike regular JavaScript objects they’re totally implicit and the closure itself can’t be called by name or passed as an argument.
My implementation of masque
uses a trick similar to the pick
parameter in the linked list example, but since we want to monitor the arguments that masque
accepts when it’s masquerading as another function, we need a pick
argument that can’t be confused with any other argument. The ES6 Symbol
class is the perfect fit for the job. Let’s make a simple version of masque
that only tracks whether or not it was called:
export const masque = () => {
var called = false;
return (...args) => {
if(args.length == 1 && args[0] == masque.called)
return called;
else {
called = true;
}
}
}
masque.called = Symbol('masque.called');
Now if we run var x = masque
we have a callable function in the variable x
. We can query x
if has ever been called by invoking it with our special unique argument masque.called
.
var x = masque();
x(masque.called); // false
x();
x(masque.called); // true
Recall that we wanted to be able to write expect(magic).wasCalled();
. Now we know that magic
is a value created by masque
and that the wasCalled
method of the Expectation
class will look something like this:
wasCalled() {
if(!this.value(masque.called))
throw new Error('The function was never called.');
}
My complete implementation of masque
logs all of the activity of an underlying function, fn
, that you can optionally pass masque
.
export const masque = (fn) => {
var memos = new Array();
if(!fn) fn = () => {};
const wrapper = (...args) => {
if(args.length == 1 && args[0] == masque.memos) {
return memos;
}
let memo = { args: args };
memos.push(memo);
try {
memo.result = fn(...args);
} catch(error) {
memo.error = error;
throw error;
}
return memo.result;
}
wrapper.sigil = masque.sigil;
return wrapper;
}
masque.memos = Symbol('masque.memos');
masque.sigil = Symbol('masque.sigil');
The masque.memos
symbol let us interrogate all of the arguments the function received, and all of the things the underlying function returned. The symbol masque.sigil
is a marker on the wrapper functions returned to distinguish masques from regular functions—this distinction is used when dispatching masques to the correct type of Expectation subclass.
Link to the GitHub repository
The point of earmarking this as “casual” is to emphasize that I think it’s a worthwhile exercise to build your own testing framework. Probably as long as you’re not building medical equipment or running critical infrastructure—but even the most reliable software started somewhere. That said, you can find my implementation on GitHub, because… vanity?