Translations: Español, Français, Italiano, 日本語, Português, Русский, 简体中文
One major difference between AVA and tap/tape is the behavior of t.plan(). In AVA, t.plan() is only used to assert that the expected number of assertions are called; it does not auto-end the test.
Many users transitioning from tap/tape are accustomed to using t.plan() prolifically in every test. However, in AVA, we don't consider that to be a "best practice". Instead, we believe t.plan() should only be used in situations where it provides some value.
t.plan() is unnecessary in most sync tests.
test('simple sums', t => {
// BAD: there is no branching here - t.plan() is pointless
t.plan(2);
t.is(1 + 1, 2);
t.is(2 + 2, 4);
});t.plan() does not provide any value here, and creates an extra chore if you ever decide to add or remove assertions.
test('gives foo', t => {
t.plan(1);
return somePromise().then(result => {
t.is(result, 'foo');
});
});At a glance, this tests appears to make good use of t.plan() since an async promise handler is involved. However there are several problems with the test:
-
t.plan()is presumably used here to protect against the possibility thatsomePromise()might be rejected; But returning a rejected promise would fail the test anyways. -
It would be better to take advantage of
async/await:
test('gives foo', async t => {
t.is(await somePromise(), 'foo');
});test('rejects with foo', t => {
t.plan(2);
return shouldRejectWithFoo().catch(reason => {
t.is(reason.message, 'Hello');
t.is(reason.foo, 'bar');
});
});Here, the use of t.plan() seeks to ensure that the code inside the catch block is executed.
Instead, you should take advantage of t.throwsAsync and async/await, as this leads to flatter code that is easier to reason about:
test('rejects with foo', async t => {
const reason = await t.throwsAsync(shouldRejectWithFoo());
t.is(reason.message, 'Hello');
t.is(reason.foo, 'bar');
});test('throws', t => {
t.plan(2);
try {
shouldThrow();
} catch (err) {
t.is(err.message, 'Hello');
t.is(err.foo, 'bar');
}
});As stated in the previous example, using the t.throws() assertion with async/await is a better choice.
t.plan() provides value in the following cases.
In most cases, it's a bad idea to use any complex branching inside your tests. A notable exception is for tests that are auto-generated (perhaps from a JSON document). Below t.plan() is used to ensure the correctness of the JSON input:
import fs from 'node:fs';
import path from 'node:path';
const testData = JSON.parse(fs.readFileSync(new URL('./fixtures/test-definitions.json', import.meta.url)));
for (const testDefinition of testData) {
test('foo or bar', t => {
const result = functionUnderTest(testDefinition.input);
// testDefinition should have an expectation for `foo` or `bar` but not both
t.plan(1);
if (testDefinition.foo) {
t.is(result.foo, testDefinition.foo);
}
if (testDefinition.bar) {
t.is(result.bar, testDefinition.foo);
}
});
}t.plan() has plenty of valid uses, but it should not be used indiscriminately. A good rule of thumb is to use it any time your test does not have straightforward, easily reasoned about, code flow. Tests with assertions inside callbacks, if/then statements, for/while loops, and (in some cases) try/catch blocks, are all good candidates for t.plan().