How I Discovered Monkey Patching While Mocking AWS Cognito in an Angular App

I’ve been using AWS Cognito as the authentication piece to give users access to an Angular web project I’m working on.
Everything worked as advertised and I was happy with the result, until I started testing my authentication service.
That’s when things got interesting and I could not create a spy object on any of the Cognito classes.

Instead, I got…

1
2
3
4
5
6
7
8
9
10
AuthenticationService when .signInUser is working normally instantiate cognito AuthenticationDetails with correct user credentials FAILED
Error: <spyOn> : AuthenticationDetails is not declared writable or has no setter
Usage: spyOn(<object>, <methodName>)
at <Jasmine>
at UserContext.<anonymous> (http://localhost:9876/_karma_webpack_/src/shared/services/auth/authentication.service.spec.ts:33:40)
at ZoneDelegate.invoke (http://localhost:9876/_karma_webpack_/node_modules/zone.js/dist/zone-evergreen.js:359:1)
at ProxyZoneSpec.push../node_modules/zone.js/dist/zone-testing.js.ProxyZoneSpec.onInvoke (http://localhost:9876/_karma_webpack_/node_modules/zone.js/dist/zone-testing.js:308:1)
at ZoneDelegate.invoke (http://localhost:9876/_karma_webpack_/node_modules/zone.js/dist/zone-evergreen.js:358:1)
at Zone.run (http://localhost:9876/_karma_webpack_/node_modules/zone.js/dist/zone-evergreen.js:124:1)
at runInTestZone (http://localhost:9876/_karma_webpack_/node_modules/zone.js/dist/zone-testing.js:561:1)

Quick Answer

1
2
3
4
5
6
7
8
import * as AWSCognito from 'amazon-cognito-identity-js';

Object.defineProperty(AWSCognito, 'AuthenticationDetails', {
writable: true,
value: 'foo'
});

spyAuthenticationDetails = spyOn(AWSCognito, 'AuthenticationDetails');
  • This is strange because the same code in node.js throws TypeError: Cannot redefine property: AuthenticationDetails.
  • Angular uses zone.js to modify, or extend, all your async APIs.
  • We call this Monkey patching.
  • In my case, Angular Monkey patched amazon-cognito-identity-js and the result is we can execute Object.defineProperty without TypeError: Cannot redefine property: AuthenticationDetails.
  • Mocking AWS Cognito is simple, now that I know :)
  • Code examples on how to mock AWS Cognito in Angular.

What is Object.defineProperty

Object.defineProperty is a function which is natively present in the Javascript runtime environment and takes the following arguments:

1
Object.defineProperty(obj, prop, descriptor)

With Object.defineProperty, one defines a new property directly on an object or modifies an existing property on an object.

I found a very good StackOverflow answer, How to use javascript Object.defineProperty, and here are some points which helped me:

In Javascript, standard properties (data member with getter and setter) are defined by accessor descriptor.
Exclusively, you can use data descriptor (so you can’t use value and set on the same property):

  • accessor descriptor = get + set
    • get must be a function; its return value is used in reading the property; if not specified, the default is undefined, which behaves like a function that returns undefined.
    • set must be a function; its parameter is filled with Right Hand Side in assigning a value to property; if not specified, the default is undefined, which behaves like an empty function.
  • data descriptor = value + writable
    • value default undefined; if writable, configurable and enumerable (see below) are true, the property behaves like an ordinary data field.
    • writable - default false; if not true, the property is read only; attempt to write is ignored without error**

Both descriptors can have these members:

  • configurable - default false; if not true, the property can’t be deleted; attempt to delete is ignored without error**
  • enumerable - default false; if true, it will be iterated in for(var i in theObject); if false, it will not be iterated, but it is still accessible as public.

(** unless in strict mode - in that case javascript stops execution with TypeError unless it is caught in try-catch block)

(Side note: Also good to explore Object.preventExtensions(), Object.seal(), Object.freeze())

In my case, I actually marked the Cognito.AuthenticationDetails constructor function as overridable (writable).
But, you can only do that if configurable: true.

Here is the Mozilla example Object.defineProperty to explore:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
'use strict';
const object1 = {};

Object.defineProperty(object1, 'property1', {
value: 42,
writable: false
});

console.log(Object.getOwnPropertyDescriptor(object1, 'property1'));

// throws an error in strict mode
// Error: Cannot assign to read only property 'property1' of object '#<Object>'
object1.property1 = 77;

console.log(object1.property1);
// expected output: 42

If you want to explore my claim of read-only is the default, use console.log(Object.getOwnPropertyDescriptor(object1, 'property1'));.

Expected output:

1
Object { value: 42, writable: false, enumerable: false, configurable: false }

Let’s explore the configurable flag:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
'use strict';
const object1 = {};

// define a property
Object.defineProperty(object1, 'property1', {
value: 42,
});

// let's redefine the property
Object.defineProperty(object1, 'property1', {
value: 88,
});

console.log(object1.property1);
// expected output: 88

The example above will throw error:

1
Error: Cannot redefine property: property1

Resolve this error with configurable: true:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
'use strict';
const object1 = {};

Object.defineProperty(object1, 'property1', {
value: 42,
configurable: true
});

// let's redefine the property
Object.defineProperty(object1, 'property1', {
value: 88,
});

console.log(object1.property1);
// expected output: 88

Background

I have an authentication.service.ts and I want to test signInUser.
I reference amazon-cognito-identity-js for the Cognito functionality.

Here are a few tests I’ve identified to be implemented:

  • Cognito.AuthenticationDetails instantiated with correct credentials.
  • Cognito.CognitoUserPool instantiated with the correct UserPoolId and ClientId.
  • Cognito.CognitoUser instantiated with the correct ICognitoUserData object.
  • cognitoUser.authenticateUser was called only once.
  • On successful login, username must be returned.

(I’ve identified these tests by the comment: // test - ... in authentication.service.ts)

Mocking Cognito

My first mission: Test Cognito.AuthenticationDetails instantiated with correct credentials.

Because I reference amazon-cognito-identity-js, I thought mocks and spies would be the tools for testing AuthenticationService.signInUser.

The code below should do the trick…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
describe('AuthenticationService', () => {
let service: AuthenticationService;

beforeEach(() => {
// arrange
TestBed.configureTestingModule({});
service = TestBed.get(AuthenticationService);
});

it('should be created', () => {
expect(service).toBeTruthy();
});

describe('when .signInUser is working normally', () => {
let spyAuthenticationDetails: jasmine.Spy;
let result;

beforeEach(async () => {
//create spy
spyAuthenticationDetails = spyOn(AWSCognito, 'AuthenticationDetails');

// act
// system under test
service.signInUser({ userName: 'hello', password: 'password' }).subscribe(
value => {
result = value;
},
error => {
throw Error(error);
}
);
});

it('instantiate cognito AuthenticationDetails with correct user credentials', () => {
// assert
expect(spyAuthenticationDetails).toHaveBeenCalledTimes(1);
expect(spyAuthenticationDetails).toHaveBeenCalledWith({
Username: 'hello',
Password: 'password'
});
});
});
});

But failed with:

1
2
3
4
5
6
7
8
9
10
AuthenticationService when .signInUser is working normally instantiate cognito AuthenticationDetails with correct user credentials FAILED
Error: <spyOn> : AuthenticationDetails is not declared writable or has no setter
Usage: spyOn(<object>, <methodName>)
at <Jasmine>
at UserContext.<anonymous> (http://localhost:9876/_karma_webpack_/src/shared/services/auth/authentication.service.spec.ts:33:40)
at ZoneDelegate.invoke (http://localhost:9876/_karma_webpack_/node_modules/zone.js/dist/zone-evergreen.js:359:1)
at ProxyZoneSpec.push../node_modules/zone.js/dist/zone-testing.js.ProxyZoneSpec.onInvoke (http://localhost:9876/_karma_webpack_/node_modules/zone.js/dist/zone-testing.js:308:1)
at ZoneDelegate.invoke (http://localhost:9876/_karma_webpack_/node_modules/zone.js/dist/zone-evergreen.js:358:1)
at Zone.run (http://localhost:9876/_karma_webpack_/node_modules/zone.js/dist/zone-evergreen.js:124:1)
at runInTestZone (http://localhost:9876/_karma_webpack_/node_modules/zone.js/dist/zone-testing.js:561:1)

Based on the research here:

I’ve decided to go with the solution below:

1
2
3
4
Object.defineProperty(AWSCognito, 'AuthenticationDetails', {
writable: true,
value: 'foo'
});

I’ve updated my test and all was green…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
describe('AuthenticationService', () => {
let service: AuthenticationService;

beforeEach(() => {
// arrange
TestBed.configureTestingModule({});
service = TestBed.get(AuthenticationService);
});

it('should be created', () => {
expect(service).toBeTruthy();
});

describe('when .signInUser is working normally', () => {
let spyAuthenticationDetails: jasmine.Spy;
let result;

beforeEach(async () => {
// Confusion: Why does this output `configurable: true` in Angular world but not in Node???
// outputs: Object{get: function() { ... }, set: undefined, enumerable: true, configurable: true}
console.log(Object.getOwnPropertyDescriptor(AWSCognito, 'AuthenticationDetails')); configurable: true}

/********************* Magic *******************************/
Object.defineProperty(AWSCognito, 'AuthenticationDetails', {
writable: true,
value: 'foo'
});
/***********************************************************/

spyAuthenticationDetails = spyOn(AWSCognito, 'AuthenticationDetails');

// act
service.signInUser({ userName: 'hello', password: 'password' }).subscribe(
value => {
result = value;
},
error => {
throw Error(error);
}
);
});

it('instantiate cognito AuthenticationDetails with correct user credentials', () => {
// assert
expect(spyAuthenticationDetails).toHaveBeenCalledTimes(1);
expect(spyAuthenticationDetails).toHaveBeenCalledWith({
Username: 'hello',
Password: 'password'
});
});
});
});

Life was good and I continued working until the voice inside my head whispered:

“Why is this working…?”

Node behaves different to Angular

Although the chances of using a front-end library (amazon-cognito-identity-js) in a node.js server application is very low, I’ve created a quick test in node.js just to proof a point.

The test is following exactly what I’ve done in Angular, but without the extra package dependencies from Angular.

My investigation revealed Object.getOwnPropertyDescriptor(AWSCognito, 'AuthenticationDetails') returns configurable: false.

This is proof that AWS Cognito library is shipped with configurable: false.

Because it returns false, one would not be able to execute Object.defineProperty without an exception.
The AWS team does not want you to fiddle with their implementation of AWSCognito.AuthenticationDetails.

Below is my node.js test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const AWSCognito = require('amazon-cognito-identity-js');

describe('Investigaste Object.defineProperty of AWSCognito.AuthenticationDetails', () => {
it('It should throw "TypeError: Cannot redefine property: AuthenticationDetails" because Object.getOwnPropertyDescriptor returns configurable: false', () => {

console.log('Object.getOwnPropertyDescriptor(AWSCognito, "AuthenticationDetails"): ');
console.log(Object.getOwnPropertyDescriptor(AWSCognito, 'AuthenticationDetails'));

// this will throw exception because of 'configurable: false',
// meaning the AWS team does not want you to fiddle with this...and I agree, you shouldn't.
Object.defineProperty(AWSCognito, 'AuthenticationDetails', {
writable: true,
value: 'foo'
});

console.log(Object.getOwnPropertyDescriptor(AWSCognito, 'AuthenticationDetails'));
});

});

Output after running the test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Randomized with seed 26638
Started
Object.getOwnPropertyDescriptor(AWSCognito, "AuthenticationDetails"):
{ get: [Function: get],
set: undefined,
enumerable: true,
configurable: false }
F

Failures:
1) Investigaste Object.defineProperty of AWSCognito.AuthenticationDetails It should throw "TypeError: Cannot redefine property: AuthenticationDetails" because Object.getOwnPropertyDescriptor returns configurable: false
Message:
TypeError: Cannot redefine property: AuthenticationDetails
Stack:
at <Jasmine>
at UserContext.it (/Users/ruanbeukes/repos/MockCognitoInAngular/node-property-descriptor-investigation/test.spec.js:11:12)
at <Jasmine>
at runCallback (timers.js:705:18)
at tryOnImmediate (timers.js:676:5)

1 spec, 1 failure
Finished in 0.011 seconds
Randomized with seed 26638 (jasmine --random=true --seed=26638)

I’m confused…I’m sure in my Angular test, before the property modification code, I logged the exact same thing and the result was configurable: true.

How is that possible?!

Angular’s Zone.js and Monkey patching

Angular uses zone.js to handle change detection.
Because we have async code, Angular will Monkey patch all async APIs in order to managed the change detection.
(See resources below for a more in-depth look at zone'js as it does more than just change detection, it also helps with debugging)

A quick google revealed:

What is Monkey patching?
By definition, Monkey patching is basically extending or modifying the original API.
Now, zone.js re-defines all the async APIs like browser apis which includes set/clearTimeOut, set/clearInterval, alert, XHR apis etc.

amazon-cognito-identity-js will be re-defined by zone.js and also define the properties as configurable: true.
Because it sets configurable: true, it explains why I can define/modify object properties in the Angular world but not in the node.js world.

Below is the code that changes configurable: false to configurable: true from zone-evergreen.js.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function rewriteDescriptor(obj, prop, desc) {
// issue-927, if the desc is frozen, don't try to change the desc
if (!Object.isFrozen(desc)) {

/******** Ruan: This is where Angular does something different compared to node.js **/
desc.configurable = true;
/************************************************************************************/
}
if (!desc.configurable) {
// issue-927, if the obj is frozen, don't try to set the desc to obj
if (!obj[unconfigurablesKey] && !Object.isFrozen(obj)) {
_defineProperty(obj, unconfigurablesKey, { writable: true, value: {} });
}
if (obj[unconfigurablesKey]) {
obj[unconfigurablesKey][prop] = true;
}
}
return desc;
}

Here is the stack trace when I’ve added a conditional breakpoint prop === 'AuthenticationDetails':

1
2
3
4
5
6
7
8
9
10
11
12
13
rewriteDescriptor	                                   @ zone-evergreen.js:2227
Object.defineProperty @ zone-evergreen.js:2178
__webpack_require__.d @ http://localhost:9876/_karma_webpack_/webpack/bootstrap:98
./src/shared/services/auth/authentication.service.ts @ authentication.service.spec.ts:201
__webpack_require__ @ http://localhost:9876/_karma_webpack_/webpack/bootstrap:79
./src/shared/services/auth/authentication.service.spec.ts @ environment.ts:13
__webpack_require__ @ http://localhost:9876/_karma_webpack_/webpack/bootstrap:79
webpackContext @ src sync \.spec\.ts$:9
./src/test.ts @ test.ts:20
__webpack_require__ @ http://localhost:9876/_karma_webpack_/webpack/bootstrap:79
checkDeferredModules @ http://localhost:9876/_karma_webpack_/webpack/bootstrap:45
(anonymous) @ http://localhost:9876/_karma_webpack_/webpack/bootstrap:152
(anonymous) @ http://localhost:9876/_karma_webpack_/webpack/bootstrap:152

The rewriteDescriptor function can be found in angular/packages/zone.js/lib/browser/define-property.ts in the Angular repository.

Conclusion

  • Be aware that Angular uses zone.js to modify, or extend, all your async APIs.
  • We call this Monkey patching and it is Angular’s way to deal with change detection.
  • If you build an Angular app and have unexpected side-effects in one of your async API libraries, start your investigation with zone.js.

Hopefully it saved you a few hours of research.

Use it…don’t use it :)

Code Samples

Mock Cognito In Angular

Resources