Skip to content

Instantly share code, notes, and snippets.

@andresgutgon
Last active December 3, 2016 17:08
Show Gist options
  • Save andresgutgon/d4ab8b104b5e24b0d9d201c04933d395 to your computer and use it in GitHub Desktop.
Save andresgutgon/d4ab8b104b5e24b0d9d201c04933d395 to your computer and use it in GitHub Desktop.
Little mocker excercise. Learn what are the different test doubles we can use when we test our code.

Testing: The little mocker

This is an example trying to apply some lessons learned from Uncle Bob's article The little mocker

In this gist first I'll explain what I learned. Then I'll show you what the code does and finally how I tested the code.

What I learned

When we're making unit test we want to focus on the code we want to test not external dependencies that are not under our control. To make life easier and being able to test on isolation our code without side effects from the rest of the world we use test doubles. Test doubles try to simulate behavior that is in our code but belongs to external agents/objects.

There are diferent types of test doubles that is the main point of The litlle mocker article. Explain what are those types and when use each one. Lest's explain in one line what are the main test doubles we can use in our tests:

Dummy is an object that implements the interface of the real object but do nothing. Imagine the class Dog with the method run. The real class start running when you do const dog = new Dog(); dog.run(); but dummyDog will do nothing.

Stub Is an object that implements the interface of the real object and returns a result. Imagine an Auth service with a method isLoggedIn. You can use a stub that returns true. This way you forget about authentication and focus on your code.

Spy Is an object that implements the interface of the real object, CAN return a result, but their mission is to check that your code is calling the spied object. Imagine that you want to to know if isLoggedIn method was called. You could spy Auth class.

Mock Object: Is a substitute implementation to emulate or instrument other domain code. Mocks are similar to spies but the difference is that you put the assertion into the mock. They are a kind of smart spies. I find it less helpful and to coupled to my taste.

Fakes They are different than the rest of Test Doubles. Fakes implements real bussines behavior.

class AcceptingAuthorizerFake extends Authorizer {
  authorize(username, password) {
     return expect(username).beEquals('Bob');
  }
}

As you can see this fake object returns always true when username is "Bob". As the author of the article says this can get very complicated.

So what should we use?

I agree with the author of the article. stubs and spies are usually enough in my daily basics. I've never used mocks as described in this paper. and I will continue using just stubs and spies but is good to know that we have other tools.

What the code does?

We will explain briefly what the example does before get into the code. The authenticate method that we want to test is used to log in users into a web page with their Google Account. It uses under ther hood Google Auth 2 flow.

The method only has on parameter. immediate. If you use it this way inmediate(true) it will start the oauth 2 dance with Google servers and if you were already log in into your google account and you have permissions in the web page it will authorize to enter into the page. The other way is with immediate flag set to false. In that case it will ask you to log into your Google account:

image

The tests

I added the google-auth.js service and their specs google-auth__spec.js. The thing is that the method is doing 2 nested callbacks to call Google servers.

First is loading auth2 javascript module:

