Skip to content

Commit cf63996

Browse files
committed
[BREAKING CHANGE] Spec: Combine the "Test" and "Suite" concepts
== Status quo == The TAP 13 specification does not standardise a way of describing parent-child relationships between tests, nor does it standardise how to group tests. Yet, all major test frameworks have a way to group tests (e.g. QUnit module, and Mocha suite) and/or allow nesting tests inside of other tests (like tape, and node-tap). While the CRI draft provided a way to group tests, it did not accomodate Tap. They would either need to flatten the tests with a separator symbol in the test name, or to create an implied "Suite" for every test that has non-zero children and then come up with an ad-hoc naming scheme for it. Note that the TAP 13 reporter we ship, even after this change, still ends up flattening the tests by defaut using the greater than `>` symbol, but at least the event model itself recognises the relationships so that other output formats can make use of it, and in the future TAP 14 hopefully will recognise it as well, which we can then make use of. Ref TestAnything/testanything.github.io#36. == Summary of changes == See the diff of `test/integration/reference-data.js` for the concrete changes this makes to the consumable events. - Remove `suiteStart` and `suiteEnd` events. Instead, the spec now says that tests are permitted to have children. The link from child to parent remains the same as before, using the `fullName` field which is now a stack of test names. Previously, it was a stack of suite names with a test name at the end. - Remove all "downward" links from parent to child. Tests don't describe their children upfront in detail, and neither does `runStart`. This was information was very repetitive and tedious to satisy for implementors, and encouraged or required inefficient use of memory. I do recognise that a common use case might be to generate a single output file or stream where real-time updates are not needed, in which case you may want a convenient tree that is ready to traverse without needing to listen for async events and put it together. For this purpose, I have added a built-in reporter that simply listens to the new events and outputs a "summary" event with an object that is similar to the old "runEnd" event object where the entire run is described in a single large object. - New "SummaryReporter" for simple use cases of non-realtime traversing of single structure after the test has completed. == Caveats == - A test with the "failed" status is no longer expected to always have an error directly associated with it. Now that tests aggregate into other tests rather than into suites, this means tests that merely have other tests as children do still have to send a full testEnd event, and thus an `errors` and `assertions` array. I considered specifying that errors have to propagate but this seemed messy and could lead to duplicate diagnostic output in reporters, as well ambiguity or uncertainty over where errors originated. - A suite containing only "skipped" tests now aggregates as "passed" instead of "skipped". Given we can't know whether a suite is its own test with its own assertions, we also can't assume that if a test parent has only "skipped" children that the parent was also skipped. This applies to our built-in adapters, but individual frameworks, if they know that a suite was skipped in its entirety, can of course still set the status of parents however they see fit. - Graphical reporters (such as QUnit and Mocha's HTML reporters) may no longer assume that a test parent has either assertions/errors or other tests. A test parente can now have both its own assertions/errors, as well as other tests beneath it. This restricts the freedom and possibilities for visualisation. My recommendation is that, if a visual reporter wants to keep using different visual shapes for "group of assertions" and "group of tests", that they buffer the information internally such that they can first render all the tests's own assertions, and then render the children, even if they originally ran interleaved and/or the other way around. Ref #126. - The "Console" reporter that comes with js-reporter now no longer uses `console.group()` for collapsing nested tests. == Misc == - Add definitions for the "Adapter" and "Producer" terms. - Use terms "producer" and "reporter" consistently, instead of "framework", "runner", or "adapter". - Remove mention that the spec is for reporting information from "JavaScript test frameworks". CRI can be used to report information about any kind of test that can be represented in CRI's event model, including linting and end-to-end tests for JS programs, as well as non-JS programs. It describes a JS interface for reporters, but the information can come from anywhere. This further solifies that CRI is not meant to be used for "hooking" into a framework, and sets no expectation about timing or run-time environment being shared with whatever is executing tests in some form or another. This was already the intent originally, since it could be used to report information from other processes or from a cloud-based test runner like BrowserStack, but this removes any remaining confusion or doubt there may have been. Fixes #126.
1 parent e9411f1 commit cf63996

