Unit testing capabilities using advanced ES6 features
Unit testing is a fundamental part of any development process. It’s a key tool that helps us to scale and balance maintainability and extensibility in large and diverse code repositories.
The challenge: legacy components
Ideally unit tests are contained and easy to write, and you can apply TDD because you are writing new functionality, right? Well, that’s the ideal world, normally the reality is a little more complex and we have to write unit tests for both new and also legacy components.
Sometimes old components have a mixed set of dependencies and we can't afford to refactor all of them. We usually prefer a gradual transition where the old code can coexist with the old one.
Mocking objects behavior using advanced ES6 features: enters the Proxy object
In order to write tests for legacy components without having to have all of their real dependencies in place during test execution time we need advanced mocking capabilities.
For that a first feature present in ES2015 that I have used in order to create a unit test framework (based in Mocha with the BDD driver) was the ES2015 Proxy object that allows you to create a virtual version of an already existing object and be notified of operations and modifications over it.
Node.js v5 vs the Proxy ES6 spec
As you know ES2015 standards have been evolving and changing a lot which causes sometimes inconsistencies. In this case I found one regarding a mismatch in the implementation of Proxy between modern browsers and the Node.js v5.
In Node.js v5 the way of creating a Proxy based on another object is via Proxy.create, whereas in Node.js v6 and browsers that use the new spec it uses directly the contructor. Since I had to support both execution environments I had to determine in runtime which interface of the object was available:
/**
* Encapsulate the operation of creating a Proxy object assuring compatibility
* across different versions of Node.js and browsers.
*/
initializeProxy(handler: ProxyHandler, proto: Object): Proxy {
let proxy;
if (typeof Proxy.create !== 'undefined') {
proxy = Proxy.create(handler, proto);
} else {
proxy = new Proxy(proto, handler);
}
return proxy;
}
Notice how I was using ES2015 syntax combined with Facebook Flow for defining the types of the parameters and the return value of the method.
Proxy handlers for mocking objects
The Proxy object initialization requires the object that you want to mock and a handler.
The handler is a wrapper that allows you to define operations that you want to be notified of. I needed to listen to get and set operations in both properties and methods, from now on I’ll refer to both of them by calling them properties, but don’t forget that it means both types.
Now, based on my requirements the handler’s interface has a structure like this:
const proxyHandler = {
/**
* Intercepts access operations on properties and methods.
*/
get(obj: Object, key: string): Object {
...
},
/**
* Intercepts set operations over properties and methods.
*/
set(obj: Object, key: string): boolean {
...
}
};
Registering access operations over object properties
Property access operations over an object will trigger our custom get and set methods.
For my object mocking library I needed to register those operations storing them in a Map object via a helper method (_registerAccess). We’ll use that information afterwards when checking the result operations in our unit tests via asserts.
/**
* Uses a map for keeping track of access and set operations.
*/
_registerAccess(propertyName: string, isDefined: boolean): void {
let register = {
count: 0,
called: false,
isDefined
};
register.count++;
register.called = true;
this._spyMap.set(propertyName, register);
}
A handler can detect accesses to both existing and undefined properties. Knowing if we tried to access to a property that didn’t exist can be useful for some tests so we store that information passing it as a second parameter for our _registerAccess method.
/**
* Registering access operations.
*/
get(objectTarget: Object, key: string): Object {
const originalValue = Reflect.get(objectTarget, key);
_registerAccess(key, typeof originalValue !== undefined);
...
}
Return values for access operations
The get operation in a handler also requires you to return explicitly the value that the external object using our mock will get. Here, depending on the implementation you could return the original value or a mocked value as I do in the use case of a unit testing FW.
In order to return the original value there is a caveat you need to take into consideration: the handler get method receives the virtual object and the key of the property.
My first try in order to return the original value was to use directly the combination return obj[key]
. That works just fine following the current spec, however I found that for Node.js v5 it was triggering a recursive call again to the own get listener since that’s yet another access to the same property.
The only way to circumvent that was by using the reflection API via the Reflect object that allowed me to access to the target property/method without triggering another access in the handler.
/**
* Using Reflect for reading the property value without triggering a recursive call.
*/
get(obj: Object, key: string): Object {
const originalValue = Reflect.get(target, key);
...
}
Mocking method calls
As I have mentioned, the handler’s method get is able to detect both property and method calls. If we want to mock a method call by returning a custom function we need to be careful of when we are registering the access. If we do it inside of the listener but out of the custom function the access will be registered before the actual function is called by the receiving object causing race conditions in our unit tests.
/**
* Registering the acccess to method calls inside of the callback to avoid race
* conditions.
*/
get(obj: Object, key: string): Object {
...
if (typeof originalValue === 'function') {
result = () => {
_registerAccess(key);
...
}
Set operations
The set method is a bit easier, there we only want to register the access. The only warning here is to remember to return a boolean value set to true that will allow us to complete the set operation on the mock object. If you return false (or undefined) an access error exception will be thrown.
/**
* Returning true for letting the property value assignment to succeed.
*/
set(obj: Object, key: string): boolean {
self._registerAccess(key, typeof target[key] !== 'undefined');
return true;
}
Checking get and set operations via our access map
Now that we are able to register property accesses to properties and methods over our mocked objects we just need to expose a method that we’ll use in our unit test asserts to verify the operations performed:
/**
* Access to the spy map to assert accesses over properties and methods.
*/
getPropertyRegister(propertyName: string): Object|null {
const register = this._spyMap.get(propertyName);
return typeof register !== 'undefined' ? register : null;
}
Using the Proxy object wrapper
By using the previous described techniques we have created a Spy object that we can use in our test cases. What better way of understanding how it works than showing some of its own unit tests:
const originalObject = {
testMethod: () => {}
};
it('#spy intercepts property gets', () => {
const spy = new Spy();
const mock = spy.watch(originalObject);
const triggerSetAccess = mock.test;
assert.equal(spy.get('test').called, true);
assert.equal(spy.get('test').count, 1);
assert.equal(spy.get('test').isDefined, true);
});
Similarly we can mock method calls and test the access operations:
it('#intercept overrides method calls', () => {
const spy = new Spy();
const mock = spy.watch(originalObject);
mock.testMethod();
assert.equal(spy.get('testMethod').called, true);
});
Overview
I have presented a couple of examples on how by using ES2015 features you can create mock objects that you can use in your tests. Of course you could achieve similar functionality by using already existing libraries like Sinon, however I’ve found that in the most of my use-cases I can use a custom and lighter version.
ES2015 features are powerful but you need to know well what options do you have and when to use them. Not everything works everywhere. For example you can trade off some performance when running your unit tests (if we are talking about milliseconds, of course) however you probably would be much more restrictive for production code and would think it twice before using Proxy objects extensively there.