Gleb Bahmutov PRO
JavaScript ninja, image processing expert, software quality fanatic
Dr. Gleb Bahmutov PhD
Kensho Boston / NY / SF
Angular Remote Conf - Backup
Dr. Gleb Bahmutov PhD
Kensho Boston / NY / SF
Angular Remote Conf - Backup BONUS
tests?
tests
Write code
Refactor (iterate)
get paid
Write code
Refactor (iterate)
get paid
Write code
Write tests
Refactor (iterate)
get paid
Keep the app working
Write tests
Write code
Refactor (iterate)
get paid
Test Driven Development
Write tests
Refactor
Write code
get paid
Take away: before we can refactor - write tests
but:
writing tests cuts our profits
Solution: effective testing
Unit tests: small, fast
E2E tests: large, slow
use Protractor (not in this presentation)
use Karma
function add(a, b) {
return a + b;
}
describe('addition', function () {
});
it('adds numbers', function () {
expect(add(2, 3)).toEqual(5);
});
it('concatenates', function () {
expect(add('foo', 'bar')).toEqual('foobar');
});
add.js
add-spec.js
unit test
unit test
$ npm install -g karma
$ karma init
$ npm install karma-jasmine
module.exports = function(config) {
config.set({
// available frameworks: https://npmjs.org/browse/keyword/karma-adapter
frameworks: ['jasmine'],
files: [
'add.js', 'add-spec.js'
],
// available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
preprocessors: {
},
reporters: ['progress'],
// available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
browsers: ['Chrome']
});
};
$ karma start
$ karma start --single-run=true
INFO [karma]: Karma v0.12.21 server started at http://localhost:9876/
INFO [launcher]: Starting browser Chrome
INFO [Chrome 44.0.2403 (Mac OS X 10.10.2)]:
Connected on socket 6Hp2TOIipFToLo8rgaIk with id 32903062
Chrome 44.0.2403 (Mac OS X 10.10.2):
Executed 2 of 2 SUCCESS (0.007 secs / 0.004 secs)
$ npm install karma-coverage --save-dev
// karma.conf.js
module.exports = function(config) {
config.set({
...
preprocessors: {
'*.js': 'coverage'
},
reporters: ['progress', 'coverage'],
...
});
};
Tip: use coverage preprocessor for code only, not for test files
$ karma start --single-run=true
$ open coverage/Chrome/index.html
The dependency injection in AngularJS allows you to declaratively describe how your application is wired. This means that your application needs no main() method which is usually an unmaintainable mess. Dependency injection is also a core to AngularJS. This means that any component which does not fit your needs can easily be replaced.
AngularJS was designed from ground up to be testable. It encourages behavior-view separation, comes pre-bundled with mocks, and takes full advantage of dependency injection. It also comes with end-to-end scenario runner which eliminates test flakiness by understanding the inner workings of AngularJS.
angular.module('TodoApp', [])
.controller('TodoController', function ($scope) {
$scope.todos = ['do this', 'do that'];
});
Todo example (controller uses a scope)
angular-mocks - inject modules into tests, sync methods
Angularjs - Unit testing introduction by Nic Kaufman
1, 2, 3, tested ng-describe tutorial
describe('standard unit test', function () {
beforeEach(angular.mock.module('TodoApp'));
var $controller, $rootScope;
beforeEach(angular.mock.inject(
function (_$controller_, _$rootScope_) {
$controller = _$controller_;
$rootScope = _$rootScope_;
}
));
it('creates a controller', function () {
var scope = $rootScope.$new();
$controller('TodoController', {
$scope: scope
});
expect(scope.todos.length).toEqual(2, 'has 2 todo items');
});
});
angular.module('TodoApp', [])
.controller('TodoController', function ($scope) {
$scope.todos = ['do this', 'do that'];
});
Unit testing Todo example
angular.module('TodoApp', [])
.controller('TodoController', function ($scope) {
$scope.todos = ['do this', 'do that'];
});
Unit testing Todo example
describe('standard unit test', function () {
beforeEach(angular.mock.module('TodoApp'));
var $controller, $rootScope;
beforeEach(inject(function (_$controller_, _$rootScope_) {
$controller = _$controller_;
$rootScope = _$rootScope_;
}));
it('creates a controller', function () {
var scope = $rootScope.$new();
$controller('TodoController', {
$scope: scope
});
expect(scope.todos.length).toEqual(2, 'has 2 todo items');
});
});
The same dependency injection that makes AngularJS great fights you during testing
describe('standard unit test', function () {
beforeEach(angular.mock.module('TodoApp'));
var $controller, $rootScope;
beforeEach(inject(function (_$controller_, _$rootScope_) {
$controller = _$controller_;
$rootScope = _$rootScope_;
}));
it('creates a controller', function () {
var scope = $rootScope.$new();
$controller('TodoController', {
$scope: scope
});
expect(scope.todos.length).toEqual(2, 'has 2 todo items');
});
});
The same scope magic that makes AngularJS great fights you during testing
describe('standard unit test', function () {
beforeEach(angular.mock.module('TodoApp'));
var $controller, $rootScope;
beforeEach(inject(function (_$controller_, _$rootScope_) {
$controller = _$controller_;
$rootScope = _$rootScope_;
}));
it('creates a controller', function () {
var scope = $rootScope.$new();
$controller('TodoController', {
$scope: scope
});
expect(scope.todos.length).toEqual(2, 'has 2 todo items');
});
});
I want
ngDescribe({
module: 'TodoApp',
controller: 'TodoController',
tests: function (deps) {
it('creates a controller', function () {
expect(deps.TodoController.todos.length).toEqual(2);
});
}
});
same unit testing using ng-describe
ngDescribe({
module: 'TodoApp',
controller: 'TodoController',
tests: function (deps) {
it('creates a controller', function () {
expect(deps.TodoController.todos.length).toEqual(2);
});
}
});
AngularJS (magically) creates controller and $scope for you
ng-describe (magically) creates controller and $scope for you during testing
angular.module('TodoApp', [])
.controller('TodoController', function ($scope) {
$scope.todos = ['do this', 'do that'];
});
ngDescribe({
module: 'TodoApp',
controller: 'TodoController',
element: '<myDirective ....></myDirective>',
parentScope: { ... },
inject: ['foo', 'bar', '$q'],
tests: function (deps) {
// deps will have everything that ng-describe creates
it('works', function () {
...
});
}
});
ngDescribe recreates everything from scratch before every unit test
ngDescribe({
module: 'TodoApp',
controller: 'TodoController',
element: '<myDirective ....></myDirective>',
parentScope: { ... },
inject: ['foo', 'bar', '$q'],
tests: function (deps) {
beforeAll(function ...);
beforeEach(function ...);
it('works', function () {
...
});
afterEach(function ...);
afterAll(function ...);
}
});
Can we compute boilerplate / code ratio?
ngDescribe({ // Boilerplate
module: 'TodoApp', // Code
original boilerplate / code ratio = 2.7
describe('standard unit test', function () { // B
beforeEach(angular.mock.module('TodoApp')); // C
var $controller, $rootScope; // B
beforeEach(inject(function (_$controller_, _$rootScope_) { // B
$controller = _$controller_; // B
$rootScope = _$rootScope_; // B
}));
it('creates a controller', function () { // B
var scope = $rootScope.$new(); // B
$controller('TodoController', { // C
$scope: scope // B
});
expect(scope.todos.length).toEqual(2, 'has 2 todo items'); // C
});
}); // B / C = 8 / 3 = 2.67
Trying to cram lots of assertions into single test
describe('standard unit test', function () {
beforeEach(angular.mock.module('TodoApp'));
var $controller, $rootScope;
beforeEach(inject(function (_$controller_, _$rootScope_) {
$controller = _$controller_;
$rootScope = _$rootScope_;
}));
it('creates a controller', function () {
var scope = $rootScope.$new();
$controller('TodoController', {
$scope: scope
});
expect(Array.isArray(deps.TodoController.todos)).toBe(true);
expect(scope.todos.length).toEqual(2, 'has 2 todo items');
expect(...);
expect(...);
});
});
ngDescribe({ // B
module: 'TodoApp', // C
controller: 'TodoController', // C
tests: function (deps) { // B
it('creates a controller', function () { // B
expect(deps.TodoController.todos.length).toEqual(2); // C
});
}
}); // B / C = 3 / 3 = 1.0
ng-describe boilerplate / code ratio = 1
ngDescribe({
module: 'TodoApp',
controller: 'TodoController',
tests: function (deps) {
it('has a list of todos', function () {
expect(Array.isArray(deps.TodoController.todos)).toBe(true);
});
it('has two todos', function () {
expect(deps.TodoController.todos.length).toEqual(2);
});
}
});
angular.module('A', [])
.value('foo', 'bar');
Need module "A"
Inject "foo"
// using value 'foo' from module 'A'
angular.module('useA', ['A'])
.controller(function (foo, $scope) {
$scope.name = foo;
});
angular.module('A', [])
.value('foo', 'bar');
ngDescribe({
module: 'A',
inject: 'foo',
tests: function (deps) {
}
it('works', function () {
expect(deps.foo).toEqual('bar');
});
});
npm install ng-describe --save-dev
// karma.conf.js
files: [
'node_modules/angular/angular.js',
'node_modules/angular-mocks/angular-mocks.js',
'node_modules/ng-describe/dist/ng-describe.js',
'<your source.js>',
'<your specs.js>'
],
frameworks: ['mocha'], // or jasmine
beforeAll, afterAll, fit, it.only, xit - they all work, ng-describe does not affect them
ngDescribe(optionsObject);
// single function taking
// a single options object
All properties in the options object: kensho/ng-describe#api
angular.module('A', [])
.value('foo', 'bar');
ngDescribe({
module: 'A',
inject: 'foo',
tests: function (deps) {
it('has foo', function () {
expect(deps.foo).toEqual('bar', 'foo is ' + deps.foo);
});
}
});
// equivalent lazy-ass assertion
la(deps.foo === 'bar', 'foo is', deps.foo);
// la(condition, any, number, of, messages, objects, etc,
// to be included in the exception);
angular.module('A', [])
.value('foo', 'bar');
ngDescribe({
module: 'A',
inject: 'foo',
tests: function (deps) {
it('has foo', function () {
la(check.has(deps, 'foo'), 'foo was injected');
la(check.equal(deps.foo, 'bar'), 'foo is "bar"');
});
}
});
bahmutov/lazy-ass - lazy assertion message formatting
kensho/check-more-types - lots of predicates
We also monitor the code quality using Codacy
ngDescribe({
name: 'not ready yet',
skip: true,
tests: function (deps) { ... }
});
ngDescribe({
name: 'just this feature',
only: true,
tests: function (deps) { ... }
});
ngDescribe({
name: 'logical contradiction',
only: true,
skip: true,
tests: function (deps) { ... }
});
// ERROR!
ngDescribe({
name: 'let us find the problem',
only: true,
verbose: true,
tests: function (deps) { ... }
});
// prints messages on suite setup, etc.
See all examples at kensho/ng-describe
Filter
Async
Directive
Mock $http
Mock everything
No time for spying examples
ngDescribe({
name: 'built-in filter',
inject: '$filter',
tests: function (deps) {
}
});
la = lazy assertion, github.com/bahmutov/lazy-ass
it('can convert to lowercase', function () {
var lowercase = deps.$filter('lowercase');
la(lowercase('Foo') === 'foo');
});
ngDescribe({
inject: ['$q', '$rootScope'],
tests: function (deps) {
}
});
$rootScope - boilerplate digest cycle code!
it('can be resolved', function (done) {
deps.$q.when(42).then(function (value) {
expect(value).toEqual(42);
done();
});
// move promises along
deps.$rootScope.$digest();
});
ngDescribe({
inject: '$q',
tests: function (deps) {
it('can be resolved', function (done) {
deps.$q.when(42).then(function (value) {
expect(value).toEqual(42);
done();
});
// move promises along
deps.step();
});
}
});
using deps.step() shortcut
angular.module('MyFoo', [])
.directive('myFoo', function () {
return {
restrict: 'E',
replace: true,
template: '<span>{{ bar }}</span>'
};
});
ngDescribe({
name: 'MyFoo directive',
modules: 'MyFoo',
element: '<my-foo></my-foo>',
// you can also setup custom parent scope
tests: function (deps) {
}
});
it('can update DOM using binding', function () {
la(check.has(deps, 'element'));
var scope = deps.element.scope();
scope.bar = 'bar';
scope.$apply(); // or deps.step()
la(deps.element.html() === 'bar');
});
function setupElement(elementHtml) {
var scope = dependencies.$rootScope.$new();
angular.extend(scope,
angular.copy(options.parentScope));
var element = angular.element(elementHtml);
var compiled = dependencies.$compile(element);
compiled(scope);
dependencies.$rootScope.$digest();
dependencies.element = element;
dependencies.parentScope = scope;
}
if (options.element) {
beforeEach(setupElement.bind(null, options.element));
}
// studyFlags directive makes HTTP call on init
ngDescribe({
exposeApi: true,
inject: '$httpBackend',
tests: function (deps, describeApi) {
});
});
beforeEach(function () {
deps.$httpBackend
.expectGET('/api/foo/bar').respond(500);
});
beforeEach(function () {
// now create an element ourselves
describeApi.setupElement('<study-flags />');
});
it('created an element', function () {
la(check.has(deps.element));
});
ngDescribe({
http: {
get: {
'/some/url': 42,
'/some/other/url': [500, 'something went wrong']
},
post: {
// you can use custom functions too
'/some/post/url': function (method, url, data, headers) {
return [200, 'ok'];
}
}
},
inject: '$http',
tests: function (deps) {
}
});
Shorthand notation for $httpBackend
it('has meaning', function (done) {
$http.get('/some/url').success(function (value) {
expect(value).toEqual(42);
done();
});
deps.step();
});
angular.module('User', [])
.value('greeting', function greeting() {
return 'Hello';
})
.value('username', function username($http) {
return $http.get('/user/name');
});
Service with 1 http GET and one local method
angular.module('GreetUser', ['User'])
.value('helloUser', function (greeting, username) {
return username()
.then(function (name) {
return greeting() + ' ' + name + '!';
});
});
Use the service with "mixed" calls
Server
User
GreetUser
GET
ngDescribe({
module: 'GreetUser',
inject: ['helloUser', '$rootScope'],
tests: function (deps) {
it('makes http request', function (done) {
deps.helloUser()
.then(function (value) {
// hmm
})
.finally(done);
deps.$rootScope.$apply();
});
}
});
ட default tests
ட ✘ makes http request FAILED
Error: Unexpected request: GET /user/name
No more request expected
Server
User
GreetUser
Test
GET
ngDescribe({
module: 'GreetUser',
inject: ['helloUser', '$rootScope'],
http: {
get: {
'/user/name': 'World'
}
},
tests: function (deps) {
it('makes http request', function (done) {
...
});
}
});
BAD!
Server
User
GreetUser
Test
GET
angular.module('GreetUser', ['User'])
.value('helloUser', function (greeting, username) {
return username()
.then(function (name) {
return greeting() + ' ' + name + '!';
});
});
Leave unchanged
Mock
ngDescribe({
module: 'GreetUser',
inject: ['helloUser', '$rootScope'],
mock: {
User: {
username: function ($q) {
return $q.when('Test');
}
}
},
tests: function (deps) {
it('does not make http requests', function (done) {
deps.helloUser()
.then(function (value) {
la(value === 'Hello Test!');
})
.finally(done);
deps.$rootScope.$apply();
});
}
});
replaces User.username with this code for testing
ngDescribe({
module: 'GreetUser',
inject: ['helloUser', '$rootScope'],
mock: {
GreetUser: {
username: function ($q) {
return $q.when('Test');
}
}
},
angular.module('GreetUser', ['User'])
.value('helloUser', function (greeting, username) {
return username()
.then(function (name) {
return greeting() + ' ' + name + '!';
});
});
ngDescribe(optionsObject);
// single function taking
// a single options object
ngDescribe({
module: 'A'
});
// plural
ngDescribe({
module: ['A', 'B']
});
// and
ngDescribe({
modules: ['A', 'B']
});
ngDescribe({
name: 'A',
module: 'A',
tests: function (deps) {
...
});
})({
name: 'A with B',
modules: ['A', 'B'],
tests: function (deps) {
...
});
});
ngDescribe({
name: 'A',
module: 'A',
tests: function (deps) {
...
});
})({
name: 'A with B',
modules: ['A', 'B'],
tests: function (deps) {
...
});
});
})({
ngDescribe({
...
})({
...
});
})({
it('does something', function () {
...
expect(foo).toEqual('bar');
});
test "does something" failed
Error:
it('does something', function () {
...
expect(foo).toEqual('bar', 'expected foo to equal "bar"');
});
test "does something" failed
Error: expected foo to equal "bar"
What is the value of "foo"?
it('does something', function () {
...
expect(foo).toEqual('bar',
'expected foo ' + foo + ' to equal "bar"');
});
test "does something" failed
Error: expected foo something to equal "bar"
hmm, boilerplate code again
helpDescribe(function () {
ngDescribe({
name: 'example',
tests: function (deps) {
it('does something', function () {
...
la(deps.foo === 'bar');
});
}
});
});
test "does something" failed
Error: condition [deps.foo === 'bar'] deps: { foo: ... }
lazy-ass-helpful works by on the fly rewriting test function's code
Load Angular from Node?
Synthetic browser with benv
// npm install benv
var benv = require('benv');
benv.setup(function () {
benv.expose({
angular: benv.require('node_modules/angular/angular.js', 'angular')
});
console.log('window is', typeof window);
console.log('document is', typeof document);
console.log('angular is', typeof angular);
console.log('window.angular === angular', window.angular === angular);
});
Use Angular controller
// app.js
angular.module('HelloApp', [])
.controller('HelloController', function ($scope) {
$scope.names = ['John', 'Mary'];
$scope.addName = function () {
$scope.names.push(nextName());
};
});
// load.js
var benv = require('benv');
benv.setup(function () {
benv.expose({
angular: benv.require('node_modules/angular/angular.js', 'angular')
});
require('./app');
// confirm that module HelloApp has controller HelloController
var $controller = angular.injector(['ng', 'HelloApp']).get('$controller');
var scope = {};
$controller('HelloController', { $scope: scope });
console.log(scope.names);
// prints [ 'John', 'Mary' ]
});
Node (CommonJS) require
// app.js
angular.module('HelloApp', [])
.controller('HelloController', function ($scope) {
$scope.names = ['John', 'Mary'];
$scope.addName = function () {
$scope.names.push(nextName());
};
});
// load.js
var benv = require('benv');
benv.setup(function () {
benv.expose({
angular: benv.require('node_modules/angular/angular.js', 'angular')
});
require('./app');
// confirm that module HelloApp has controller HelloController
var $controller = angular.injector(['ng', 'HelloApp']).get('$controller');
var scope = {};
$controller('HelloController', { $scope: scope });
console.log(scope.names);
// prints [ 'John', 'Mary' ]
});
Node (CommonJS) require
Node `require` allows preprocessor hook
// npm install node-hook
var hook = require('node-hook');
function logLoadedFilename(source, filename) {
return 'console.log("' + filename + '");\n' + source;
}
hook.hook('.js', logLoadedFilename);
require('./dummy');
// prints fulle dummy.js filename, runs dummy.js
hook.unhook('.js'); // removes your own transform
node-hook - one or multiple hooks
really-need - code coverage, cache bust, code transformation
// npm install really-need
require = require('really-need');
// global require is now a better one!
var foo = require('./foo', {
// remove previously loaded foo module
bustCache: true,
// remove from cache AFTER loading
keep: false,
pre: function (source, filename) {
// transform the source before compiling it
return source;
},
post: function (exported, filename) {
// transform the exported object
return exported;
},
// inject additional values into foo.js
args: {
a: 10,
b: 5,
__dirname: '/some/path'
}
});
Reach into private closures: describe-it
// foo.js
(function reallyPrivate() {
function getFoo() {
return 'foo';
}
}());
// get-foo-spec.js
var describeIt = require('describe-it');
describeIt(__dirname + '/foo.js', 'getFoo()', function (getFn) {
it('returns "foo"', function () {
var getFoo = getFn();
console.assert(getFoo() === 'foo');
});
});
// app.js
(function () {
function nextName() { return 'World'; }
angular.module('HelloApp', [])
.controller('HelloController', function ($scope) {
$scope.names = ['John', 'Mary'];
$scope.addName = function () {
$scope.names.push(nextName());
};
});
}());
// next-name-spec.js
var ngDice = require('ng-dice');
ngDice({
file: __dirname + '/app.js',
extract: 'nextName()',
tests: function (codeExtract) {
it('returns next name', function () {
var nextName = codeExtract();
console.assert(nextName() === 'World');
});
}
});
Kensho Boston / NYC / SF
Angular Remote Conf - Backup
By Gleb Bahmutov
Testing Angular code requires too much boilerplate. In this presentation I will show ng-describe - a BDD helper that removes all extra code and allows to start unit testing in seconds. Then I will show ng-dice - a combination of dependency injection and code extraction for testing Angular taking advantage of Node require hooks.
JavaScript ninja, image processing expert, software quality fanatic