File tree

16 files changed

+845
-697
lines changed

16 files changed

+845
-697
lines changed

README.md

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -52,26 +52,25 @@ Would _you_ be interested in discussing this with us further? Please join in!
5252
Listen to the events and receive the emitted data:
5353

5454
```js
55-
// Attach one of the exiting adapters.
55+
// Use automatic discovery of the framework adapter.
5656
const runner = JsReporters.autoRegister();
5757

58-
// Listen to the same events for any testing framework.
59-
runner.on('testEnd', function (test) {
60-
console.log('Test %s has errors:', test.fullname.join(' '), test.errors);
58+
// Listen to standard events, from any testing framework.
59+
runner.on('testEnd', (test) => {
60+
console.log('Test %s has errors:', test.fullName.join(' '), test.errors);
6161
});
6262

63-
runner.on('runEnd', function (globalSuite) {
64-
const testCounts = globalSuite.testCounts;
65-
66-
console.log('Testsuite status: %s', globalSuite.status);
63+
runner.on('runEnd', (run) => {
64+
const counts = run.counts;
6765

66+
console.log('Testsuite status: %s', run.status);
6867
console.log('Total %d tests: %d passed, %d failed, %d skipped',
69-
testCounts.total,
70-
testCounts.passed,
71-
testCounts.failed,
72-
testCounts.skipped);
73-
74-
console.log('Total duration: %d', globalSuite.runtime);
68+
counts.total,
69+
counts.passed,
70+
counts.failed,
71+
counts.skipped
72+
);
73+
console.log('Total duration: %d', run.runtime);
7574
});
7675

7776
// Or use one of the built-in reporters.

index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const JasmineAdapter = require('./lib/adapters/JasmineAdapter.js');
44
const MochaAdapter = require('./lib/adapters/MochaAdapter.js');
55
const TapReporter = require('./lib/reporters/TapReporter.js');
66
const ConsoleReporter = require('./lib/reporters/ConsoleReporter.js');
7+
const SummaryReporter = require('./lib/reporters/SummaryReporter.js');
78
const {
89
collectSuiteStartData,
910
collectSuiteEndData,
@@ -18,6 +19,7 @@ module.exports = {
1819
MochaAdapter,
1920
TapReporter,
2021
ConsoleReporter,
22+
SummaryReporter,
2123
EventEmitter,
2224
collectSuiteStartData,
2325
collectSuiteEndData,

lib/adapters/JasmineAdapter.js

Lines changed: 60 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,8 @@ module.exports = class JasmineAdapter extends EventEmitter {
1313
// NodeJS or browser
1414
this.env = jasmine.env || jasmine.getEnv();
1515

16-
this.suiteStarts = {};
1716
this.suiteChildren = {};
18-
this.suiteEnds = {};
17+
this.suiteEnds = [];
1918
this.testStarts = {};
2019
this.testEnds = {};
2120

@@ -56,93 +55,90 @@ module.exports = class JasmineAdapter extends EventEmitter {
5655

5756
return {
5857
name: testStart.name,
59-
suiteName: testStart.suiteName,
58+
parentName: testStart.parentName,
6059
fullName: testStart.fullName.slice(),
6160
status: (result.status === 'pending') ? 'skipped' : result.status,
61+
// TODO: Jasmine 3.4+ has result.duration, use it.
62+
// Note that result.duration uses 0 instead of null for a 'skipped' test.
6263
runtime: (result.status === 'pending') ? null : (new Date() - this.startTime),
6364
errors,
6465
assertions
6566
};
6667
}
6768

6869
/**
69-
* Convert a Jasmine SuiteResult for CRI 'runStart' or 'suiteStart' event data.
70+
* Traverse the Jasmine structured returned by `this.env.topSuite()`
71+
* in order to extract the child-parent relations and full names.
7072
*
71-
* Jasmine provides details about childSuites and tests only in the structure
72-
* returned by "this.env.topSuite()".
7373
*/
74-
createSuiteStart (result, parentNames) {
74+
processSuite (result, parentNames, parentIds) {
7575
const isGlobalSuite = (result.description === 'Jasmine__TopLevel__Suite');
7676

7777
const name = isGlobalSuite ? null : result.description;
7878
const fullName = parentNames.slice();
79-
const tests = [];
80-
const childSuites = [];
8179

8280
if (!isGlobalSuite) {
83-
fullName.push(result.description);
81+
fullName.push(name);
8482
}
8583

84+
parentIds.push(result.id);
85+
this.suiteChildren[result.id] = [];
86+
8687
result.children.forEach((child) => {
88+
this.testStarts[child.id] = {
89+
name: child.description,
90+
parentName: name,
91+
fullName: [...fullName, child.description]
92+
};
93+
8794
if (child.id.indexOf('suite') === 0) {
88-
childSuites.push(this.createSuiteStart(child, fullName));
95+
this.processSuite(child, fullName.slice(), parentIds.slice());
8996
} else {
90-
const testStart = {
91-
name: child.description,
92-
suiteName: name,
93-
fullName: [...fullName, child.description]
94-
};
95-
tests.push(testStart);
96-
this.testStarts[child.id] = testStart;
97+
// Update flat list of test children
98+
parentIds.forEach((id) => {
99+
this.suiteChildren[id].push(child.id);
100+
});
97101
}
98102
});
99-
100-
const helperData = helpers.collectSuiteStartData(tests, childSuites);
101-
const suiteStart = {
102-
name,
103-
fullName,
104-
tests,
105-
childSuites,
106-
testCounts: helperData.testCounts
107-
};
108-
this.suiteStarts[result.id] = suiteStart;
109-
this.suiteChildren[result.id] = result.children.map(child => child.id);
110-
return suiteStart;
111103
}
112104

113-
createSuiteEnd (suiteStart, result) {
114-
const tests = [];
115-
const childSuites = [];
116-
this.suiteChildren[result.id].forEach((childId) => {
117-
if (childId.indexOf('suite') === 0) {
118-
childSuites.push(this.suiteEnds[childId]);
119-
} else {
120-
tests.push(this.testEnds[childId]);
121-
}
122-
});
105+
createSuiteEnd (testStart, result) {
106+
const tests = this.suiteChildren[result.id].map((testId) => this.testEnds[testId]);
123107

124-
const helperData = helpers.collectSuiteEndData(tests, childSuites);
108+
const helperData = helpers.aggregateTests(tests);
125109
return {
126-
name: suiteStart.name,
127-
fullName: suiteStart.fullName,
128-
tests,
129-
childSuites,
110+
name: testStart.name,
111+
parentName: testStart.parentName,
112+
fullName: testStart.fullName,
130113
// Jasmine has result.status, but does not propagate 'todo' or 'skipped'
131114
status: helperData.status,
132-
testCounts: helperData.testCounts,
133-
// Jasmine 3.4+ has result.duration, but uses 0 instead of null
134-
// when 'skipped' is skipped.
135-
runtime: helperData.status === 'skipped' ? null : (result.duration || helperData.runtime)
115+
runtime: result.duration || helperData.runtime,
116+
errors: [],
117+
assertions: []
136118
};
137119
}
138120

139121
onJasmineStarted () {
140-
this.globalSuite = this.createSuiteStart(this.env.topSuite(), []);
141-
this.emit('runStart', this.globalSuite);
122+
this.processSuite(this.env.topSuite(), [], []);
123+
124+
let total = 0;
125+
this.env.topSuite().children.forEach(function countChild (child) {
126+
total++;
127+
if (child.id.indexOf('suite') === 0) {
128+
child.children.forEach(countChild);
129+
}
130+
});
131+
132+
this.emit('runStart', {
133+
name: null,
134+
counts: {
135+
total: total
136+
}
137+
});
142138
}
143139

144140
onSuiteStarted (result) {
145-
this.emit('suiteStart', this.suiteStarts[result.id]);
141+
this.emit('testStart', this.testStarts[result.id]);
146142
}
147143

148144
onSpecStarted (result) {
@@ -156,11 +152,20 @@ module.exports = class JasmineAdapter extends EventEmitter {
156152
}
157153

158154
onSuiteDone (result) {
159-
this.suiteEnds[result.id] = this.createSuiteEnd(this.suiteStarts[result.id], result);
160-
this.emit('suiteEnd', this.suiteEnds[result.id]);
155+
const suiteEnd = this.createSuiteEnd(this.testStarts[result.id], result);
156+
this.suiteEnds.push(suiteEnd);
157+
this.emit('testEnd', suiteEnd);
161158
}
162159

163160
onJasmineDone (doneInfo) {
164-
this.emit('runEnd', this.createSuiteEnd(this.globalSuite, this.env.topSuite()));
161+
const topSuite = this.env.topSuite();
162+
const tests = this.suiteChildren[topSuite.id].map((testId) => this.testEnds[testId]);
163+
const helperData = helpers.aggregateTests([...tests, ...this.suiteEnds]);
164+
this.emit('runEnd', {
165+
name: null,
166+
status: helperData.status,
167+
counts: helperData.counts,
168+
runtime: helperData.runtime
169+
});
165170
}
166171
};

lib/adapters/MochaAdapter.js

Lines changed: 56 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ module.exports = class MochaAdapter extends EventEmitter {
66
super();
77

88
this.errors = null;
9+
this.finalRuntime = 0;
10+
this.finalCounts = {
11+
passed: 0,
12+
failed: 0,
13+
skipped: 0,
14+
todo: 0,
15+
total: 0
16+
};
917

1018
// Mocha will instantiate the given function as a class, even if you only need a callback.
1119
// As such, it can't be an arrow function as those throw TypeError when instantiated.
@@ -27,40 +35,37 @@ module.exports = class MochaAdapter extends EventEmitter {
2735
convertToSuiteStart (mochaSuite) {
2836
return {
2937
name: mochaSuite.title,
30-
fullName: this.titlePath(mochaSuite),
31-
tests: mochaSuite.tests.map(this.convertTest.bind(this)),
32-
childSuites: mochaSuite.suites.map(this.convertToSuiteStart.bind(this)),
33-
testCounts: {
34-
total: mochaSuite.total()
35-
}
38+
parentName: (mochaSuite.parent && !mochaSuite.parent.root) ? mochaSuite.parent.title : null,
39+
fullName: this.titlePath(mochaSuite)
3640
};
3741
}
3842

3943
convertToSuiteEnd (mochaSuite) {
4044
const tests = mochaSuite.tests.map(this.convertTest.bind(this));
4145
const childSuites = mochaSuite.suites.map(this.convertToSuiteEnd.bind(this));
42-
const helperData = helpers.collectSuiteEndData(tests, childSuites);
46+
const helperData = helpers.aggregateTests([...tests, ...childSuites]);
47+
4348
return {
4449
name: mochaSuite.title,
50+
parentName: (mochaSuite.parent && !mochaSuite.parent.root) ? mochaSuite.parent.title : null,
4551
fullName: this.titlePath(mochaSuite),
46-
tests,
47-
childSuites,
4852
status: helperData.status,
49-
testCounts: helperData.testCounts,
50-
runtime: helperData.runtime
53+
runtime: helperData.runtime,
54+
errors: [],
55+
assertions: []
5156
};
5257
}
5358

5459
convertTest (mochaTest) {
55-
let suiteName;
60+
let parentName;
5661
let fullName;
5762
if (!mochaTest.parent.root) {
58-
suiteName = mochaTest.parent.title;
63+
parentName = mochaTest.parent.title;
5964
fullName = this.titlePath(mochaTest.parent);
6065
// Add also the test name.
6166
fullName.push(mochaTest.title);
6267
} else {
63-
suiteName = null;
68+
parentName = null;
6469
fullName = [mochaTest.title];
6570
}
6671

@@ -73,21 +78,23 @@ module.exports = class MochaAdapter extends EventEmitter {
7378
message: error.message || error.toString(),
7479
stack: error.stack
7580
}));
81+
const status = (mochaTest.state === undefined) ? 'skipped' : mochaTest.state;
82+
const runtime = (mochaTest.duration === undefined) ? null : mochaTest.duration;
7683

7784
return {
7885
name: mochaTest.title,
79-
suiteName,
86+
parentName,
8087
fullName,
81-
status: (mochaTest.state === undefined) ? 'skipped' : mochaTest.state,
82-
runtime: (mochaTest.duration === undefined) ? null : mochaTest.duration,
88+
status,
89+
runtime,
8390
errors,
8491
assertions: errors
8592
};
8693
} else {
8794
// It is a "test start".
8895
return {
8996
name: mochaTest.title,
90-
suiteName,
97+
parentName,
9198
fullName
9299
};
93100
}
@@ -112,15 +119,24 @@ module.exports = class MochaAdapter extends EventEmitter {
112119
}
113120

114121
onStart () {
115-
const globalSuiteStart = this.convertToSuiteStart(this.runner.suite);
116-
globalSuiteStart.name = null;
117-
118-
this.emit('runStart', globalSuiteStart);
122+
// total is all tests + all suites
123+
// each suite gets a CRI "test" wrapper
124+
let total = this.runner.suite.total();
125+
this.runner.suite.suites.forEach(function addSuites (suite) {
126+
total++;
127+
suite.suites.forEach(addSuites);
128+
});
129+
this.emit('runStart', {
130+
name: null,
131+
counts: {
132+
total: total
133+
}
134+
});
119135
}
120136

121137
onSuite (mochaSuite) {
122138
if (!mochaSuite.root) {
123-
this.emit('suiteStart', this.convertToSuiteStart(mochaSuite));
139+
this.emit('testStart', this.convertToSuiteStart(mochaSuite));
124140
}
125141
}
126142

@@ -148,19 +164,29 @@ module.exports = class MochaAdapter extends EventEmitter {
148164
// and status are already attached to the test, but the errors are not.
149165
mochaTest.errors = this.errors;
150166

151-
this.emit('testEnd', this.convertTest(mochaTest));
167+
const testEnd = this.convertTest(mochaTest);
168+
this.emit('testEnd', testEnd);
169+
this.finalCounts.total++;
170+
this.finalCounts[testEnd.status]++;
171+
this.finalRuntime += testEnd.runtime || 0;
152172
}
153173

154174
onSuiteEnd (mochaSuite) {
155175
if (!mochaSuite.root) {
156-
this.emit('suiteEnd', this.convertToSuiteEnd(mochaSuite));
176+
const testEnd = this.convertToSuiteEnd(mochaSuite);
177+
this.emit('testEnd', testEnd);
178+
this.finalCounts.total++;
179+
this.finalCounts[testEnd.status]++;
180+
this.finalRuntime += testEnd.runtime || 0;
157181
}
158182
}
159183

160-
onEnd () {
161-
const globalSuiteEnd = this.convertToSuiteEnd(this.runner.suite);
162-
globalSuiteEnd.name = null;
163-
164-
this.emit('runEnd', globalSuiteEnd);
184+
onEnd (details) {
185+
this.emit('runEnd', {
186+
name: null,
187+
status: this.finalCounts.failed > 0 ? 'failed' : 'passed',
188+
counts: this.finalCounts,
189+
runtime: this.finalRuntime
190+
});
165191
}
166192
};

0 commit comments

Comments
 (0)