Skip to content

Commit

Permalink
Add t.teardown()
Browse files Browse the repository at this point in the history
Co-Authored-By: Mark Wubben <mark@novemberborn.net>
  • Loading branch information
ulken and novemberborn committed Apr 26, 2020
1 parent 993bb90 commit 75cbc3b
Show file tree
Hide file tree
Showing 8 changed files with 218 additions and 4 deletions.
2 changes: 2 additions & 0 deletions docs/01-writing-tests.md
Expand Up @@ -167,6 +167,8 @@ AVA lets you register hooks that are run before and after your tests. This allow

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.

Like `test()` these methods take an optional title and an implementation function. The title is shown if your hook fails to execute. The implementation is called with an [execution object](./02-execution-context.md). You can use assertions in your hooks. You can also pass a [macro function](#reusing-test-logic-through-macros) and additional arguments.

`.before()` hooks execute before `.beforeEach()` hooks. `.afterEach()` hooks execute before `.after()` hooks. Within their category the hooks execute in the order they were defined. By default hooks execute concurrently, but you can use `test.serial` to ensure only that single hook is run at a time. Unlike with tests, serial hooks are *not* run before other hooks:
Expand Down
16 changes: 12 additions & 4 deletions docs/02-execution-context.md
Expand Up @@ -26,10 +26,6 @@ Contains shared state from hooks.

Whether a test has passed. This value is only accurate in the `test.afterEach()` and `test.afterEach.always()` hooks.

## `t.plan(count)`

Plan how many assertion there are in the test. The test will fail if the actual assertion count doesn't match the number of planned assertions. See [assertion planning](./03-assertions.md#assertion-planning).

## `t.end()`

End the test. Only works with `test.cb()`.
Expand All @@ -38,6 +34,18 @@ End the test. Only works with `test.cb()`.

Log values contextually alongside the test result instead of immediately printing them to `stdout`. Behaves somewhat like `console.log`, but without support for placeholder tokens.

## `t.plan(count)`

Plan how many assertion there are in the test. The test will fail if the actual assertion count doesn't match the number of planned assertions. See [assertion planning](./03-assertions.md#assertion-planning).

## `t.teardown(fn)`

Registers the `fn` function to be run after the test has finished. You can register multiple functions and they'll run in order. You can use asynchronous functions: only one will run at a time.

You cannot perform assertions using the `t` object or register additional functions from inside `fn`.

You cannot use `t.teardown()` in hooks either.

## `t.timeout(ms)`

Set a timeout for the test, in milliseconds. The test will fail if this timeout is exceeded. The timeout is reset each time an assertion is made.
2 changes: 2 additions & 0 deletions docs/recipes/test-setup.md
Expand Up @@ -64,6 +64,8 @@ test('second scenario', t => {
});
```

You can use [`t.teardown()`](../02-execution-context.md#tteardownfn) to register a teardown function which will run after the test has finished (regardless of whether it's passed or failed).

## A practical example

```js
Expand Down
6 changes: 6 additions & 0 deletions index.d.ts
Expand Up @@ -313,6 +313,7 @@ export interface ExecutionContext<Context = unknown> extends Assertions {

log: LogFn;
plan: PlanFn;
teardown: TeardownFn;
timeout: TimeoutFn;
try: TryFn<Context>;
}
Expand Down Expand Up @@ -344,6 +345,11 @@ export interface TimeoutFn {
(ms: number): void;
}

export interface TeardownFn {
/** Declare a function to be run after the test has ended. */
(fn: () => void): void;
}

export interface TryFn<Context = unknown> {
/**
* Attempt to run some assertions. The result must be explicitly committed or discarded or else
Expand Down
1 change: 1 addition & 0 deletions lib/runner.js
Expand Up @@ -283,6 +283,7 @@ class Runner extends Emittery {
metadata: task.metadata,
powerAssert: this.powerAssert,
title: `${task.title}${titleSuffix || ''}`,
isHook: true,
testPassed
}));
const outcome = await this.runMultiple(hooks, this.serial);
Expand Down
35 changes: 35 additions & 0 deletions lib/test.js
Expand Up @@ -68,6 +68,10 @@ class ExecutionContext extends assert.Assertions {
test.timeout(ms);
};

this.teardown = callback => {
test.addTeardown(callback);
};

this.try = async (...attemptArgs) => {
const {args, buildTitle, implementations, receivedImplementationArray} = parseTestArgs(attemptArgs);

Expand Down Expand Up @@ -193,12 +197,14 @@ class Test {
this.experiments = options.experiments || {};
this.failWithoutAssertions = options.failWithoutAssertions;
this.fn = options.fn;
this.isHook = options.isHook === true;
this.metadata = options.metadata;
this.powerAssert = options.powerAssert;
this.title = options.title;
this.testPassed = options.testPassed;
this.registerUniqueTitle = options.registerUniqueTitle;
this.logs = [];
this.teardowns = [];

const {snapshotBelongsTo = this.title, nextSnapshotIndex = 0} = options;
this.snapshotBelongsTo = snapshotBelongsTo;
Expand Down Expand Up @@ -457,6 +463,34 @@ class Test {
this.timeoutTimer = null;
}

addTeardown(callback) {
if (this.isHook) {
this.saveFirstError(new Error('`t.teardown()` is not allowed in hooks'));
return;
}

if (this.finishing) {
this.saveFirstError(new Error('`t.teardown()` cannot be used during teardown'));
return;
}

if (typeof callback !== 'function') {
throw new TypeError('Expected a function');
}

this.teardowns.push(callback);
}

async runTeardowns() {
for (const teardown of this.teardowns) {
try {
await teardown(); // eslint-disable-line no-await-in-loop
} catch (error) {
this.saveFirstError(error);
}
}
}

verifyPlan() {
if (!this.assertError && this.planCount !== null && this.planCount !== this.assertCount) {
this.saveFirstError(new assert.AssertionError({
Expand Down Expand Up @@ -670,6 +704,7 @@ class Test {
this.clearTimeout();
this.verifyPlan();
this.verifyAssertions();
await this.runTeardowns();

this.duration = nowAndTimers.now() - this.startedAt;

Expand Down
20 changes: 20 additions & 0 deletions test-tap/hooks.js
Expand Up @@ -546,3 +546,23 @@ test('shared context of any type', t => {
});
});
});

test('teardowns cannot be used in hooks', async t => {
let hookFailure = null;
await promiseEnd(new Runner(), runner => {
runner.on('stateChange', evt => {
if (evt.type === 'hook-failed') {
hookFailure = evt;
}
});

runner.chain.beforeEach(a => {
a.teardown(() => {});
});

runner.chain('test', a => a.pass());
});

t.ok(hookFailure);
t.match(hookFailure.err.message, /not allowed in hooks/);
});
140 changes: 140 additions & 0 deletions test-tap/test.js
Expand Up @@ -5,6 +5,7 @@ require('../lib/worker/options').set({});
const path = require('path');
const React = require('react');
const {test} = require('tap');
const sinon = require('sinon');
const delay = require('delay');
const snapshotManager = require('../lib/snapshot-manager');
const Test = require('../lib/test');
Expand Down Expand Up @@ -746,6 +747,133 @@ test('timeout is refreshed on assert', t => {
});
});

test('teardown passing test', t => {
const teardown = sinon.spy();
return ava(a => {
a.teardown(teardown);
a.pass();
}).run().then(result => {
t.is(result.passed, true);
t.ok(teardown.calledOnce);
});
});

test('teardown failing test', t => {
const teardown = sinon.spy();
return ava(a => {
a.teardown(teardown);
a.fail();
}).run().then(result => {
t.is(result.passed, false);
t.ok(teardown.calledOnce);
});
});

test('teardown awaits promise', t => {
let tornDown = false;
const teardownPromise = delay(200).then(() => {
tornDown = true;
});
return ava(a => {
a.teardown(() => teardownPromise);
a.pass();
}).run().then(result => {
t.is(result.passed, true);
t.ok(tornDown);
});
});

test('teardowns run sequentially in order', t => {
const teardownA = sinon.stub().resolves(delay(200));
let resolveB;
const teardownB = sinon.stub().returns(new Promise(resolve => {
resolveB = resolve;
}));
return ava(a => {
a.teardown(() => teardownA().then(resolveB));
a.teardown(teardownB);
a.pass();
}).run().then(result => {
t.is(result.passed, true);
t.ok(teardownA.calledBefore(teardownB));
});
});

test('teardown with cb', t => {
const teardown = sinon.spy();
return ava.cb(a => {
a.teardown(teardown);
setTimeout(() => {
a.pass();
a.end();
});
}).run().then(result => {
t.is(result.passed, true);
t.ok(teardown.calledOnce);
});
});

test('teardown without function callback fails', t => {
return ava(a => {
return a.throwsAsync(async () => {
a.teardown(false);
}, {message: 'Expected a function'});
}).run().then(result => {
t.is(result.passed, true);
});
});

test('teardown errors fail the test', t => {
const teardown = sinon.stub().throws('TeardownError');
return ava(a => {
a.teardown(teardown);
a.pass();
}).run().then(result => {
t.is(result.passed, false);
t.is(result.error.name, 'TeardownError');
t.ok(teardown.calledOnce);
});
});

test('teardown errors are hidden behind assertion errors', t => {
const teardown = sinon.stub().throws('TeardownError');
return ava(a => {
a.teardown(teardown);
a.fail();
}).run().then(result => {
t.is(result.passed, false);
t.is(result.error.name, 'AssertionError');
t.ok(teardown.calledOnce);
});
});

test('teardowns errors do not stop next teardown from running', t => {
const teardownA = sinon.stub().throws('TeardownError');
const teardownB = sinon.spy();
return ava(a => {
a.teardown(teardownA);
a.teardown(teardownB);
a.pass();
}).run().then(result => {
t.is(result.passed, false);
t.is(result.error.name, 'TeardownError');
t.ok(teardownA.calledOnce);
t.ok(teardownB.calledOnce);
t.ok(teardownA.calledBefore(teardownB));
});
});

test('teardowns cannot be registered by teardowns', async t => {
const result = await ava(a => {
a.teardown(() => {
a.teardown(() => {});
});
a.pass();
}).run();
t.is(result.passed, false);
t.match(result.error.message, /cannot be used during teardown/);
});

test('.log() is bound', t => {
return ava(a => {
const {log} = a;
Expand Down Expand Up @@ -796,3 +924,15 @@ test('.end() is bound', t => {
t.true(result.passed);
});
});

test('.teardown() is bound', t => {
const teardownCallback = sinon.spy();
return ava(a => {
const {teardown} = a;
teardown(teardownCallback);
a.pass();
}).run().then(result => {
t.true(result.passed);
t.ok(teardownCallback.calledOnce);
});
});

0 comments on commit 75cbc3b

Please sign in to comment.