diff --git a/docs/01-writing-tests.md b/docs/01-writing-tests.md index f21c44546..ce2724360 100644 --- a/docs/01-writing-tests.md +++ b/docs/01-writing-tests.md @@ -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: diff --git a/docs/02-execution-context.md b/docs/02-execution-context.md index 71b312a2c..730921e22 100644 --- a/docs/02-execution-context.md +++ b/docs/02-execution-context.md @@ -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()`. @@ -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. diff --git a/docs/recipes/test-setup.md b/docs/recipes/test-setup.md index 27bbbcdd0..3f4175c1d 100644 --- a/docs/recipes/test-setup.md +++ b/docs/recipes/test-setup.md @@ -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 diff --git a/index.d.ts b/index.d.ts index 7dab001c0..835be8035 100644 --- a/index.d.ts +++ b/index.d.ts @@ -313,6 +313,7 @@ export interface ExecutionContext extends Assertions { log: LogFn; plan: PlanFn; + teardown: TeardownFn; timeout: TimeoutFn; try: TryFn; } @@ -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 { /** * Attempt to run some assertions. The result must be explicitly committed or discarded or else diff --git a/lib/runner.js b/lib/runner.js index bd93042e8..c46fadf5d 100644 --- a/lib/runner.js +++ b/lib/runner.js @@ -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); diff --git a/lib/test.js b/lib/test.js index f91a471f4..f5d75e45c 100644 --- a/lib/test.js +++ b/lib/test.js @@ -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); @@ -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; @@ -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({ @@ -670,6 +704,7 @@ class Test { this.clearTimeout(); this.verifyPlan(); this.verifyAssertions(); + await this.runTeardowns(); this.duration = nowAndTimers.now() - this.startedAt; diff --git a/test-tap/hooks.js b/test-tap/hooks.js index 00be44408..1898f578d 100644 --- a/test-tap/hooks.js +++ b/test-tap/hooks.js @@ -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/); +}); diff --git a/test-tap/test.js b/test-tap/test.js index ab7dce4ae..ad2e8c812 100644 --- a/test-tap/test.js +++ b/test-tap/test.js @@ -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'); @@ -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; @@ -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); + }); +});