2.5 TDD Example: Object.defineProperty for object mocking
This section will go through how to use Object.defineProperty
to mock how constructors create methods, ie. non-enumerable properties that are functions.
The gist of Object.defineProperty
use with a function value boils down to:
const obj = {};
Object.defineProperty(obj, 'yes', { value: () => Math.random() > 0.5 });
console.log(obj); // {}
console.log(obj.yes()); // false or true depending on the call :D
As you can see, the yes
property is not enumerated, but it does exist. That’s great for setting functions as method mocks.
It’s useful to testing code that uses things like Mongo’s ObjectId
. We don’t want actual ObjectIds strewn around our code.
We’ll test-drive the development of a Mock Mongo ObjectId instance and show how we can leverage Object.defineProperty
to create mock object instances with properties that aren’t enumerated.
Testing Mongo ObjectId
You use them in your persistence layer, you usually want to convert a string to an ObjectId using the ObjectId()
constructor
See the following snippet:
const { ObjectID: ObjectId, MongoClient } = require('mongodb');
const mongoClient = new MongoClient();
async function getUserIdFromSession(sessionId) {
const session = await (await mongoClient.connect())
.collection('sessions')
.findOne({
_id: ObjectId(sessionId)
});
return session.userId && session.userId.toString();
}
A naive mock
An naive mock implementation would be:
const mockObjectId = data => data;
We’re relying on the fact that the .toString
method exists on strings:
'myString'.toString(); // 'myString'
The issue is that it’s not an object, so it behaves differently
A better mock
So those are our 3 requirements:
toString()
should exist and return whatever’s passed into the constructor- it should be an object
ObjectId('a')
should deep equalObjectId('a')
const mockObjectId = data => {
return {
name: data,
toString: () => data
};
};
test('toString() returns right value', () => {
expect(mockObjectId('foo').toString()).toEqual('foo');
});
test("it's an object", () => {
const actual = mockObjectId('foo');
expect(typeof actual).toEqual('object');
});
test('two objectIds with same value are equal', () => {
const first = mockObjectId('foo');
const second = mockObjectId('foo');
expect(first).toEqual(second);
});
We get a failure on the last test, note that we need to override the testRegex since this is a failing test, it’s been stored under src/02.05-mock-object-id.failing.js
which doesn’t match Jest’s default test matching regular expression.
npx jest --testRegex ".*\.failing\.js" src/02.05-mock-object-id.failing.js
FAIL src/02.05-mock-object-id.failing.js
✓ toString() returns right value (4ms)
✓ it's an object
✕ two objectIds with same value are equal (11ms)
● two objectIds with same value are equal
expect(received).toEqual(expected) // deep equality
Expected: {"name": "foo", "toString": [Function toString]}
Received: serializes to the same string
16 | const first = mockObjectId('foo');
17 | const second = mockObjectId('foo');
> 18 | expect(first).toEqual(second);
| ^
19 | });
20 |
at Object.<anonymous> (src/02.05-mock-object-id.failing.js:18:17)
Test Suites: 1 failed, 1 total
Tests: 1 failed, 2 passed, 3 total
Our issue is Expected: {"name": "foo", "toString": [Function toString]}
, what is happening is that toString
is a new function for each mock instance… which means they’re not equal (by reference).
The right mock
const mockObjectId = data => {
const oid = {
name: data
};
Object.defineProperty(oid, 'toString', {
value: () => data
});
return oid;
};
test('toString() returns right value', () => {
expect(mockObjectId('foo').toString()).toEqual('foo');
});
test("it's an object", t => {
const actual = mockObjectId('foo');
expect(typeof actual).toEqual('object');
});
test('two objectIds with same value are equal', () => {
const first = mockObjectId('foo');
const second = naiveObjectId('foo');
expect(first).toEqual(second);
});
Which gives us a green test.
npx jest src/02.05-mock-object-id.test.js
PASS src/02.05-mock-object-id.test.js
✓ toString() returns right value (4ms)
✓ it's an object (1ms)
✓ two objectIds with same value are equal (1ms)
Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
How Object.defineProperty saved our bacon
It’s about enumerability. We want to mock an Object, with methods on it, without the methods appearing when people enumerate through it.
Object.defineProperty
allows you to set whether or not the property is enumerable, writable, and configurable as well as a value or a get/set (getter/setter) pair (see MDN Object.defineProperty).
There are 2 required descriptor (configuration) values: configurable
(if true, the property can be modified or deleted, false by default), enumerable
(if true, it will show during enumeration of the properties of the object, false by default).
There are also some optional descriptor (configuration) values: value
(value associated with property, any JS type which includes function), writable
(can this property be written to using the assignment operator, false by default), get
and set
(which are called to get and set the property).
This is an example call:
const o = {};
Object.defineProperty(o, 'me', {
value: 'Hugo',
writable: false // default: false
});
console.log(o.me); // 'Hugo'
This section has gone through how we can create mocks using Object.defineProperty
.
The next section looks at how to mock out JavaScript module imports.