feat(test): Add an external version of the test library

Adds test adapters for TypeScript and JavaScript only, exported
as part of the test_lib module. These work with the Jasmine test
framework, and allow use of the test injector within test blocks
via the `inject` function.

See #4572, #4177, #4035, #2783

This includes the TestComponentBuilder. It allows using the
test injector with Jasmine bindings, and waits for returned
promises before completing async test blocks.
This commit is contained in:
Julie Ralph
2015-10-08 15:33:17 -07:00
parent eb2c15786e
commit a1fa2e472f
12 changed files with 844 additions and 303 deletions

View File

@ -0,0 +1,112 @@
library test_lib.matchers;
import 'dart:async';
import 'package:guinness/guinness.dart' as gns;
import 'package:angular2/src/core/dom/dom_adapter.dart' show DOM;
Expect expect(actual, [matcher]) {
final expect = new Expect(actual);
if (matcher != null) expect.to(matcher);
return expect;
}
const _u = const Object();
expectErrorMessage(actual, expectedMessage) {
expect(actual.toString()).toContain(expectedMessage);
}
expectException(Function actual, expectedMessage) {
try {
actual();
} catch (e, s) {
expectErrorMessage(e, expectedMessage);
}
}
class Expect extends gns.Expect {
Expect(actual) : super(actual);
NotExpect get not => new NotExpect(actual);
void toEqual(expected) => toHaveSameProps(expected);
void toContainError(message) => expectErrorMessage(this.actual, message);
void toThrowError([message = ""]) => toThrowWith(message: message);
void toThrowErrorWith(message) => expectException(this.actual, message);
void toBePromise() => gns.guinness.matchers.toBeTrue(actual is Future);
void toHaveCssClass(className) =>
gns.guinness.matchers.toBeTrue(DOM.hasClass(actual, className));
void toImplement(expected) => toBeA(expected);
void toBeNaN() =>
gns.guinness.matchers.toBeTrue(double.NAN.compareTo(actual) == 0);
void toHaveText(expected) => _expect(elementText(actual), expected);
void toHaveBeenCalledWith([a = _u, b = _u, c = _u, d = _u, e = _u, f = _u]) =>
_expect(_argsMatch(actual, a, b, c, d, e, f), true,
reason: 'method invoked with correct arguments');
Function get _expect => gns.guinness.matchers.expect;
// TODO(tbosch): move this hack into Guinness
_argsMatch(spyFn, [a0 = _u, a1 = _u, a2 = _u, a3 = _u, a4 = _u, a5 = _u]) {
var calls = spyFn.calls;
final toMatch = _takeDefined([a0, a1, a2, a3, a4, a5]);
if (calls.isEmpty) {
return false;
} else {
gns.SamePropsMatcher matcher = new gns.SamePropsMatcher(toMatch);
for (var i = 0; i < calls.length; i++) {
var call = calls[i];
// TODO: create a better error message, not just 'Expected: <true> Actual: <false>'.
// For hacking this is good:
// print(call.positionalArguments);
if (matcher.matches(call.positionalArguments, null)) {
return true;
}
}
return false;
}
}
List _takeDefined(List iter) => iter.takeWhile((_) => _ != _u).toList();
}
class NotExpect extends gns.NotExpect {
NotExpect(actual) : super(actual);
void toEqual(expected) => toHaveSameProps(expected);
void toBePromise() => gns.guinness.matchers.toBeFalse(actual is Future);
void toHaveCssClass(className) =>
gns.guinness.matchers.toBeFalse(DOM.hasClass(actual, className));
void toBeNull() => gns.guinness.matchers.toBeFalse(actual == null);
Function get _expect => gns.guinness.matchers.expect;
}
String elementText(n) {
hasNodes(n) {
var children = DOM.childNodes(n);
return children != null && children.length > 0;
}
if (n is Iterable) {
return n.map(elementText).join("");
}
if (DOM.isCommentNode(n)) {
return '';
}
if (DOM.isElementNode(n) && DOM.tagName(n) == 'CONTENT') {
return elementText(DOM.getDistributedNodes(n));
}
if (DOM.hasShadowRoot(n)) {
return elementText(DOM.childNodesAsList(DOM.getShadowRoot(n)));
}
if (hasNodes(n)) {
return elementText(DOM.childNodesAsList(n));
}
return DOM.getText(n);
}

