Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/01-writing-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ AVA lets you register hooks that are run before and after your tests. This allow

`test.beforeEach()` registers a hook to be run before each test in your test file. Similarly `test.afterEach()` registers a hook to be run after each test. Use `test.afterEach.always()` to register an after hook that is called even if other test hooks, or the test itself, fail.

`test.cleanup()` is shorthand for registering the same hook with `test.before()` and `test.after.always()`. Use it for idempotent file-level cleanup that should run before tests start and again once they complete. `test.cleanupEach()` works the same way for each test, combining `test.beforeEach()` with `test.afterEach.always()`.

If a test is skipped with the `.skip` modifier, the respective `.beforeEach()`, `.afterEach()` and `.afterEach.always()` hooks are not run. Likewise, if all tests in a test file are skipped `.before()`, `.after()` and `.after.always()` hooks for the file are not run.

*You may not need to use `.afterEach.always()` hooks to clean up after a test.* You can use [`t.teardown()`](./02-execution-context.md#tteardownfn) to undo side-effects *within* a particular test. Or use [`registerCompletionHandler()`](./08-common-pitfalls.md#timeouts-because-a-file-failed-to-exit) to run cleanup code after AVA has completed its work.
Expand Down Expand Up @@ -223,6 +225,14 @@ test.afterEach.always(t => {
// This runs after each test and other test hooks, even if they failed
});

test.cleanup(t => {
// This runs before tests, and again after all tests and hooks complete
});

test.cleanupEach(t => {
// This runs before each test, and again afterwards even if the test failed
});

test('title', t => {
// Regular test
});
Expand Down
4 changes: 4 additions & 0 deletions lib/create-chain.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,15 @@ export default function createChain(fn, defaults, meta) {
root.afterEach = createHookChain(startChain('test.afterEach', fn, {...defaults, type: 'afterEach'}), true);
root.before = createHookChain(startChain('test.before', fn, {...defaults, type: 'before'}), false);
root.beforeEach = createHookChain(startChain('test.beforeEach', fn, {...defaults, type: 'beforeEach'}), false);
root.cleanup = createHookChain(startChain('test.cleanup', fn, {...defaults, type: 'cleanup'}), false);
root.cleanupEach = createHookChain(startChain('test.cleanupEach', fn, {...defaults, type: 'cleanupEach'}), false);

root.serial.after = createHookChain(startChain('test.after', fn, {...defaults, serial: true, type: 'after'}), true);
root.serial.afterEach = createHookChain(startChain('test.afterEach', fn, {...defaults, serial: true, type: 'afterEach'}), true);
root.serial.before = createHookChain(startChain('test.before', fn, {...defaults, serial: true, type: 'before'}), false);
root.serial.beforeEach = createHookChain(startChain('test.beforeEach', fn, {...defaults, serial: true, type: 'beforeEach'}), false);
root.serial.cleanup = createHookChain(startChain('test.cleanup', fn, {...defaults, serial: true, type: 'cleanup'}), false);
root.serial.cleanupEach = createHookChain(startChain('test.cleanupEach', fn, {...defaults, serial: true, type: 'cleanupEach'}), false);

// "todo" tests cannot be chained. Allow todo tests to be flagged as needing
// to be serial.
Expand Down
73 changes: 55 additions & 18 deletions lib/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,24 +157,61 @@ export default class Runner extends Emittery {
metadata: {...metadata},
};

if (metadata.type === 'test') {
task.metadata.selected &&= isTitleMatch(title.value, this.matchPatterns);
// Unmatched .only() are not selected and won't run. However, runOnlyExclusive can only be true if no titles
// are being matched.
this.runOnlyExclusive ||= this.matchPatterns.length === 0 && task.metadata.exclusive && task.metadata.selected;

this.tasks[metadata.serial ? 'serial' : 'concurrent'].push(task);

this.snapshots.touch(title.value, metadata.taskIndex);

this.emit('stateChange', {
type: 'declared-test',
title: title.value,
knownFailing: metadata.failing,
todo: false,
});
} else if (!metadata.skipped) {
this.tasks[metadata.type + (metadata.always ? 'Always' : '')].push(task);
switch (metadata.type) {
case 'test': {
task.metadata.selected &&= isTitleMatch(title.value, this.matchPatterns);
// Unmatched .only() are not selected and won't run. However, runOnlyExclusive can only be true if no titles
// are being matched.
this.runOnlyExclusive ||= this.matchPatterns.length === 0 && task.metadata.exclusive && task.metadata.selected;

this.tasks[metadata.serial ? 'serial' : 'concurrent'].push(task);

this.snapshots.touch(title.value, metadata.taskIndex);

this.emit('stateChange', {
type: 'declared-test',
title: title.value,
knownFailing: metadata.failing,
todo: false,
});
break;
}

case 'cleanup': {
if (!metadata.skipped) {
this.tasks.before.push({
...task,
metadata: {...task.metadata, type: 'before'},
});
this.tasks.afterAlways.push({
...task,
metadata: {...task.metadata, always: true, type: 'after'},
});
}

break;
}

case 'cleanupEach': {
if (!metadata.skipped) {
this.tasks.beforeEach.push({
...task,
metadata: {...task.metadata, type: 'beforeEach'},
});
this.tasks.afterEachAlways.push({
...task,
metadata: {...task.metadata, always: true, type: 'afterEach'},
});
}

break;
}

default: {
if (!metadata.skipped) {
this.tasks[metadata.type + (metadata.always ? 'Always' : '')].push(task);
}
}
}
}
}, {
Expand Down
72 changes: 72 additions & 0 deletions test-tap/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,42 @@ test('after.always run even if before failed', t => {
});
});

