– Primer on Async (before we do async testing)



– Primer on Async (before we do async testing)

0 0


lab--js-unit-testing-slides

A code-filled slideshow on JS/ES6+Angular unit testing with mocha, chai, sinon, karma, and friends

On Github tyangliu / lab--js-unit-testing-slides

des

Thomas Liu October 8, 2015

Tools We'll Be Using

  • mocha
  • chai
  • sinon
  • karma
  • describe(), it()
  • expect(), assert(), .should()
  • spies and mocks
  • automatically running our tests in browser(s)

What a Spec Looks Like

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');
  });

});

Setup and Teardown

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
  });

});

Primer on Async (before we do async testing)

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);
});

Imagine we had a photo slideshow

Pretend this refreshes every 2 seconds
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);
  });
  • Photo refreshes
  • Photo refreshes
  • for (let i=0; i < usersIds.length; i++) { let id = userIds[i] , request = new XMLHttpRequest(); request.open('GET', `/users/${id}`, false /* switches off async */); (request.status == 200) && console.log(request.responseText); });
  • Photo refreshes

The loop completely freezes the webpage (not just the slideshow) until it finishes all 4 requests!

An Async Version

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)
  );
});

What happens this time?

Pretend this refreshes every 2 seconds
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)
  );
});
  • Photo refreshes
  • Send request 1
  • Send request 2
  • Photo refreshes
  • Send request 3
  • Send request 4
  • Photo refreshes
  • console.log response 3
  • Photo refreshes
  • console.log response 2
  • console.log response 1
  • console.log response 4

Callbacks

// 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

Promises

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

Async/Await (ES7? inspired by C#)

// 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;
    });

  });
}
So much
// 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;
}

Testing Callbacks

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.

The Solution: Spies

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!`);
}

Testing with Our Simple Spy

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

Spies with Sinon and Sinon-chai

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`
    };
  });

});

Testing Promises with chai-as-promised

// 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`
    });
  });

});

Loading Angular Components into Tests

// 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;
  }));

});

Directly Injecting a Filter

// 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');
    );
  });

});

Sharing Dependencies Between Tests

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);
  });

});

Mocking Services

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');
  });

});
Thomas Liu October 8, 2015