View File

@ -0,0 +1,191 @@
import {DOM} from 'angular2/src/core/dom/dom_adapter';
import {global} from 'angular2/src/core/facade/lang';
export interface NgMatchers extends jasmine.Matchers {
toBePromise(): boolean;
toBeAnInstanceOf(expected: any): boolean;
toHaveText(expected: any): boolean;
toHaveCssClass(expected: any): boolean;
toImplement(expected: any): boolean;
toContainError(expected: any): boolean;
toThrowErrorWith(expectedMessage: any): boolean;
not: NgMatchers;
}
var _global: jasmine.GlobalPolluter = <any>(typeof window === 'undefined' ? global : window);
export var expect: (actual: any) => NgMatchers = <any>_global.expect;
// Some Map polyfills don't polyfill Map.toString correctly, which
// gives us bad error messages in tests.
// The only way to do this in Jasmine is to monkey patch a method
// to the object :-(
Map.prototype['jasmineToString'] = function() {
var m = this;
if (!m) {
return '' + m;
}
var res = [];
m.forEach((v, k) => { res.push(`${k}:${v}`); });
return `{ ${res.join(',')} }`;
};
_global.beforeEach(function() {
jasmine.addMatchers({
// Custom handler for Map as Jasmine does not support it yet
toEqual: function(util, customEqualityTesters) {
return {
compare: function(actual, expected) {
return {pass: util.equals(actual, expected, [compareMap])};
}
};
function compareMap(actual, expected) {
if (actual instanceof Map) {
var pass = actual.size === expected.size;
if (pass) {
actual.forEach((v, k) => { pass = pass && util.equals(v, expected.get(k)); });
}
return pass;
} else {
return undefined;
}
}
},
toBePromise: function() {
return {
compare: function(actual, expectedClass) {
var pass = typeof actual === 'object' && typeof actual.then === 'function';
return {pass: pass, get message() { return 'Expected ' + actual + ' to be a promise'; }};
}
};
},
toBeAnInstanceOf: function() {
return {
compare: function(actual, expectedClass) {
var pass = typeof actual === 'object' && actual instanceof expectedClass;
return {
pass: pass,
get message() {
return 'Expected ' + actual + ' to be an instance of ' + expectedClass;
}
};
}
};
},
toHaveText: function() {
return {
compare: function(actual, expectedText) {
var actualText = elementText(actual);
return {
pass: actualText == expectedText,
get message() { return 'Expected ' + actualText + ' to be equal to ' + expectedText; }
};
}
};
},
toHaveCssClass: function() {
return {compare: buildError(false), negativeCompare: buildError(true)};
function buildError(isNot) {
return function(actual, className) {
return {
pass: DOM.hasClass(actual, className) == !isNot,
get message() {
return `Expected ${actual.outerHTML} ${isNot ? 'not ' : ''}to contain the CSS class "${className}"`;
}
};
};
}
},
toContainError: function() {
return {
compare: function(actual, expectedText) {
var errorMessage = actual.toString();
return {
pass: errorMessage.indexOf(expectedText) > -1,
get message() { return 'Expected ' + errorMessage + ' to contain ' + expectedText; }
};
}
};
},
toThrowErrorWith: function() {
return {
compare: function(actual, expectedText) {
try {
actual();
return {
pass: false,
get message() { return "Was expected to throw, but did not throw"; }
};
} catch (e) {
var errorMessage = e.toString();
return {
pass: errorMessage.indexOf(expectedText) > -1,
get message() { return 'Expected ' + errorMessage + ' to contain ' + expectedText; }
};
}
}
};
},
toImplement: function() {
return {
compare: function(actualObject, expectedInterface) {
var objProps = Object.keys(actualObject.constructor.prototype);
var intProps = Object.keys(expectedInterface.prototype);
var missedMethods = [];
intProps.forEach((k) => {
if (!actualObject.constructor.prototype[k]) missedMethods.push(k);
});
return {
pass: missedMethods.length == 0,
get message() {
return 'Expected ' + actualObject + ' to have the following methods: ' +
missedMethods.join(", ");
}
};
}
};
}
});
});
function elementText(n) {
var hasNodes = (n) => {
var children = DOM.childNodes(n);
return children && children.length > 0;
};
if (n instanceof Array) {
return n.map(elementText).join("");
}
if (DOM.isCommentNode(n)) {
return '';
}
if (DOM.isElementNode(n) && DOM.tagName(n) == 'CONTENT') {
return elementText(Array.prototype.slice.apply(DOM.getDistributedNodes(n)));
}
if (DOM.hasShadowRoot(n)) {
return elementText(DOM.childNodesAsList(DOM.getShadowRoot(n)));
}
if (hasNodes(n)) {
return elementText(DOM.childNodesAsList(n));
}
return DOM.getText(n);
}

