Skip to content

Latest commit

 

History

History
133 lines (92 loc) · 4.53 KB

when-to-use-plan.md

File metadata and controls

133 lines (92 loc) · 4.53 KB

When to use t.plan()

Translations: Español, Français, 日本語, 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.

Poor uses of t.plan()

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.

Sync tests with no branching

t.plan() is unnecessary in most sync tests.

test(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.

Promises that are expected to resolve

test(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:

  1. t.plan() is presumably used here to protect against the possibility that somePromise() might be rejected; But returning a rejected promise would fail the test anyways.

  2. It would be better to take advantage of async/await:

test(async t => {
	t.is(await somePromise(), 'foo');
});

Good uses of t.plan()

t.plan() has many acceptable uses.

Promises with a .catch() block

test(t => {
	t.plan(2);

	return shouldRejectWithFoo().catch(reason => {
		t.is(reason.message, 'Hello') // Prefer t.throws() if all you care about is the message
		t.is(reason.foo, 'bar');
	});
});

Here, t.plan() is used to ensure the code inside the catch block happens. In most cases, you should prefer the t.throws() assertion, but this is an acceptable use since t.throws() only allows you to assert against the error's message property.

Ensuring a catch statement happens

test(t => {
	t.plan(2);

	try {
		shouldThrow();
	} catch (err) {
		t.is(err.message, 'Hello') // Prefer t.throws() if all you care about is the message
		t.is(err.foo, 'bar');
	}
});

As stated in the try/catch example above, using the t.throws() assertion is usually a better choice, but it only lets you assert against the error's message property.

Ensuring multiple callbacks are actually called

test.cb(t => {
	t.plan(2);

	const callbackA = () => {
		t.pass();
		t.end();
	};

	const callbackB = () => t.pass();

	bThenA(callbackA, callbackB);
});

The above ensures callbackB is called first (and only once), followed by callbackA. Any other combination would not satisfy the plan.

Tests with branching statements

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:

const testData = require('./fixtures/test-definitions.json');

testData.forEach(testDefinition => {
	test(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);
		}
	});
});

Conclusion

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().