gapi.load('auth2', () => {
//...

Then in the load callback it's calling Google Auth servers to authorize the user:

gapi.auth.authorize(params, (response) => {
//...

After those 2 Google calls we have their server response. We need to check if there was an error. In that case we reject the promise. If there is no error we store on localStorage the tokens.

What I wanted to accomplish from a test perspective is:

  1. Ensure that some of the params we pass to Google authorize endpoint do not change by mistake. Imagine someone add a new scope. She/he should be aware of it. for that reason we added a spec for it.
  2. I want to make sure that always the server responses with an error the code rejects the promise.
  3. Always that the server returns valid tokens we store it on localStorage.

Google Javascript Api

Google JS api gapi is loaded into the browser and the code has access to it througth window object. When we're testing the code we don't want to load that dependency for that reason we do a dummy implementation and assign it to window object. Then in the code we can spy over that fake gapi object and make it return what we need.

Conclusion

I think is too much words for so litle code :) but it has been a good excercise for me to understand better the tools we use. Also is hard to deal with async flows. But when you work with dummy implementations is always easier.

/**
* Google script is attached into gapi window variable.
* Is a Global object for that reason we need to mock it.
* This way is available in our specs and we can stub/spy their methods.
*
* `gapi` is filled when this script is loaded in the browser:
* <script src="https://apis.google.com/js/api:client.js"></script>
*/
window.gapi = {
// Load the api from google api you need, like oauth2 module
load: function(api_module, callback) {
callback();
},
auth: {
authorize: function(params, callback) {}
},
};
export export const GOOGLE_API_SCOPE = 'https://www.googleapis.com/auth/userinfo';
const CLIENT_ID = '__YOUR_GOOGLE_AUTH_CLIENT_ID__';
function getAuthorizeParams(immediate) {
return {
client_id: CLIENT_ID,
scope: [
`${GOOGLE_API_SCOPE}.email`,
`${GOOGLE_API_SCOPE}.profile`,
],
response_type: 'token id_token',
immediate,
};
}
export function authorize(immediate = true) {
const params = this.getAuthorizeParams(immediate);
return new Promise((resolve: any, reject: any) => {
gapi.load('auth2', () => {
gapi.auth.authorize(params, (response) => {
if (this.authorizeHasEror(response)) {
reject();
return;
}
localStorage.setItem('access_token', response.access_token);
localStorage.setItem('id_token', response.id_token);
resolve(response);
});
});
});
}
describe('#authorize', () => {
it('calls google server and loads auth2 module', () => {
spyOn(gapi, 'load');
expect(gapi.load).toHaveBeenCalledWith('auth2', jasmine.any(Function));
service.authorize();
gapi.load.calls.reset();
});
it('pass params to Google Auth 2 authorize method', () => {
spyOn(gapi.auth, 'authorize').and.callFake(function(response) {
const requestParams = arguments[0];
expect(requestParams.response_type).toEqual('token id_token');
expect(requestParams.immediate).toEqual(true);
expect(requestParams.scope).toEqual([
`${GOOGLE_API_SCOPE}.email`,
`${GOOGLE_API_SCOPE}.profile`,
]);
});
service.authorize();
gapi.auth.authorize.calls.reset();
});
it('set immediate param to false', () => {
spyOn(gapi.auth, 'authorize').and.callFake(function(response) {
const requestParams = arguments[0];
expect(requestParams.immediate).toEqual(false);
});
service.authorize(false);
gapi.auth.authorize.calls.reset();
});
it('set localStorage tokens on response success', (done) => {
const googleServerResponse = {
access_token: 'hola',
id_token: 'adios',
};
spyOn(window.localStorage, 'setItem');
spyOn(gapi.auth, 'authorize').and.callFake(function() {
const googleAuthorizeCallback = arguments[1];
googleAuthorizeCallback(googleServerResponse);
done();
});
service
.authorize()
.then((response) => {
expect(window.localStorage.setItem)
.toHaveBeenCalledWith('access_token', googleServerResponse.access_token);
expect(window.localStorage.setItem)
.toHaveBeenCalledWith('id_token', googleServerResponse.id_token);
done();
})
.catch(() => {});
window.localStorage.setItem.calls.reset();
gapi.auth.authorize.calls.reset();
});
it('reject promise when google respond with an error', (done) => {
const googleServerResponse = {
error: 'error_authorizing_user_with_google_account',
};
spyOn(window.localStorage, 'setItem');
spyOn(gapi.auth, 'authorize').and.callFake(function() {
const googleAuthorizeCallback = arguments[1];
googleAuthorizeCallback(googleServerResponse);
done();
});
expect(window.localStorage.setItem).not.toHaveBeenCalled();
service
.authorize()
.catch((error) => {
expect(error).toEqual(googleServerResponse.error);
done();
});
window.localStorage.setItem.calls.reset();
gapi.auth.authorize.calls.reset();
});
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment