-- Shakespeare, Henry VIII
Act I, Scene I, Line 125.2
We, the software craftsmen, understand that tests are a first class citizen. If the system code was deleted it would be a great opportunity to rewrite the system and know once all the tests pass that the system has all the same functionality. The question is should tests have the same style as the system code?
Say you have the Coin Changer kata and you want to use a function which takes both the coin value and the amount of change to be given.
If we wrote this in a C-style interface it look something like the following:
int[] changeFor(int[] coins, int amount)
While testing this we have a choice: we can either pass in the coins each time in the test thus violating the DRY principle or we could some how fill in the coins we want to use on each test in a testing series. The second choice is very interesting since it is screaming for currying the coins per testing series. Following this idea we have the following for the Coin Changer kata using AngularJS.
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 = angular.module('coinChangerApp', []); | |
// based on http://snippetrepo.com/snippets/lodash-in-angularjs | |
app.factory('_', ['$window', | |
function($window) { | |
return $window._; | |
} | |
]); | |
app.controller('CoinChangerCtrl', ['$scope', '_', | |
function($scope) { | |
$scope.changeFor = function(coins, amount) { | |
if (_.isEmpty(coins)) return []; | |
return _.reduce(coins, function(m, coin) { | |
m.change.push(Math.floor(m.amount / coin)); | |
m.amount %= coin; | |
return m; | |
}, { | |
amount: amount, | |
change: [] | |
}).change; | |
}; | |
} | |
]); |
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('Testing a Hello World controller', function() { | |
var $scope = null; | |
var ctrl = null; | |
var _ = null; | |
//you need to indicate your module in a test | |
beforeEach(module('coinChangerApp')); | |
beforeEach(inject(function($rootScope, $controller, $window) { | |
$scope = $rootScope.$new(); | |
_ = $window._; | |
ctrl = $controller('CoinChangerCtrl', { | |
$scope: $scope | |
}); | |
})); | |
describe('given no coins', function() { | |
var noCoinsFor; | |
beforeEach(function() { | |
noCoinsFor = _.curry($scope.changeFor)([]); | |
}); | |
it('should return nothing for 0 cents', function() { | |
expect(noCoinsFor(0)).toEqual([]); | |
}); | |
it('should return nothing for 99 cents', function() { | |
expect(noCoinsFor(99)).toEqual([]); | |
}); | |
}); | |
describe('given pennies', function(){ | |
var penniesFor; | |
beforeEach(function(){ | |
penniesFor = _.curry($scope.changeFor)([1]); | |
}); | |
it('should return nothing for 0 cents', function(){ | |
expect(penniesFor(0)).toEqual([0]); | |
}); | |
it('should return 1 penny for 1 cents', function(){ | |
expect(penniesFor(1)).toEqual([1]); | |
}); | |
it('should return 2 pennies for 2 cents', function(){ | |
expect(penniesFor(2)).toEqual([2]); | |
}); | |
}); | |
describe('given nickels and pennies', function(){ | |
var nickelsPenniesFor; | |
beforeEach(function(){ | |
nickelsPenniesFor = _.curry($scope.changeFor)([5, 1]); | |
}); | |
it('should return nothing for 0 cents', function(){ | |
expect(nickelsPenniesFor(0)).toEqual([0, 0]); | |
}); | |
it('should return 1 penny for 1 cents', function(){ | |
expect(nickelsPenniesFor(1)).toEqual([0, 1]); | |
}); | |
it('should return 1 nickel and 1 penny for 6 cents', function(){ | |
expect(nickelsPenniesFor(6)).toEqual([1, 1]); | |
}); | |
}); | |
describe('given quarters, dimes, nickels, and pennies', function(){ | |
var changeFor; | |
beforeEach(function(){ | |
changeFor = _.curry($scope.changeFor)([25, 10, 5, 1]); | |
}); | |
it('should return nothing for 0 cents', function(){ | |
expect(changeFor(0)).toEqual([0, 0, 0, 0]); | |
}); | |
it('should return 1 penny for 1 cents', function(){ | |
expect(changeFor(1)).toEqual([0, 0, 0, 1]); | |
}); | |
it('should return 3 quarters, 2 dimes, 0 nickels and 4 pennies for 99 cents', function(){ | |
expect(changeFor(99)).toEqual([3, 2, 0, 4]); | |
}); | |
}); | |
}); |
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
<!DOCTYPE html> | |
<html ng-app="coinChangerApp"> | |
<head> | |
<meta charset="utf-8" /> | |
<title>AngularJS test</title> | |
<link rel="stylesheet" href="style.css" /> | |
<script data-require="angular.js" data-semver="1.2.16" src="https://ajax.googleapis.com/ajax/libs/angularjs/1.2.16/angular.min.js"></script> | |
<script data-require="lodash.js@*" data-semver="2.4.1" src="http://cdnjs.cloudflare.com/ajax/libs/lodash.js/2.4.1/lodash.js"></script> | |
<script src="app.js"></script> | |
</head> | |
<body ng-controller="CoinChangerCtrl" ng-init="coins = [25, 10, 5, 1]"> | |
<div>Make change for</div> | |
<input ng-model="value"> | |
<table ng-if="value"> | |
<thead> | |
<tr> | |
<td>Coin value</td> | |
<td>Number of coins</td> | |
</tr> | |
</thead> | |
<tbody ng-repeat="coin in changeFor(coins, value) track by $index"> | |
<tr> | |
<td>{{coins[$index]}}</td> | |
<td>{{coin}}</td> | |
</tr> | |
</tbody> | |
</table> | |
</body> | |
</html> |
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() { | |
var jasmineEnv = jasmine.getEnv(); | |
jasmineEnv.updateInterval = 250; | |
/** | |
Create the `HTMLReporter`, which Jasmine calls to provide results of each spec and each suite. The Reporter is responsible for presenting results to the user. | |
*/ | |
var htmlReporter = new jasmine.HtmlReporter(); | |
jasmineEnv.addReporter(htmlReporter); | |
/** | |
Delegate filtering of specs to the reporter. Allows for clicking on single suites or specs in the results to only run a subset of the suite. | |
*/ | |
jasmineEnv.specFilter = function(spec) { | |
return htmlReporter.specFilter(spec); | |
}; | |
/** | |
Run all of the tests when the page finishes loading - and make sure to run any previous `onload` handler | |
### Test Results | |
Scroll down to see the results of all of these specs. | |
*/ | |
var currentWindowOnload = window.onload; | |
window.onload = function() { | |
if (currentWindowOnload) { | |
currentWindowOnload(); | |
} | |
//document.querySelector('.version').innerHTML = jasmineEnv.versionString(); | |
execJasmine(); | |
}; | |
function execJasmine() { | |
jasmineEnv.execute(); | |
} | |
})(); |
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
/* restore "body" styling that were changes by "jasmine.css"... */ | |
body { background-color: white; padding: 0; margin: 8px; } | |
/* ... but remain the "jasmine.css" styling for the Jasmine reporting */ | |
.jasmine_reporter { background-color: #eeeeee; padding: 0; margin: 0; } |
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
<!DOCTYPE html> | |
<html ng-app="coinChangerApp"> | |
<head> | |
<meta charset="utf-8" /> | |
<title>AngularJS test</title> | |
<link data-require="jasmine" data-semver="2.0.0" rel="stylesheet" href="//cdn.jsdelivr.net/jasmine/2.0.0/jasmine.css" /> | |
<script data-require="json2" data-semver="0.0.2012100-8" src="//cdnjs.cloudflare.com/ajax/libs/json2/20121008/json2.js"></script> | |
<script data-require="jasmine" data-semver="2.0.0" src="//cdn.jsdelivr.net/jasmine/2.0.0/jasmine.js"></script> | |
<script data-require="jasmine" data-semver="2.0.0" src="//cdn.jsdelivr.net/jasmine/2.0.0/jasmine-html.js"></script> | |
<script data-require="jasmine@*" data-semver="2.0.0" src="//cdn.jsdelivr.net/jasmine/2.0.0/boot.js"></script> | |
<script data-require="angular.js" data-semver="1.2.16" src="https://ajax.googleapis.com/ajax/libs/angularjs/1.2.16/angular.min.js"></script> | |
<script data-require="angular-mocks" data-semver="1.2.16" src="https://code.angularjs.org/1.2.16/angular-mocks.js"></script> | |
<script data-require="lodash.js@*" data-semver="2.4.1" src="http://cdnjs.cloudflare.com/ajax/libs/lodash.js/2.4.1/lodash.js"></script> | |
<link rel="stylesheet" href="style.css" /> | |
<script src="app.js"></script> | |
<script src="appSpec.js"></script> | |
<script src="jasmineBootstrap.js"></script> | |
<!-- bootstraps Jasmine --> | |
</head> | |
<body> | |
<div id="HTMLReporter" class="jasmine_reporter"></div> | |
</body> | |
</html> |
We have the following for the system code of the changeFor function.
$scope.changeFor = function(coins, amount) {
if (_.isEmpty(coins)) return [];
return _.reduce(coins, function(m, coin) {
m.change.push(Math.floor(m.amount / coin));
m.amount %= coin;
return m;
}, {
amount: amount,
change: []
}).change;
};
We see that lodash's isEmpty makes sure that we have an array of coins before we go to the aggregation using the reduce function. In the reduce we create an object with the current amount and change calculated so that we do not have to mutate the values passed in to the changeFor function.
We test the cases when we have no coins being passed in with the following code from appSpec.js.
describe('given no coins', function() {var noCoinsFor;beforeEach(function() {noCoinsFor = _.curry($scope.changeFor)([]);});it('should return nothing for 0 cents', function() {expect(noCoinsFor(0)).toEqual([]);});it('should return nothing for 99 cents', function() {expect(noCoinsFor(99)).toEqual([]);});});
We set up changeFor function by currying with an empty array for the coins in the beforeEach in the top level describe. Using curry in the beforeEach we do not have to pass in the empty array on each call in the its. This makes the test a bit more readable.
Similarly when we test the penny series we can curry the penny.
describe('given pennies', function(){var penniesFor;beforeEach(function(){penniesFor = _.curry($scope.changeFor)([1]);});it('should return nothing for 0 cents', function(){expect(penniesFor(0)).toEqual([0]);});it('should return 1 penny for 1 cents', function(){expect(penniesFor(1)).toEqual([1]);});it('should return 2 pennies for 2 cents', function(){expect(penniesFor(2)).toEqual([2]);});});
This makes the grouping in the describe seem more logical and the test more readable.
The same with the nickel and penny.
describe('given nickels and pennies', function(){var nickelsPenniesFor;beforeEach(function(){nickelsPenniesFor = _.curry($scope.changeFor)([5, 1]);});it('should return nothing for 0 cents', function(){expect(nickelsPenniesFor(0)).toEqual([0, 0]);});it('should return 1 penny for 1 cents', function(){expect(nickelsPenniesFor(1)).toEqual([0, 1]);});it('should return 1 nickel and 1 penny for 6 cents', function(){expect(nickelsPenniesFor(6)).toEqual([1, 1]);});});
Finally our integration tests for US coins.
describe('given quarters, dimes, nickels, and pennies', function(){var changeFor;beforeEach(function(){changeFor = _.curry($scope.changeFor)([25, 10, 5, 1]);});it('should return nothing for 0 cents', function(){expect(changeFor(0)).toEqual([0, 0, 0, 0]);});it('should return 1 penny for 1 cents', function(){expect(changeFor(1)).toEqual([0, 0, 0, 1]);});it('should return 3 quarters, 2 dimes, 0 nickels and 4 pennies for 99 cents', function(){expect(changeFor(99)).toEqual([3, 2, 0, 4]);});});
By using the curry function we have added to the readability of the Jasmine tests. This style is a bit different than the system code in the AngularJS application.
Check out the plunker which goes with this post.