Skip to content

Commit b508af6

Browse files
committed
Restrict test modifier chaining
Explicitly specify allowable chains, with some ground rules in mind: Test chaining rules: * serial must come at the start * only and skip must come at the end * failing must come at the end, but can be followed by only and skip * only and skip cannot be chained together * no repeating Hook chaining rules: * always comes immediately after "after hooks" * skip must come at the end * no only * no repeating Additionally: * todo cannot be chained, except after serial * all methods except for test are available on serial This commit also removes now unnecessary assertions from TestCollection. Fixes #1182.
1 parent b6fa8b9 commit b508af6

File tree

9 files changed

+185
-294
lines changed

9 files changed

+185
-294
lines changed

docs/recipes/typescript.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ test.beforeEach(t => {
8989
t.context.foo = 123; // error: Type '123' is not assignable to type 'string'
9090
});
9191

92-
test.after.always.failing.cb.serial('very long chains are properly typed', t => {
92+
test.serial.failing.cb('very long chains are properly typed', t => {
9393
t.context.fooo = 'a value'; // error: Property 'fooo' does not exist on type '{ foo: string }'
9494
});
9595

lib/runner.js

+114-27
Original file line numberDiff line numberDiff line change
@@ -2,38 +2,117 @@
22
const EventEmitter = require('events');
33
const path = require('path');
44
const Bluebird = require('bluebird');
5-
const optionChain = require('option-chain');
65
const matcher = require('matcher');
76
const snapshotManager = require('./snapshot-manager');
87
const TestCollection = require('./test-collection');
98
const validateTest = require('./validate-test');
109

11-
const chainableMethods = {
12-
defaults: {
13-
type: 'test',
14-
serial: false,
15-
exclusive: false,
16-
skipped: false,
17-
todo: false,
18-
failing: false,
19-
callback: false,
20-
always: false
21-
},
22-
chainableMethods: {
23-
test: {},
24-
serial: {serial: true},
25-
before: {type: 'before'},
26-
after: {type: 'after'},
27-
skip: {skipped: true},
28-
todo: {todo: true},
29-
failing: {failing: true},
30-
only: {exclusive: true},
31-
beforeEach: {type: 'beforeEach'},
32-
afterEach: {type: 'afterEach'},
33-
cb: {callback: true},
34-
always: {always: true}
10+
const chainRegistry = new WeakMap();
11+
12+
function startChain(name, call, defaults) {
13+
const fn = function () {
14+
call(Object.assign({}, defaults), Array.from(arguments));
15+
};
16+
Object.defineProperty(fn, 'name', {value: name});
17+
chainRegistry.set(fn, {call, defaults, fullName: name});
18+
return fn;
19+
}
20+
21+
function extendChain(prev, name, flag) {
22+
if (!flag) {
23+
flag = name;
24+
}
25+
26+
const fn = function () {
27+
callWithFlag(prev, flag, Array.from(arguments));
28+
};
29+
const fullName = `${chainRegistry.get(prev).fullName}.${name}`;
30+
Object.defineProperty(fn, 'name', {value: fullName});
31+
prev[name] = fn;
32+
33+
chainRegistry.set(fn, {flag, fullName, prev});
34+
return fn;
35+
}
36+
37+
function callWithFlag(prev, flag, args) {
38+
const combinedFlags = {[flag]: true};
39+
do {
40+
const step = chainRegistry.get(prev);
41+
if (step.call) {
42+
step.call(Object.assign({}, step.defaults, combinedFlags), args);
43+
prev = null;
44+
} else {
45+
combinedFlags[step.flag] = true;
46+
prev = step.prev;
47+
}
48+
} while (prev);
49+
}
50+
51+
function createHookChain(hook, isAfterHook) {
52+
// Hook chaining rules:
53+
// * always comes immediately after "after hooks"
54+
// * skip must come at the end
55+
// * no only
56+
// * no repeating
57+
extendChain(hook, 'cb', 'callback');
58+
extendChain(hook, 'skip', 'skipped');
59+
extendChain(hook.cb, 'skip', 'skipped');
60+
if (isAfterHook) {
61+
extendChain(hook, 'always');
62+
extendChain(hook.always, 'cb', 'callback');
63+
extendChain(hook.always, 'skip', 'skipped');
64+
extendChain(hook.always.cb, 'skip', 'skipped');
3565
}
36-
};
66+
return hook;
67+
}
68+
69+
function createChain(fn, defaults) {
70+
// Test chaining rules:
71+
// * serial must come at the start
72+
// * only and skip must come at the end
73+
// * failing must come at the end, but can be followed by only and skip
74+
// * only and skip cannot be chained together
75+
// * no repeating
76+
const root = startChain('test', fn, Object.assign({}, defaults, {type: 'test'}));
77+
extendChain(root, 'cb', 'callback');
78+
extendChain(root, 'failing');
79+
extendChain(root, 'only', 'exclusive');
80+
extendChain(root, 'serial');
81+
extendChain(root, 'skip', 'skipped');
82+
extendChain(root.cb, 'failing');
83+
extendChain(root.cb, 'only', 'exclusive');
84+
extendChain(root.cb, 'skip', 'skipped');
85+
extendChain(root.cb.failing, 'only', 'exclusive');
86+
extendChain(root.cb.failing, 'skip', 'skipped');
87+
extendChain(root.failing, 'only', 'exclusive');
88+
extendChain(root.failing, 'skip', 'skipped');
89+
extendChain(root.serial, 'cb', 'callback');
90+
extendChain(root.serial, 'failing');
91+
extendChain(root.serial, 'only', 'exclusive');
92+
extendChain(root.serial, 'skip', 'skipped');
93+
extendChain(root.serial.cb, 'failing');
94+
extendChain(root.serial.cb, 'only', 'exclusive');
95+
extendChain(root.serial.cb, 'skip', 'skipped');
96+
extendChain(root.serial.cb.failing, 'only', 'exclusive');
97+
extendChain(root.serial.cb.failing, 'skip', 'skipped');
98+
99+
// No additional methods todo tests.
100+
root.todo = startChain('test.todo', fn, Object.assign({}, defaults, {type: 'test', todo: true}));
101+
102+
root.after = createHookChain(startChain('test.after', fn, Object.assign({}, defaults, {type: 'after'})), true);
103+
root.afterEach = createHookChain(startChain('test.afterEach', fn, Object.assign({}, defaults, {type: 'afterEach'})), true);
104+
root.before = createHookChain(startChain('test.before', fn, Object.assign({}, defaults, {type: 'before'})), false);
105+
root.beforeEach = createHookChain(startChain('test.beforeEach', fn, Object.assign({}, defaults, {type: 'beforeEach'})), false);
106+
107+
// Add to root.serial to support `import {serial} from 'ava'` use cases
108+
root.serial.after = createHookChain(startChain('test.serial.after', fn, Object.assign({}, defaults, {serial: true, type: 'after'})), true);
109+
root.serial.afterEach = createHookChain(startChain('test.serial.afterEach', fn, Object.assign({}, defaults, {serial: true, type: 'afterEach'})), true);
110+
root.serial.before = createHookChain(startChain('test.serial.before', fn, Object.assign({}, defaults, {serial: true, type: 'before'})), false);
111+
root.serial.beforeEach = createHookChain(startChain('test.serial.beforeEach', fn, Object.assign({}, defaults, {serial: true, type: 'beforeEach'})), false);
112+
root.serial.todo = startChain('test.serial.todo', fn, Object.assign({}, defaults, {type: 'test', todo: true}));
113+
114+
return root;
115+
}
37116

38117
function wrapFunction(fn, args) {
39118
return function (t) {
@@ -63,7 +142,7 @@ class Runner extends EventEmitter {
63142
compareTestSnapshot: this.compareTestSnapshot.bind(this)
64143
});
65144

66-
this.chain = optionChain(chainableMethods, (opts, args) => {
145+
this.chain = createChain((opts, args) => {
67146
let title;
68147
let fn;
69148
let macroArgIndex;
@@ -100,6 +179,14 @@ class Runner extends EventEmitter {
100179
} else {
101180
this.addTest(title, opts, fn, args);
102181
}
182+
}, {
183+
serial: false,
184+
exclusive: false,
185+
skipped: false,
186+
todo: false,
187+
failing: false,
188+
callback: false,
189+
always: false
103190
});
104191
}
105192

lib/test-collection.js

-12
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,6 @@ class TestCollection extends EventEmitter {
3838
const metadata = test.metadata;
3939
const type = metadata.type;
4040

41-
if (!type) {
42-
throw new Error('Test type must be specified');
43-
}
44-
4541
if (test.title === '' || typeof test.title !== 'string') {
4642
if (type === 'test') {
4743
throw new TypeError('Tests must have a title');
@@ -58,16 +54,8 @@ class TestCollection extends EventEmitter {
5854
}
5955
}
6056

61-
if (metadata.always && type !== 'after' && type !== 'afterEach') {
62-
throw new Error('"always" can only be used with after and afterEach hooks');
63-
}
64-
6557
// Add a hook
6658
if (type !== 'test') {
67-
if (metadata.exclusive) {
68-
throw new Error(`"only" cannot be used with a ${type} hook`);
69-
}
70-
7159
this.hooks[type + (metadata.always ? 'Always' : '')].push(test);
7260
return;
7361
}

package-lock.json

-5
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

-1
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,6 @@
123123
"ms": "^2.1.1",
124124
"multimatch": "^2.1.0",
125125
"observable-to-promise": "^0.5.0",
126-
"option-chain": "^1.0.0",
127126
"package-hash": "^2.0.0",
128127
"pkg-conf": "^2.1.0",
129128
"plur": "^2.0.0",

readme.md

+13-16
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,8 @@ test.serial('passes serially', t => {
385385

386386
Note that this only applies to tests within a particular test file. AVA will still run multiple tests files at the same time unless you pass the [`--serial` CLI flag](#cli).
387387

388+
You can use the `.serial` modifier with all tests, hooks and even `.todo()`, but it's only available on the `test` function.
389+
388390
### Running specific tests
389391

390392
During development it can be helpful to only run a few specific tests. This can be accomplished using the `.only` modifier:
@@ -399,6 +401,8 @@ test.only('will be run', t => {
399401
});
400402
```
401403

404+
You can use the `.only` modifier with all tests. It cannot be used with hooks or `.todo()`.
405+
402406
*Note:* The `.only` modifier applies to the test file it's defined in, so if you run multiple test files, tests in other files will still run. If you want to only run the `test.only` test, provide just that test file to AVA.
403407

404408
### Running tests with matching titles
@@ -485,7 +489,7 @@ test.skip('will not be run', t => {
485489
});
486490
```
487491

488-
You must specify the implementation function.
492+
You must specify the implementation function. You can use the `.skip` modifier with all tests and hooks, but not with `.todo()`. You can not apply further modifiers to `.skip`.
489493

490494
### Test placeholders ("todo")
491495

@@ -495,6 +499,12 @@ You can use the `.todo` modifier when you're planning to write a test. Like skip
495499
test.todo('will think about writing this later');
496500
```
497501

502+
You can signal that you need to write a serial test:
503+
504+
```js
505+
test.serial.todo('will think about writing this later');
506+
```
507+
498508
### Failing tests
499509

500510
You can use the `.failing` modifier to document issues with your code that need to be fixed. Failing tests are run just like normal ones, but they are expected to fail, and will not break your build when they do. If a test marked as failing actually passes, it will be reported as an error and fail the build with a helpful message instructing you to remove the `.failing` modifier.
@@ -558,7 +568,7 @@ test('title', t => {
558568
});
559569
```
560570

561-
Hooks can be synchronous or asynchronous, just like tests. To make a hook asynchronous return a promise or observable, use an async function, or enable callback mode via `test.cb.before()`, `test.cb.beforeEach()` etc.
571+
Hooks can be synchronous or asynchronous, just like tests. To make a hook asynchronous return a promise or observable, use an async function, or enable callback mode via `test.before.cb()`, `test.beforeEach.cb()` etc.
562572

563573
```js
564574
test.before(async t => {
@@ -569,7 +579,7 @@ test.after(t => {
569579
return new Promise(/* ... */);
570580
});
571581

572-
test.cb.beforeEach(t => {
582+
test.beforeEach.cb(t => {
573583
setTimeout(t.end);
574584
});
575585

@@ -610,19 +620,6 @@ test('context is unicorn', t => {
610620

611621
Context sharing is *not* available to `before` and `after` hooks.
612622

613-
### Chaining test modifiers
614-
615-
You can use the `.serial`, `.only` and `.skip` modifiers in any order, with `test`, `before`, `after`, `beforeEach` and `afterEach`. For example:
616-
617-
```js
618-
test.before.skip(...);
619-
test.skip.after(...);
620-
test.serial.only(...);
621-
test.only.serial(...);
622-
```
623-
624-
This means you can temporarily add `.skip` or `.only` at the end of a test or hook definition without having to make any other changes.
625-
626623
### Test macros
627624

628625
Additional arguments passed to the test declaration will be passed to the test implementation. This is useful for creating reusable test macros.

0 commit comments

Comments
 (0)