Dr. Gleb Bahmutov PhD
Kensho Boston / NYC
100s of OSS projects at glebbahmutov.com/
100s of blog posts at glebbahmutov.com/blog/
@bahmutov Kensho
@bahmutov Kensho
@bahmutov Kensho
@bahmutov Kensho
@bahmutov Kensho
Why invest in writing an OSS testing library?
@bahmutov Kensho
tests?
@bahmutov Kensho
tests
@bahmutov Kensho
@bahmutov Kensho
Write code
Refactor (iterate)
get paid
@bahmutov Kensho
Write code
Refactor (iterate)
get paid
@bahmutov Kensho
Write code
Write tests
Refactor (iterate)
get paid
Keep the app working
@bahmutov Kensho
Write tests
Write code
Refactor (iterate)
get paid
Test Driven Development
@bahmutov Kensho
Write tests
Refactor
Write code
get paid
@bahmutov Kensho
@bahmutov Kensho
@bahmutov Kensho
Unit tests: small, fast
E2E tests: large, slow
use Protractor (not in this presentation)
use Karma
@bahmutov Kensho
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
@bahmutov Kensho
$ npm install -g karma
$ karma init
$ npm install karma-mocha
module.exports = function(config) {
config.set({
// available frameworks: https://npmjs.org/browse/keyword/karma-adapter
frameworks: ['mocha'],
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
@bahmutov Kensho
$ 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)
@bahmutov Kensho
$ 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
@bahmutov Kensho
$ karma start --single-run=true
$ open coverage/Chrome/index.html
@bahmutov Kensho
filename lines coverage
------------------------------
foo.js 100 25%
bar.js 10 20%
@bahmutov Kensho
@bahmutov Kensho
@bahmutov Kensho
bundle unit test coverage
----------------------------------
dist/foo.js 40%
dist/bar.js 70%
dist/baz.js 10%
@bahmutov Kensho
bahmutov/was-tested - code coverage from live websites
bahmutov/tested-commits - splits code coverage info from any source by commit
bundle total coverage = unit tests + live
--------------------------------------------------
dist/foo.js 40% 35% 5%
dist/bar.js 70% 35% 35%
dist/baz.js 10% 10% 0%
@bahmutov Kensho
@bahmutov Kensho
@bahmutov Kensho
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.
@bahmutov Kensho
@bahmutov Kensho
angular.module('TodoApp', [])
.controller('TodoController', function ($scope) {
$scope.todos = ['do this', 'do that'];
});
// <li ng-repeat="todo in todos">{{ todo }}</li>
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
@bahmutov Kensho
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(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');
});
});
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(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');
});
});
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(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');
});
});
Goal: start testing without boilerplate
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');
});
});
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
@bahmutov Kensho
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
@bahmutov Kensho
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 ...);
}
});
@bahmutov Kensho
Can we compute boilerplate / code ratio?
ngDescribe({ // Boilerplate
module: 'TodoApp', // Code
@bahmutov Kensho
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
@bahmutov Kensho
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(...);
});
});
@bahmutov Kensho
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
@bahmutov Kensho
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);
});
}
});
@bahmutov Kensho
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
@bahmutov Kensho
ngDescribe(optionsObject);
// single function taking
// a single options object
All properties in the options object: kensho/ng-describe#api
@bahmutov Kensho
beforeAll, afterAll, fit, it.only, xit - they all work, ng-describe does not affect them
@bahmutov Kensho
@bahmutov Kensho
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
@bahmutov Kensho
See all examples at kensho/ng-describe
Filter
Async
Directive
Mock $http
Mock everything
...
@bahmutov Kensho
ngDescribe({
name: 'built-in filter',
inject: '$filter',
tests: function (deps) {
}
});
it('can convert to lowercase', function () {
var lowercase = deps.$filter('lowercase');
la(lowercase('Foo') === 'foo');
});
@bahmutov Kensho
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();
});
@bahmutov Kensho
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
@bahmutov Kensho
angular.module('MyFoo', [])
.directive('myFoo', function () {
return {
restrict: 'E',
replace: true,
template: '<span>{{ bar }}</span>'
};
});
@bahmutov Kensho
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));
}
@bahmutov Kensho
// 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();
});
@bahmutov Kensho
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
@bahmutov Kensho
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
@bahmutov Kensho
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
@bahmutov Kensho
angular.module('GreetUser', ['User'])
.value('helloUser', function (greeting, username) {
return username()
.then(function (name) {
return greeting() + ' ' + name + '!';
});
});
Leave unchanged
Mock
@bahmutov Kensho
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 + '!';
});
});
@bahmutov Kensho
ngDescribe(optionsObject);
// single function taking
// a single options object
@bahmutov Kensho
ngDescribe({
module: 'A'
});
// plural
ngDescribe({
module: ['A', 'B']
});
// and
ngDescribe({
modules: ['A', 'B']
});
@bahmutov Kensho
ngDescribe({
name: 'A',
module: 'A',
tests: function (deps) {
...
});
})({
name: 'A with B',
modules: ['A', 'B'],
tests: function (deps) {
...
});
});
@bahmutov Kensho
ngDescribe({
name: 'A',
module: 'A',
tests: function (deps) {
...
});
})({
name: 'A with B',
modules: ['A', 'B'],
tests: function (deps) {
...
});
});
})({
@bahmutov Kensho
ngDescribe({
...
})({
...
});
})({
@bahmutov Kensho
ngDescribe({
name: 'not ready yet',
skip: true,
tests: function (deps) { ... }
});
@bahmutov Kensho
ngDescribe({
name: 'just this feature',
only: true,
tests: function (deps) { ... }
});
@bahmutov Kensho
ngDescribe({
name: 'logical contradiction',
only: true,
skip: true,
tests: function (deps) { ... }
});
// ERROR!
@bahmutov Kensho
ngDescribe({
name: 'let us find the problem',
only: true,
verbose: true,
tests: function (deps) { ... }
});
// prints messages on suite setup, etc.
@bahmutov Kensho
it('does something', function () {
...
expect(foo).toEqual('bar');
});
test "does something" failed
Error:
@bahmutov Kensho
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"?
@bahmutov Kensho
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
@bahmutov Kensho
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
@bahmutov Kensho
@bahmutov Kensho
@bahmutov Kensho
100% code coverage, 4x LOC of tests, 75 closed issues.
@bahmutov Kensho
We also monitor the code quality using Codacy
@bahmutov Kensho
@bahmutov Kensho
@bahmutov Kensho
@bahmutov Kensho
Boston / NYC
https://kensho.com/#/careers Python, JS, data, machine learning
@bahmutov Kensho
Last slide with links
Take a picture!
@bahmutov Kensho