This time, we'll make it actually do something - and perform a little refactoring along the way. I'll also talk a bit about more advanced features of mocha.
As you may remember, we are implementing the Roman Numeral coding kata. So far, we have an app object with 4 functions.
* init - sets up a click handler on the button to call the action function
* action - currently does nothing
* fetchInput - fetches the input from our input DOM element
* writeOutput - writes an output to the output DOM element
Here's a quick recap of the code:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var qwery = require('qwery'), | |
bonzo = require('bonzo'), | |
bean = require('bean'); | |
var App = { | |
selector: qwery, | |
dom: bonzo, | |
event: bean, | |
action: function(e) { | |
}, | |
init: function( callback ) { | |
this.event.on( this.selector( '#convert' )[0], 'click', this.action.bind( this ) ); | |
callback(); | |
}, | |
fetchInput: function( callback ){ | |
callback( this.dom( this.selector('#field1') ).val() ); | |
}, | |
writeOutput: function( value, callback ){ | |
callback( this.dom( this.selector('#output') ).text( value ) ); | |
} | |
}; | |
module.exports = App; |
* bean
* bonzo
* jsdom
* mocha
* qwery
* requirejs
* sinon
And here are the unit tests:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var jsdom = require('jsdom').jsdom; | |
document = jsdom( '<html/>' ); | |
window = document.createWindow(); | |
navigator = { userAgent: '' } | |
var assert = require('assert'), | |
sinon = require('sinon'), | |
util = require('util'); | |
describe( 'App', function(){ | |
var app; | |
beforeEach(function(done){ | |
app = require( '../src/app' ); | |
done(); | |
}); | |
describe('Dom related', function() { | |
beforeEach(function(done){ | |
app.selector = sinon.stub(); | |
app.dom = sinon.stub(); | |
app.event = { | |
on: sinon.stub() | |
}; | |
app.action = { | |
bind: function(){ return 'ACTION'; } | |
} | |
done(); | |
}); | |
it( 'fetchInput fetches input value using selector+dom', function( done ) { | |
app.selector.returns( [{}] ); | |
app.dom.returns( { val: function(){ return '7'; } } ); | |
app.fetchInput( function(value) { | |
assert.equal( value, '7' ); | |
done(); | |
}); | |
}); | |
it( 'writeOutput saves value using selector+dom', function( done ) { | |
app.selector.returns( [{}] ); | |
var textFunc = sinon.spy(); | |
app.dom.returns( { text: textFunc } ); | |
app.writeOutput( 'XVI', function(response) { | |
sinon.assert.calledWith( textFunc, 'XVI' ); | |
done(); | |
}); | |
}); | |
it( 'init sets up action function as event handler for button click', function(done) { | |
app.selector.returns( [{}] ); | |
app.init( function() { | |
sinon.assert.calledWith( app.event.on, {}, 'click', 'ACTION' ); | |
done(); | |
}); | |
}); | |
}) | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
describe('action', function() { | |
it('reads from input, convert and writes to output', function(){ | |
app.fetchInput = sinon.stub().yields( 23 ); | |
app.writeOutput = sinon.stub().yields(); | |
app.convertArabicToRoman = sinon.stub().returns('IV'); | |
var e = { | |
stop: sinon.stub() | |
}; | |
app.action( e ); | |
sinon.assert.callCount( app.fetchInput, 1 ); | |
sinon.assert.callCount( app.convertArabicToRoman, 1 ); | |
sinon.assert.callCount( app.writeOutput, 1 ); | |
sinon.assert.calledWith( app.convertArabicToRoman, 23 ); | |
sinon.assert.calledWith( app.writeOutput, 'IV' ); | |
}); | |
}); |
✖ 3 of 4 tests failed
Wait just a minute - that's not just the new test failing (since we haven't even changed action to call convertArabicToRoman yet), but also 2 of the other tests.
So what caused those other failures?
This is down to the way that we have set up our stubs. You'll notice that we reassign app to the module under test in a beforeEach block. This actually has no effect whatsoever. If you include a file with require more than once, it does not create a new object - it refers to the same object every time.
Ok, but that doesn't explain why the other tests failed.
If I had placed the test for action at the end of the test file, they wouldn't. The tests are interacting because we are overriding the methods with sinon stubs. The tests in this case are failing because they are no longer calling the real versions of fetchInput and writeOutput - they are calling the stubs.
So how do we solve this, do we need another test file?
No, not quite so drastic. Fortunately, sinon will "wrap" methods with its stub.. allowing them later to be unwrapped and restored to their original versions.
We need to replace all occurrences this:
app.method = sinon.stub()
with this
sinon.stub( app,'method')
We can ignore the special case of bind now too.
I did that, and ran the tests again, but I've still got 3 tests failing!
We didn't add a step to unwrap those stubs and restore them.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
describe( 'App', function(){ | |
afterEach(function(){ | |
for( element in app ) { | |
if( app[element].restore ) { | |
app[element].restore(); | |
} | |
} | |
}); | |
... | |
}); |
This is not really an issue, since we are never going to want to use the real bean in our unit tests. There is one little niggle though, the call to bind now returns a real function - so we need to drop the expected third parameter from our init test.
Now we just have a single red test as expected (sinon is erroring because convertArabicToRoman does not exist), lets get it green.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var App = { | |
... | |
convertArabicToRoman: function(v) { | |
return v; | |
}, | |
action: function(e) { | |
var self = this; | |
self.fetchInput( function(value) { | |
var out = self.convertArabicToRoman( value ); | |
self.writeOutput( out, function(){} ); | |
}); | |
e.stop(); | |
} | |
... | |
} |
4 tests complete
That was pretty trivial since action is really just a simple function that glues the rest of our app together. Now we can get to the meat of our application - the conversion of roman numerals.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
it( 'converts single digits', function(){ | |
assert.equal( '', app.convertArabicToRoman( '0' ), '0 != ""' ); | |
assert.equal( 'I', app.convertArabicToRoman( '1' ), '1 != I' ); | |
assert.equal( 'II', app.convertArabicToRoman( '2' ), '2 != II' ); | |
assert.equal( 'III', app.convertArabicToRoman( '3' ), '3 != II' ); | |
assert.equal( 'IV', app.convertArabicToRoman( '4' ), '4 != IV' ); | |
assert.equal( 'V', app.convertArabicToRoman( '5' ), '5 != V' ); | |
assert.equal( 'VI', app.convertArabicToRoman( '6' ), '6 != VI' ); | |
assert.equal( 'VII', app.convertArabicToRoman( '7' ), '7 != VII' ); | |
assert.equal( 'VIII', app.convertArabicToRoman( '8' ), '8 != VIII' ); | |
assert.equal( 'IX', app.convertArabicToRoman( '9' ), '9 != IX' ); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var App = { | |
... | |
convertArabicToRoman = function( arabic ) { | |
var i = parseInt( arabic, 10 ); | |
switch( i ) { | |
case 1: return "I"; | |
case 2: return "II"; | |
case 3: return "III"; | |
case 4: return "IV"; | |
case 5: return "V"; | |
case 6: return "VI"; | |
case 7: return "VII"; | |
case 8: return "VIII"; | |
case 9: return "IX"; | |
} | |
return ""; | |
} | |
... | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
it( 'converts tens', function(){ | |
assert.equal( 'X', app.convertArabicToRoman( '10' ), '10 != X' ); | |
assert.equal( 'XX', app.convertArabicToRoman( '20' ), '20 != XX' ); | |
assert.equal( 'XXX', app.convertArabicToRoman( '30' ), '30 != XXX' ); | |
assert.equal( 'XL', app.convertArabicToRoman( '40' ), '40 != XL' ); | |
assert.equal( 'L', app.convertArabicToRoman( '50' ), '50 != L' ); | |
assert.equal( 'LX', app.convertArabicToRoman( '60' ), '60 != LX' ); | |
assert.equal( 'LXX', app.convertArabicToRoman( '70' ), '70 != LXX' ); | |
assert.equal( 'LXXX', app.convertArabicToRoman( '80' ), '80 != LXXX' ); | |
assert.equal( 'XC', app.convertArabicToRoman( '90' ), '90 != XC' ); | |
}); | |
it( 'converts hundreds', function(){ | |
assert.equal( 'C', app.convertArabicToRoman( '100' ), '100 != C' ); | |
assert.equal( 'CC', app.convertArabicToRoman( '200' ), '200 != CC' ); | |
assert.equal( 'CCC', app.convertArabicToRoman( '300' ), '300 != CCC' ); | |
assert.equal( 'CD', app.convertArabicToRoman( '400' ), '400 != CD' ); | |
assert.equal( 'D', app.convertArabicToRoman( '500' ), '500 != D' ); | |
assert.equal( 'DC', app.convertArabicToRoman( '600' ), '600 != DC' ); | |
assert.equal( 'DCC', app.convertArabicToRoman( '700' ), '700 != DCC' ); | |
assert.equal( 'DCCC', app.convertArabicToRoman( '800' ), '800 != DCCC' ); | |
assert.equal( 'CM', app.convertArabicToRoman( '900' ), '900 != CM' ); | |
}); | |
it( 'converts thousands', function(){ | |
assert.equal( 'M', app.convertArabicToRoman( '1000' ), '1000 != M' ); | |
assert.equal( 'MM', app.convertArabicToRoman( '2000' ), '2000 != MM' ); | |
assert.equal( 'MMM', app.convertArabicToRoman( '3000' ), '3000 != MMM' ); | |
}); | |
it( 'combines digits', function(){ | |
assert.equal( 'XXIII', app.convertArabicToRoman( '23' ), '23 != XXIII' ); | |
assert.equal( 'MMMCMXCIX', app.convertArabicToRoman( '3999' ), '3999 != MMMCMXCIX' ); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
function _doConvert( arabic, single, five, ten ) { | |
var i = parseInt( arabic, 10 ); | |
switch( i ) { | |
case 1: return single; | |
case 2: return single + single; | |
case 3: return single + single + single; | |
case 4: return single + five; | |
case 5: return five; | |
case 6: return five + single; | |
case 7: return five + single + single; | |
case 8: return five + single + single + single; | |
case 9: return single + ten; | |
} | |
return ""; | |
} | |
var App = { | |
... | |
convertArabicToRoman = function(arabic){ | |
var thousands = _doConvert( Math.floor(arabic / 1000), "M" ); | |
arabic = arabic % 1000; | |
var hundreds = _doConvert( Math.floor(arabic / 100), "C", "D", "M" ); | |
arabic = arabic % 100; | |
var tens = _doConvert( Math.floor(arabic / 10), "X", "L", "C" ); | |
arabic = arabic % 10; | |
return thousands + hundreds + tens + _doConvert( arabic, "I", "V", "X" ); | |
} | |
... | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
it( 'returns errors', function(){ | |
assert.equal( 'Number out of range', app.convertArabicToRoman( '4000' ), '4000 error not shown' ); | |
assert.equal( 'Number out of range', app.convertArabicToRoman( '-1' ), '-1 error not shown' ); | |
assert.equal( 'Cannot convert', app.convertArabicToRoman( 'dsfa' ), 'string error not shown' ); | |
assert.equal( 'Cannot convert', app.convertArabicToRoman( '7 years' ), 'string error not shown' ); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
function checkDef( val ) { | |
if( val === undefined ) { | |
throw new Error( 'Number out of range' ); | |
} | |
} | |
function _doConvert( arabic, single, five, ten ) { | |
var i = parseInt( arabic, 10 ); | |
switch( i ) { | |
case 1: return single; | |
case 2: return single + single; | |
case 3: return single + single + single; | |
case 4: checkDef( five ); return single + five; | |
case 5: checkDef( five ); return five; | |
case 6: checkDef( five ); return five + single; | |
case 7: checkDef( five ); return five + single + single; | |
case 8: checkDef( five ); return five + single + single + single; | |
case 9: checkDef( ten ); return single + ten; | |
case 0: return ""; | |
} | |
if( isNaN(i) || i+"" != arabic+"" ) | |
{ | |
throw new Error( 'Cannot convert' ); | |
} | |
throw new Error( 'Number out of range' ); | |
} | |
var App = { | |
... | |
convertArabicToRoman = function(arabic){ | |
try{ | |
var thousands = _doConvert( Math.floor(arabic / 1000), "M" ); | |
arabic = arabic % 1000; | |
var hundreds = _doConvert( Math.floor(arabic / 100), "C", "D", "M" ); | |
arabic = arabic % 100; | |
var tens = _doConvert( Math.floor(arabic / 10), "X", "L", "C" ); | |
arabic = arabic % 10; | |
return thousands + hundreds + tens + _doConvert( arabic, "I", "V", "X" ); | |
} catch( e ) { | |
return e.message; | |
} | |
} | |
... | |
} |
Add a little CSS to make it look pretty (with Twitter Bootstrap), and....
A little refactoring of how we handle errors allows me to highlight them on the front end too.
To examine the final code, visit my github repo of this mini-project. The conversion absolutely perfect (try a decimal fraction).
That concludes my mini-series on TDD of frontend code. Hopefully I have enlightened you as to how you can use TDD in an environment which at first is not friendly without the need to test via headless browsers or some other convoluted sequence of hoops.
Feel free to comment or link back to me - I'm sure I'll come up with a new series to work through some more examples soon.
First post in the series: here
Second post in the series: here
No comments:
Post a Comment