View File

@ -159,11 +159,15 @@ export function createTestInjector(providers: Array<Type | Provider | any[]>): I
* @return {FunctionWithParamTokens}
*/
export function inject(tokens: any[], fn: Function): FunctionWithParamTokens {
return new FunctionWithParamTokens(tokens, fn);
return new FunctionWithParamTokens(tokens, fn, false);
}
export function injectAsync(tokens: any[], fn: Function): FunctionWithParamTokens {
return new FunctionWithParamTokens(tokens, fn, true);
}
export class FunctionWithParamTokens {
constructor(private _tokens: any[], private _fn: Function) {}
constructor(private _tokens: any[], private _fn: Function, public isAsync: boolean) {}
/**
* Returns the value of the executed function.

View File

@ -15,7 +15,7 @@ export 'package:guinness/guinness.dart'
SpyObject,
SpyFunction;
import 'package:angular2/src/core/dom/dom_adapter.dart' show DOM;
export 'matchers.dart' show expect, Expect, NotExpect;
import 'package:angular2/src/core/reflection/reflection.dart';
import 'package:angular2/src/core/reflection/reflection_capabilities.dart';
@ -67,88 +67,6 @@ void testSetup() {
}, priority: 1);
}
Expect expect(actual, [matcher]) {
final expect = new Expect(actual);
if (matcher != null) expect.to(matcher);
return expect;
}
const _u = const Object();
expectErrorMessage(actual, expectedMessage) {
expect(actual.toString()).toContain(expectedMessage);
}
expectException(Function actual, expectedMessage) {
try {
actual();
} catch (e, s) {
expectErrorMessage(e, expectedMessage);
}
}
class Expect extends gns.Expect {
Expect(actual) : super(actual);
NotExpect get not => new NotExpect(actual);
void toEqual(expected) => toHaveSameProps(expected);
void toContainError(message) => expectErrorMessage(this.actual, message);
void toThrowError([message = ""]) => toThrowWith(message: message);
void toThrowErrorWith(message) => expectException(this.actual, message);
void toBePromise() => gns.guinness.matchers.toBeTrue(actual is Future);
void toHaveCssClass(className) =>
gns.guinness.matchers.toBeTrue(DOM.hasClass(actual, className));
void toImplement(expected) => toBeA(expected);
void toBeNaN() =>
gns.guinness.matchers.toBeTrue(double.NAN.compareTo(actual) == 0);
void toHaveText(expected) => _expect(elementText(actual), expected);
void toHaveBeenCalledWith([a = _u, b = _u, c = _u, d = _u, e = _u, f = _u]) =>
_expect(_argsMatch(actual, a, b, c, d, e, f), true,
reason: 'method invoked with correct arguments');
Function get _expect => gns.guinness.matchers.expect;
// TODO(tbosch): move this hack into Guinness
_argsMatch(spyFn, [a0 = _u, a1 = _u, a2 = _u, a3 = _u, a4 = _u, a5 = _u]) {
var calls = spyFn.calls;
final toMatch = _takeDefined([a0, a1, a2, a3, a4, a5]);
if (calls.isEmpty) {
return false;
} else {
gns.SamePropsMatcher matcher = new gns.SamePropsMatcher(toMatch);
for (var i = 0; i < calls.length; i++) {
var call = calls[i];
// TODO: create a better error message, not just 'Expected: <true> Actual: <false>'.
// For hacking this is good:
// print(call.positionalArguments);
if (matcher.matches(call.positionalArguments, null)) {
return true;
}
}
return false;
}
}
List _takeDefined(List iter) => iter.takeWhile((_) => _ != _u).toList();
}
class NotExpect extends gns.NotExpect {
NotExpect(actual) : super(actual);
void toEqual(expected) => toHaveSameProps(expected);
void toBePromise() => gns.guinness.matchers.toBeFalse(actual is Future);
void toHaveCssClass(className) =>
gns.guinness.matchers.toBeFalse(DOM.hasClass(actual, className));
void toBeNull() => gns.guinness.matchers.toBeFalse(actual == null);
Function get _expect => gns.guinness.matchers.expect;
}
void beforeEach(fn) {
if (fn is! FunctionWithParamTokens) fn = new FunctionWithParamTokens([], fn);
gns.beforeEach(() {
fn.execute(_injector);
});
}
/**
* Allows overriding default bindings defined in test_injector.js.
@ -174,8 +92,15 @@ void beforeEachBindings(Function fn) {
beforeEachProviders(fn);
}
void beforeEach(fn) {
if (fn is! FunctionWithParamTokens) fn = new FunctionWithParamTokens([], fn, false);
gns.beforeEach(() {
fn.execute(_injector);
});
}
void _it(gnsFn, name, fn) {
if (fn is! FunctionWithParamTokens) fn = new FunctionWithParamTokens([], fn);
if (fn is! FunctionWithParamTokens) fn = new FunctionWithParamTokens([], fn, false);
gnsFn(name, () {
_inIt = true;
fn.execute(_injector);
@ -232,33 +157,4 @@ class SpyObject extends gns.SpyObject {
}
}
String elementText(n) {
hasNodes(n) {
var children = DOM.childNodes(n);
return children != null && children.length > 0;
}
if (n is Iterable) {
return n.map((nn) => elementText(nn)).join("");
}
if (DOM.isCommentNode(n)) {
return '';
}
if (DOM.isElementNode(n) && DOM.tagName(n) == 'CONTENT') {
return elementText(DOM.getDistributedNodes(n));
}
if (DOM.hasShadowRoot(n)) {
return elementText(DOM.childNodesAsList(DOM.getShadowRoot(n)));
}
if (hasNodes(n)) {
return elementText(DOM.childNodesAsList(n));
}
return DOM.getText(n);
}
bool isInInnerZone() => Zone.current['_innerZone'] == true;

View File

@ -10,6 +10,8 @@ import {browserDetection} from './utils';
export {inject} from './test_injector';
export {expect, NgMatchers} from './matchers';
export var proxy: ClassDecorator = (t) => t;
var _global: jasmine.GlobalPolluter = <any>(typeof window === 'undefined' ? global : window);
@ -20,21 +22,6 @@ export type SyncTestFn = () => void;
type AsyncTestFn = (done: () => void) => void;
type AnyTestFn = SyncTestFn | AsyncTestFn;
export interface NgMatchers extends jasmine.Matchers {
toBe(expected: any): boolean;
toEqual(expected: any): boolean;
toBePromise(): boolean;
toBeAnInstanceOf(expected: any): boolean;
toHaveText(expected: any): boolean;
toHaveCssClass(expected: any): boolean;
toImplement(expected: any): boolean;
toContainError(expected: any): boolean;
toThrowErrorWith(expectedMessage: any): boolean;
not: NgMatchers;
}
export var expect: (actual: any) => NgMatchers = <any>_global.expect;
export class AsyncTestCompleter {
constructor(private _done: Function) {}
@ -201,148 +188,6 @@ export function iit(name, fn, timeOut = null): void {
return _it(jsmIIt, name, fn, timeOut);
}
// Some Map polyfills don't polyfill Map.toString correctly, which
// gives us bad error messages in tests.
// The only way to do this in Jasmine is to monkey patch a method
// to the object :-(
Map.prototype['jasmineToString'] = function() {
var m = this;
if (!m) {
return '' + m;
}
var res = [];
m.forEach((v, k) => { res.push(`${k}:${v}`); });
return `{ ${res.join(',')} }`;
};
_global.beforeEach(function() {
jasmine.addMatchers({
// Custom handler for Map as Jasmine does not support it yet
toEqual: function(util, customEqualityTesters) {
return {
compare: function(actual, expected) {
return {pass: util.equals(actual, expected, [compareMap])};
}
};
function compareMap(actual, expected) {
if (actual instanceof Map) {
var pass = actual.size === expected.size;
if (pass) {
actual.forEach((v, k) => { pass = pass && util.equals(v, expected.get(k)); });
}
return pass;
} else {
return undefined;
}
}
},
toBePromise: function() {
return {
compare: function(actual, expectedClass) {
var pass = typeof actual === 'object' && typeof actual.then === 'function';
return {pass: pass, get message() { return 'Expected ' + actual + ' to be a promise'; }};
}
};
},
toBeAnInstanceOf: function() {
return {
compare: function(actual, expectedClass) {
var pass = typeof actual === 'object' && actual instanceof expectedClass;
return {
pass: pass,
get message() {
return 'Expected ' + actual + ' to be an instance of ' + expectedClass;
}
};
}
};
},
toHaveText: function() {
return {
compare: function(actual, expectedText) {
var actualText = elementText(actual);
return {
pass: actualText == expectedText,
get message() { return 'Expected ' + actualText + ' to be equal to ' + expectedText; }
};
}
};
},
toHaveCssClass: function() {
return {compare: buildError(false), negativeCompare: buildError(true)};
function buildError(isNot) {
return function(actual, className) {
return {
pass: DOM.hasClass(actual, className) == !isNot,
get message() {
return `Expected ${actual.outerHTML} ${isNot ? 'not ' : ''}to contain the CSS class "${className}"`;
}
};
};
}
},
toContainError: function() {
return {
compare: function(actual, expectedText) {
var errorMessage = actual.toString();
return {
pass: errorMessage.indexOf(expectedText) > -1,
get message() { return 'Expected ' + errorMessage + ' to contain ' + expectedText; }
};
}
};
},
toThrowErrorWith: function() {
return {
compare: function(actual, expectedText) {
try {
actual();
return {
pass: false,
get message() { return "Was expected to throw, but did not throw"; }
};
} catch (e) {
var errorMessage = e.toString();
return {
pass: errorMessage.indexOf(expectedText) > -1,
get message() { return 'Expected ' + errorMessage + ' to contain ' + expectedText; }
};
}
}
};
},
toImplement: function() {
return {
compare: function(actualObject, expectedInterface) {
var objProps = Object.keys(actualObject.constructor.prototype);
var intProps = Object.keys(expectedInterface.prototype);
var missedMethods = [];
intProps.forEach((k) => {
if (!actualObject.constructor.prototype[k]) missedMethods.push(k);
});
return {
pass: missedMethods.length == 0,
get message() {
return 'Expected ' + actualObject + ' to have the following methods: ' +
missedMethods.join(", ");
}
};
}
};
}
});
});
export interface GuinessCompatibleSpy extends jasmine.Spy {
/** By chaining the spy with and.returnValue, all calls to the function will return a specific
@ -410,35 +255,6 @@ export class SpyObject {
}
}
function elementText(n) {
var hasNodes = (n) => {
var children = DOM.childNodes(n);
return children && children.length > 0;
};
if (n instanceof Array) {
return n.map((nn) => elementText(nn)).join("");
}
if (DOM.isCommentNode(n)) {
return '';
}
if (DOM.isElementNode(n) && DOM.tagName(n) == 'CONTENT') {
return elementText(Array.prototype.slice.apply(DOM.getDistributedNodes(n)));
}
if (DOM.hasShadowRoot(n)) {
return elementText(DOM.childNodesAsList(DOM.getShadowRoot(n)));
}
if (hasNodes(n)) {
return elementText(DOM.childNodesAsList(n));
}
return DOM.getText(n);
}
export function isInInnerZone(): boolean {
return (<NgZoneZone>global.zone)._innerZone === true;
}

View File

@ -0,0 +1,3 @@
library angular2.test_lib_public;
// empty as this file is for external TS/js users and should not be transpiled to dart

View File

@ -0,0 +1,165 @@
/**
* Public Test Library for unit testing Angular2 Applications. Uses the
* Jasmine framework.
*/
import {global} from 'angular2/src/core/facade/lang';
import {bind} from 'angular2/src/core/di';
import {createTestInjector, FunctionWithParamTokens, inject, injectAsync} from './test_injector';
export {inject, injectAsync} from './test_injector';
export {expect} from './matchers';
var _global: jasmine.GlobalPolluter = <any>(typeof window === 'undefined' ? global : window);
export var afterEach: Function = _global.afterEach;
export var describe: Function = _global.describe;
export var ddescribe: Function = _global.fdescribe;
export var fdescribe: Function = _global.fdescribe;
export var xdescribe: Function = _global.xdescribe;
export type SyncTestFn = () => void;
export type AsyncTestFn = (done: () => void) => void;
export type AnyTestFn = SyncTestFn | AsyncTestFn;
var jsmBeforeEach = _global.beforeEach;
var jsmIt = _global.it;
var jsmIIt = _global.fit;
var jsmXIt = _global.xit;
var testProviders;
var injector;
// Reset the test providers before each test.
jsmBeforeEach(() => {
testProviders = [];
injector = null;
});
/**
* Allows overriding default providers of the test injector,
* defined in test_injector.js.
*
* The given function must return a list of DI providers.
*
* Example:
*
* beforeEachProviders(() => [
* bind(Compiler).toClass(MockCompiler),
* bind(SomeToken).toValue(myValue),
* ]);
*/
export function beforeEachProviders(fn): void {
jsmBeforeEach(() => {
var providers = fn();
if (!providers) return;
testProviders = [...testProviders, ...providers];
if (injector !== null) {
throw new Error('beforeEachProviders was called after the injector had ' +
'been used in a beforeEach or it block. This invalidates the ' +
'test injector');
}
});
}
function _isPromiseLike(input): boolean {
return input && !!(input.then);
}
function _it(jsmFn: Function, name: string, testFn: FunctionWithParamTokens | AnyTestFn,
testTimeOut: number): void {
var timeOut = testTimeOut;
if (testFn instanceof FunctionWithParamTokens) {
// The test case uses inject(). ie `it('test', inject([ClassA], (a) => { ...
// }));`
if (testFn.isAsync) {
jsmFn(name, (done) => {
if (!injector) {
injector = createTestInjector(testProviders);
}
var returned = testFn.execute(injector);
if (_isPromiseLike(returned)) {
returned.then(done, done.fail);
} else {
done.fail('Error: injectAsync was expected to return a promise, but the ' +
' returned value was: ' + returned);
}
}, timeOut);
} else {
jsmFn(name, () => {
if (!injector) {
injector = createTestInjector(testProviders);
}
var returned = testFn.execute(injector);
if (_isPromiseLike(returned)) {
throw new Error('inject returned a promise. Did you mean to use injectAsync?');
};
});
}
} else {
// The test case doesn't use inject(). ie `it('test', (done) => { ... }));`
jsmFn(name, testFn, timeOut);
}
}
export function beforeEach(fn: FunctionWithParamTokens | AnyTestFn): void {
if (fn instanceof FunctionWithParamTokens) {
// The test case uses inject(). ie `beforeEach(inject([ClassA], (a) => { ...
// }));`
if (fn.isAsync) {
jsmBeforeEach((done) => {
if (!injector) {
injector = createTestInjector(testProviders);
}
var returned = fn.execute(injector);
if (_isPromiseLike(returned)) {
returned.then(done, done.fail);
} else {
done.fail('Error: injectAsync was expected to return a promise, but the ' +
' returned value was: ' + returned);
}
});
} else {
jsmBeforeEach(() => {
if (!injector) {
injector = createTestInjector(testProviders);
}
var returned = fn.execute(injector);
if (_isPromiseLike(returned)) {
throw new Error('inject returned a promise. Did you mean to use injectAsync?');
};
});
}
} else {
// The test case doesn't use inject(). ie `beforeEach((done) => { ... }));`
if ((<any>fn).length === 0) {
jsmBeforeEach(() => { (<SyncTestFn>fn)(); });
} else {
jsmBeforeEach((done) => { (<AsyncTestFn>fn)(done); });
}
}
}
export function it(name: string, fn: FunctionWithParamTokens | AnyTestFn, timeOut: number = null):
void {
return _it(jsmIt, name, fn, timeOut);
}
export function xit(name: string, fn: FunctionWithParamTokens | AnyTestFn, timeOut: number = null):
void {
return _it(jsmXIt, name, fn, timeOut);
}
export function iit(name: string, fn: FunctionWithParamTokens | AnyTestFn, timeOut: number = null):
void {
return _it(jsmIIt, name, fn, timeOut);
}
export function fit(name: string, fn: FunctionWithParamTokens | AnyTestFn, timeOut: number = null):
void {
return _it(jsmIIt, name, fn, timeOut);
}