On Github tyangliu / lab--js-unit-testing-slides
Thomas Liu October 8, 2015
describe('the formatISODate function', () => { it('should convert a mm/dd/yyyy date to ISO8601 format', () => { let date = '09/30/2015'; // more traditional asserts assert.isEqual(formatISODate(date), '2015-09-30T07:00:00.000Z'); // .should, modifies Object.prototype! formatISODate(date).should.equal('2015-09-30T07:00:00.000Z'); // nicer than asserts, but without modifying Object.prototype // like .should does expect(formatISODate(date)).to.be('2015-09-30T07:00:00.000Z'); }); });
describe('yumm mocha', () => { let somethingCool; // runs once at the start of this describe block, does not re-run per nested describe before(() => { somethingCool = new DbClient(); }) // runs before each test (each "it"), including tests in nested describes beforeEach(() => { somethingCool.reset(); }); // (1) afterEach(() => { console.log('a test has finished!'); } after(() => { /* do some final cleanup */ }); it('should do something cool', () => {}); describe('a submodule', () => { beforeEach(() => {}); // (2) it('should do something cooler', () => {}); // both of the (1) and (2) hooks are run }); });
let userIds = [ 'qjwKKjEYbEqgtwD3BYHhww', 'tDPbT6FSn0WyyV_29bI_Zw', '97TDtHncF0y2Z5oQAi48zQ', 'WA4d7b-kaEy-XNIpw34opQ' ]; for (let i=0; i < usersIds.length; i++) { let id = userIds[i] , request = new XMLHttpRequest(); // make a synchronous HTTP GET call request.open('GET', `/users/${id}`, false /* switches off async */); // at this point, the entire thread is just sitting around for // the network IO to finish instead of doing other work! (request.status == 200) && console.log(request.responseText); });
let userIds = [ 'qjwKKjEYbEqgtwD3BYHhww', 'tDPbT6FSn0WyyV_29bI_Zw', '97TDtHncF0y2Z5oQAi48zQ', 'WA4d7b-kaEy-XNIpw34opQ' ]; for (let i=0; i < usersIds.length; i++) { let id = userIds[i] , request = new XMLHttpRequest(); // make a synchronous HTTP GET call request.open('GET', `/users/${id}`, false /* switches off async */); // at this point, the entire thread is just sitting around for // the network IO to finish instead of doing other work! (request.status == 200) && console.log(request.responseText); });
The loop completely freezes the webpage (not just the slideshow) until it finishes all 4 requests!
let userIds = [ 'qjwKKjEYbEqgtwD3BYHhww', 'tDPbT6FSn0WyyV_29bI_Zw', '97TDtHncF0y2Z5oQAi48zQ', 'WA4d7b-kaEy-XNIpw34opQ' ]; userIds.forEach(id => { let request = new XMLHttpRequest(); // make an async HTTP GET call request.open('GET', `/users/${id}`, true); // let the thread deal with something else for now, such as handle the user // scrolling down the webpage (kind of important right?) and refreshing our slideshow request.onload(() => // an event fired signalling that the server responded, // only now do we come back to deal with it (request.status == 200) && console.log(request.responseText) ); });
let userIds = [ 'qjwKKjEYbEqgtwD3BYHhww', 'tDPbT6FSn0WyyV_29bI_Zw', '97TDtHncF0y2Z5oQAi48zQ', 'WA4d7b-kaEy-XNIpw34opQ' ]; userIds.forEach(id => { let request = new XMLHttpRequest(); request.open('GET', `/users/${id}`, true); request.onload(() => (request.status == 200) && console.log(request.responseText) ); });
// define the function function findUserAsync(id, callback) { console.log(`I’m Mr. Meeseeks 1`); // assume this is a db IO op and takes significantly longer than code execution User.find({id}).exec(user => { console.log(`I’m Mr. Meeseeks 2`); callback(user); }); console.log(`I’m Mr. Meeseeks 3`); } // actual things start happening here console.log(`I’m Mr. Meeseeks 4`); findUserAsync(23932, user => { console.log(`I’m Mr. Meeseeks 5`); });
What gets printed to the console?
Answer (just the ordering): 4, 1, 3, 2, 5
function findUserWithPromises(id) { console.log(`I’m Mr. Meeseeks 1`); return User.find({id}).exec(); } console.log(`I’m Mr. Meeseeks 2`); let promise = findUserAsync(23932).then(user => { console.log(`I’m Mr. Meeseeks 3`); return `I’m Mr. Meeseeks 4`; }).then(text => { console.log(text); return findUserAsync(1032); }) console.log(`I’m Mr. Meeseeks 5`); promise.then(user => { console.log(`I’m Mr. Meeseeks 6`); return Promise.reject(new Error(`I’m Mr. Meeseeks 7`)); }).then(() => { console.log(`I’m Mr. Meeseeks 8`); }.catch(err => { console.log(err.message); }); console.log(`I’m Mr. Meeseeks 9`);
What gets printed to the console?
Answer (just the ordering): 2, 5, 9, 1, 3, 4, 1, 6, 7
// with promises function createUser(email, password) { return User.find({email}).then(existingUsers => { if (existingUsers.length > 0) { return Promise.reject(false); } return bcrypt.hashAsync(password, 15); }).then(hashedPassword => { let user = new User(email, hashedPassword); return user.save().then(() => { console.log(`user with email ${email} created!`); return user; }); }); }
// with async/await async function createUser(email, password) { // make sure there isn’t already a user with this email let existingUsers = await User.find({email}); if (existingUsers.length > 0) { return false; } let hashedPassword = await bcrypt.hashAsync(password, 15) , user = new User(email, hashedPassword); await user.save(); console.log(`user with email ${email} created!`); return user; }
function findUserAsync(id, callback) { User.find({id}).exec(user => callback(user)); } // we could try testing like this: findUserAsync(23430, user => expect(user.email).to.equal(`bob@gmail.com`) );
What's the problem with this? What if our function never actually calls our callback? The test wouldn't even run (automatic pass)! Or imagine that the callback withdraws $50 from your bank account. We want to test that the callback is only called ONE TIME.
Imagine we had a function that remembers the # of times its been called and any arguments passed to it. How can we implement this?
function simpleSpy() { // we're referencing the function name here instead of 'this' // because 'this' refers to the execution context, not the fn; // alternatively, you could bind 'this' to 'simpleSpy' simpleSpy.argsHistory = simpleSpy.argsHistory || []; simpleSpy.timesCalled = simpleSpy.timesCalled || 0; simpleSpy.timesCalled += 1; for (let i = 0; i < arguments.length; i++) { simpleSpy.argsHistory.push(arguments[i]); } console.log(`Somebody called me!`); }
Now we can use this spy to test our function properly.
describe(`the findUserAsync function`, () => { it(`should be able to find a user by id`, () => { findUserAsync(23430, simpleSpy); setTimeout(() => { expect(simpleSpy.timesCalled).to.equal(1); expect(simpleSpy.argsHistory[0].email).to.equal(`bob@gmail.com`); }, 1000); }); });
Note: this is just an example for simplicity’s sake; don’t do setTimeouts during unit tests in practice
describe(`the findUserAsync function`, () => { it(`should be able to find a user by id`, () => { let callback = sinon.spy(); findUserAsync(29034, callback); expect(callback).to.have.been.calledOnce; // deep equality check on the argument passed to our callback expect(callback).to.have.been.calledWithMatch({ email: `bob@gmail.com`, hashedPassword: `s8984932jdadfghj`, // not a real hash lol name: `Bob` }; }); });
// findUser.js export function findUserWithPromises(id) { return User.find({id}).exec(); } // findUser.spec.js import { findUserWithPromises } from './findUser'; describe(`the findUserWithPromises function`, () => { it(`should be able to find a user by id`, () => { let userPromise = findUserWithPromises(53094); expect(userPromise).to.eventually.deep.equal({ email: `bob@gmail.com`, hashedPassword: `s8984932jdadfghj`, name: `Bob` }); }); });
// myFilters.js angular.module('myFilters', []) .filter('formatDate', () => (collection, property) => { /* cool things here */ }); // myFilters.spec.js describe('Custom filters module', () => { // make all components of 'myFilters' available during tests beforeEach(() => module('myFilters')); // $filter, $controller, $directive allow you to load the // respective components by their angular names it('should have a formatDate function', inject($filter => expect($filter('formatDate')).to.not.be.null )); // the above translates to the following ES5 it('should have a dateFilter function', inject(function($filter) { expect($filter('formatDate')).to.not.be.null; })); });
// myFilters.spec.js describe('Custom filters module', () => { // make all components of 'myFilters' available during tests beforeEach(() => module('myFilters')); /* truncated */ describe('Filter: formatDate', () => { /* injecting filterName + 'Filter' will resolve the filterName filter, a little shortcut to $filter('filterName') NOTE: this is specific to filters; with services for example, just the service's name itself suffices */ it('should format dates to be MMMM d, yyyy', inject(formatDateFilter => expect(formatDateFilter('09/05/2015')).to.be('September 5, 2015'); ); }); });
Instead of injecting a dependency once for every tests, we can just assign it to a variable
// myFilters.spec.js describe('The Forms Service', () => { let FormsService; // make the forms module available beforeEach(() => module('forms')); // inject and save the FormsService; angular lets us lead & trail the dep name // with _s, so it doesn't shadow our var of the same name from above beforeEach(inject(_FormsService_ => { FormsService = _FormsService_; }); // now every test has access to the service without re-injecting it('allows me to retrieve the most recent forms', () => { let forms = FormsService.getForms(); expect(forms).to.have.length.within(3,10); }); });
Services may rely on unpredictable network I/O, so here comes the mocks.
// just an example, having other service objects wholly passed into // a generic ModalsService wouldn't be the best design in practice describe('The ModalsService', () => { let ModalsService , FormsService; beforeEach(() => module('modals')); beforeEach(inject(_ModalsService_ => ModalsService = _ModalsService_)); // create a fake object containing the mocked FormsService with the fn we need beforeEach(() => FormsService = { getData: () => ([ { name: 'A form name', date: '2015-06-05' }, { name: 'Another form name', date: '2016-07-03' } ]) }); it('creates a new modal from a service with the expected props', () => { let modal = ModalsService.createFromService(FormsService); expect(modal).to.have.all.keys('name', 'date', 'width', 'height'); }); });