test('cleanup runs before tests and after.always', t => {
t.plan(1);

const array = [];
return promiseEnd(new Runner({file: import.meta.url}), runner => {
runner.chain.cleanup(() => {
array.push('cleanup');
});

runner.chain('test', a => {
a.pass();
array.push('test');
});
}).then(() => {
t.strictSame(array, ['cleanup', 'test', 'cleanup']);
});
});

test('cleanup runs after a failed test', t => {
t.plan(1);

const array = [];
return promiseEnd(new Runner({file: import.meta.url}), runner => {
runner.chain.cleanup(() => {
array.push('cleanup');
});

runner.chain('test', () => {
array.push('test');
throw new Error('something went wrong');
});
}).then(() => {
t.strictSame(array, ['cleanup', 'test', 'cleanup']);
});
});

test('stop if before hooks failed', t => {
t.plan(1);

Expand Down Expand Up @@ -222,6 +258,42 @@ test('fail if beforeEach hook fails', t => {
});
});

test('cleanupEach runs before and after a passing test', t => {
t.plan(1);

const array = [];
return promiseEnd(new Runner({file: import.meta.url}), runner => {
runner.chain.cleanupEach(() => {
array.push('cleanupEach');
});

runner.chain('test', a => {
a.pass();
array.push('test');
});
}).then(() => {
t.strictSame(array, ['cleanupEach', 'test', 'cleanupEach']);
});
});

test('cleanupEach runs after a failed test', t => {
t.plan(1);

const array = [];
return promiseEnd(new Runner({file: import.meta.url}), runner => {
runner.chain.cleanupEach(() => {
array.push('cleanupEach');
});

runner.chain('test', () => {
array.push('test');
throw new Error('something went wrong');
});
}).then(() => {
t.strictSame(array, ['cleanupEach', 'test', 'cleanupEach']);
});
});

test('after each with concurrent tests', t => {
t.plan(1);

Expand Down
20 changes: 20 additions & 0 deletions types/test-fn.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ export type TestFn<Context = unknown> = {
afterEach: AfterFn<Context>;
before: BeforeFn<Context>;
beforeEach: BeforeFn<Context>;
cleanup: CleanupFn<Context>;
cleanupEach: CleanupFn<Context>;
failing: FailingFn<Context>;
macro: MacroFn<Context>;
meta: Meta;
Expand Down Expand Up @@ -150,6 +152,22 @@ export type BeforeFn<Context = unknown> = {
skip: HookSkipFn<Context>;
};

export type CleanupFn<Context = unknown> = {
/**
* Declare a cleanup hook that is run before and after tests.
* Additional arguments are passed to the implementation or macro.
*/
<Args extends unknown[]>(title: string, implementation: Implementation<Args, Context>, ...args: Args): void;

/**
* Declare a cleanup hook that is run before and after tests.
* Additional arguments are passed to the implementation or macro.
*/
<Args extends unknown[]>(implementation: Implementation<Args, Context>, ...args: Args): void;

skip: HookSkipFn<Context>;
};

export type FailingFn<Context = unknown> = {
/**
* Declare a concurrent test that is expected to fail.
Expand Down Expand Up @@ -209,6 +227,8 @@ export type SerialFn<Context = unknown> = {
afterEach: AfterFn<Context>;
before: BeforeFn<Context>;
beforeEach: BeforeFn<Context>;
cleanup: CleanupFn<Context>;
cleanupEach: CleanupFn<Context>;
failing: FailingFn<Context>;
only: OnlyFn<Context>;
/** Declare a test that only runs when `condition` is true; otherwise the test is skipped. */
Expand Down
Loading