Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Unit Testing - The Whys, Whens and Hows

Ates Goral
October 11, 2016

Unit Testing - The Whys, Whens and Hows

Ates Goral

October 11, 2016
Tweet

More Decks by Ates Goral

Other Decks in Programming

Transcript

  1. Unit Testing
    The Whys, Whens and Hows
    Ates Goral - Toronto Node.js Meetup - October 11, 2016

    View Slide

  2. Ates Goral
    @atesgoral
    http://magnetiq.com
    http://github.com/atesgoral
    http://stackoverflow.com/users/23501/ates-goral

    View Slide

  3. http://myplanet.com

    View Slide

  4. Definition of a unit test

    View Slide

  5. What is a unit?
    ● Smallest bit of code you can test?
    ● Talking to the actual resource may be OK if it’s stable and fast
    ● Classic versus mockist styles (Martin Fowler)
    ● Solitary versus sociable tests (Jay Fields)
    ● White box versus black box testing
    ● What’s important is the contract
    http://martinfowler.com/bliki/UnitTest.html

    View Slide

  6. Inconsistent definitions
    Here’s what’s common:
    ● Written by developers
    ● Runs fast
    ● Deterministic
    ● Does not tread into integration test territory

    View Slide

  7. Appreciation of unit testing

    View Slide

  8. You don’t know unit testing until you’ve unit tested
    There’s a first time for every developer. Some are more lucky than others because
    they ramp up in an environment that already embraces unit testing.
    “But can already write flawless code when I’m in the zone.”
    True. Because you’re actually running unit tests, without realizing, in your mind
    when you’re in the zone.
    Try taking a 3 week break and see what happens to those ephemeral unit tests.
    Turn those tests into unit test code so that they’re repeatable and unforgettable.

    View Slide

  9. Good unit tests

    View Slide

  10. Good unit tests
    ● Are functionally correct. They don’t just exercise code for the sake of
    exercising code.
    ● Don’t depend on subsequent tests -- every test runs in its own clean
    environment, failure of a test doesn’t bring the entire test suite down
    ● Run fast. You need to be able to run all of your tests as quickly and as
    frequently as possible. Otherwise, they lose value.
    ● Are actually run. Automatically. So that you don’t forget to run them.
    ● Add new unit tests for newly discovered [and fixed] issues.

    View Slide

  11. Good code

    View Slide

  12. Good code
    ● Good code is more unit testable
    ● It all comes down to good architecture and design
    ● Planning for unit tests facilitates good code
    ● Good encapsulation: interfaces with small surfaces, well-defined contracts,
    non-leaky abstractions
    ● Keep interdependencies low

    View Slide

  13. Good reasons

    View Slide

  14. Why and what are you unit testing?
    ● Misguided reasons: processes, meeting performance numbers
    ● Testing just for testing: glue code that doesn’t have any logic, ineffective tests
    that don’t actually test the functionality
    ● Testing legacy code that is actually un-unit-testable
    Be pragmatic. Don’t waste effort. Sometimes unit testing is not the answer (try
    end-to-end instead).

    View Slide

  15. Benefits of unit testing

    View Slide

  16. Benefits of unit testing
    Benefits beyond finding bugs:
    ● Better code
    ● Safety net for refactoring
    ● Documentation of functionality (especially when in BDD style)
    ● Prevents code from becoming an untestable entangled mass

    View Slide

  17. Test-environment-first Programming

    View Slide

  18. Be test-ready on day one
    ● Even if you’re not planning to add test yet
    ● Even if there’s no code worth testing yet
    ● Prime your environment for future unit tests
    ● Especially, CI environment setup can be time consuming
    ● You never know when that moment will come when you have some critical
    code that needs unit testing
    Do this. Please.

    View Slide

  19. Sidenote: At a bare minimum...
    Even you have no time or energy to write unit tests as you go, prepare a manual
    test plan, and someone in your team execute them (manually) prior to releases.
    Bonus: share the effort as a team.
    Basic smoke tests, checking for end-to-end sanity and regression.
    Do this. Please.

    View Slide

  20. Basic test environment setup

    View Slide

  21. Setting up Mocha - no configuration needed
    test/testNothing.js:
    describe('nothing', () => {
    it('should do nothing', (done) => {
    done();
    });
    });
    package.json:
    "scripts": {
    "test": "mocha"
    },
    https://mochajs.org/
    npm install --save-dev mocha
    npm test
    nothing
    ✓ should do nothing
    1 passing (8ms)

    View Slide

  22. Adding Chai
    test/testExpectation.js:
    const chai = require('chai');
    const expect = chai.expect;
    describe('2 + 2', () => {
    it('should equal 4', () => {
    expect(2 + 2).to.equal(4);
    });
    });
    http://chaijs.com/
    npm install --save-dev chai
    npm test
    2 + 2
    ✓ should equal 4

    View Slide

  23. Let’s write our first proper test

    View Slide

  24. The test
    test/testArithmetic.js:
    const arithmetic = require('../src/arithmetic');
    describe('arithmetic', () => {
    describe('.sum()', () => {
    describe('when called with two numbers', () => {
    it('should return their sum', () => {
    expect(arithmetic.sum(2, 2)).to.equal(4);
    });
    });
    });
    });

    View Slide

  25. Implementation and run
    src/arithmetic.js:
    *** REDACTED ***
    npm test
    arithmetic
    .sum()
    when called with two numbers
    ✓ should return their sum

    View Slide

  26. Opportunistic implementation
    src/arithmetic.js:
    exports.sum = (a, b) => {
    return 4;
    };

    View Slide

  27. https://xkcd.com/221/

    View Slide

  28. View Slide

  29. Who tests the tests?

    View Slide

  30. Test correctness
    ● Should not be just exercising code
    ● Should be functionally correct
    ● Subject to peer review?
    I don’t know of any solutions to ensure test correctness.

    View Slide

  31. OH BTW

    View Slide

  32. Selectively running tests with Mocha
    mocha --grep
    npm test -- --grep
    e.g.
    npm test -- --grep arithmetic

    View Slide

  33. Let’s get asynchronous

    View Slide

  34. Timeout implementation
    src/timeout.js:
    exports.set = (callback, milliseconds) => {
    setTimeout(callback, milliseconds);
    };

    View Slide

  35. Timeout test
    test/testTimeout.js:
    it('should call the callback after the delay', (done) => {
    const start = Date.now();
    timeout.set(() => {
    const elapsed = Date.now() - start;
    expect(elapsed).to.equal(100);
    done();
    }, 100);
    });

    View Slide

  36. Run
    npm test
    timeout
    .set()
    when called with a callback and a delay
    1) should call the callback after the delay
    Uncaught AssertionError: expected 105 to equal 100
    + expected - actual
    -105
    +100

    View Slide

  37. Flaky tests are evil

    View Slide

  38. Write deterministic tests that run fast
    ● Don’t rely on chance
    ● A less than 100% pass rate is not acceptable
    ● Don’t waste time with arbitrary delays
    ● Use the right tools for the [right] job

    View Slide

  39. Deterministic timing

    View Slide

  40. Bring in Sinon
    http://sinonjs.org/
    npm install --save-dev sinon

    View Slide

  41. Use a spy and a fake timer
    test/testTimeout.js:
    const sinon = require('sinon');
    describe('timeout', () => {
    let clock = null;
    beforeEach(() => {
    clock = sinon.useFakeTimers();
    });
    afterEach(() => {
    clock.restore();
    });

    View Slide

  42. Use a spy and a fake timer (continued)
    describe('.set()', () => {
    describe('when called with a callback and a delay', () => {
    it('should call the callback after the delay', () => {
    const callback = sinon.spy();
    timeout.set(callback, 100);
    clock.tick(100);
    expect(callback).to.have.been.called;
    });
    });
    });

    View Slide

  43. Run
    npm test -- --grep timeout
    timeout
    .set()
    when called with a callback and a delay
    ✓ should call the callback after the delay
    100% pass rate.

    View Slide

  44. Definitions of test doubles

    View Slide

  45. View Slide

  46. Again, some inconsistencies
    ● Dummy
    ● Fake
    ● Stub
    ● Spy
    ● Mock
    http://www.martinfowler.com/bliki/TestDouble.html
    https://en.wikipedia.org/wiki/Test_double

    View Slide

  47. Test doubles - dependency injection

    View Slide

  48. Account service that takes DB as a dependency
    src/accountService.js:
    function AccountService(db) {
    this.db = db;
    }
    AccountService.prototype.findById = function (accountId, callback) {
    const results = this.db.querySync('account', { id: accountId });
    callback(results[0]);
    };
    module.exports = AccountService;

    View Slide

  49. Bring in Sinon-Chai
    https://github.com/domenic/sinon-chai
    npm install --save-dev sinon-chai
    const sinonChai = require('sinon-chai');
    chai.use(sinonChai);

    View Slide

  50. Account service test
    test/testAccountService.js:
    describe('AccountService', () => {
    let db = null;
    let accountService = null;
    beforeEach(() => {
    db = {
    querySync: sinon.stub()
    };
    accountService = new AccountService(db);
    });

    View Slide

  51. Account service test (continued)
    db.querySync.withArgs('account', { id: 1 }).returns([{
    id: 1,
    name: 'John Doe'
    }]);
    const callback = sinon.spy();
    accountService.findById(1, callback);
    expect(callback).to.have.been.calledWith({
    id: 1,
    name: 'John Doe'
    });

    View Slide

  52. Promises

    View Slide

  53. DB now uses promises
    src/accountService.js:
    function AccountService(db) {
    this.db = db;
    }
    AccountService.prototype.findById = function (accountId, callback) {
    return this.db
    .query('account', { id: accountId })
    .then((results) => results[0]);
    };
    module.exports = AccountService;

    View Slide

  54. Bring in sinon-as-promised
    https://www.npmjs.com/package/sinon-as-promised
    npm install --save-dev sinon-as-promised
    const sinonAsPromised = require('sinon-as-promised');

    View Slide

  55. Updated account service test
    beforeEach(() => {
    db = {
    query: sinon.stub()
    };
    accountService = new AccountService(db);
    });

    View Slide

  56. Updated account service test (continued)
    db.query.withArgs('account', { id: 1 }).resolves([{
    id: 1,
    name: 'John Doe'
    }]);
    return accountService.findById(1)
    .then((account) => {
    expect(account).to.deep.equal({
    id: 1,
    name: 'John Doe'
    });
    });

    View Slide

  57. Negative case

    View Slide

  58. When account not found
    db.query.withArgs('account', { id: -1 }).rejects(
    new Error('Account not found')
    );
    return accountService.findById(-1)
    .catch((error) => {
    expect(error).to.deep.equal(
    new Error('Account not found')
    );
    });

    View Slide

  59. But wait...
    src/accountService.js:
    AccountService.prototype.findById = function (accountId, callback) {
    if (accountId === -1) {
    return Promise.resolve({
    id: -1,
    name: 'Negative One'
    });
    }
    return this.db
    .query('account', { id: accountId })
    .then((results) => results[0]);
    };

    View Slide

  60. Run
    npm test -- --grep account
    AccountService
    .findById()
    when called for an existing account
    ✓ should return a promise resolved with the account
    when called for a non-existent account
    ✓ should return a promise rejected with an error

    View Slide

  61. Need the positive case to fail the test
    return accountService.findById(-1)
    .catch((error) => {
    expect(error).to.deep.equal(
    new Error('Account not found')
    );
    })
    .then(() => {
    throw new Error('Should not have been resolved');
    });

    View Slide

  62. Run
    npm test -- --grep account
    AccountService
    .findById()
    when called for an existing account
    ✓ should return a promise resolved with the account
    when called for a non-existent account
    1) should return a promise rejected with an error

    View Slide

  63. Making the experience better

    View Slide

  64. Bring in Chai as Promised
    http://chaijs.com/plugins/chai-as-promised/
    npm install --save-dev chai-as-promised
    const chaiAsPromised = require('chai-as-promised');
    chai.use(chaiAsPromised);

    View Slide

  65. Updated positive test
    return expect(accountService.findById(1))
    .to.eventually.deep.equal({
    id: 1,
    name: 'John Doe'
    });

    View Slide

  66. Updated negative test
    return expect(accountService.findById(-1))
    .to.eventually.be.rejectedWith(Error, 'Account not found');

    View Slide

  67. Run
    npm test -- --grep account
    AccountService
    .findById()
    when called for an existing account
    ✓ should return a promise resolved with the account
    when called for a non-existent account
    1) should return a promise rejected with an error
    AssertionError:
    expected promise to be rejected with 'Error'
    but it was fulfilled with { id: -1, name: 'Negative One' }

    View Slide

  68. Without dependency injection

    View Slide

  69. To intercept any module dependency - Mockery
    https://github.com/mfncooper/mockery
    npm install --save-dev mockery
    beforeEach(() => {
    mockery.enable({
    warnOnReplace: false,
    warnOnUnregistered: false,
    useCleanCache: true
    });
    mockery.registerMock('./db', db);
    });
    afterEach(() => {
    mockery.disable();
    });

    View Slide

  70. All code so far
    https://github.com/atesgoral/hello-test
    Clean commit history with 1 commit per example.

    View Slide

  71. Q&A

    View Slide