If you are only going to write a few tests, write these

We all know we should write tests. Almost nobody has the time to write all the ones that “should” exist, and the usual advice — aim for a high coverage percentage — is exactly what makes people give up, because it sounds like an enormous job with no end to it.
Let us change the question. If you could only write ten tests in the entire project, which ones?
The answer is fairly clear, and almost none of them are the ones people write first.
First: the ones that are worth nothing
Worth getting out of the way, because these are the ones that get written most.
The ones that test that the language works.
test('the user has a name', () => {
const u = new User({ name: 'Ana' });
expect(u.name).toBe('Ana');
});
That tests that assigning a property works. We already knew that.
The ones that repeat the implementation.
test('calculates the total', () => {
expect(calculateTotal(100, 0.21)).toBe(100 * 1.21);
});
If the code has the bug, the test has the same bug. Write the expected result as
a number: 121. If you do not know what it should come out to, the test is not
verifying anything.
The ones that test what the compiler already guarantees. If your function
takes a number, there is no need to test what happens when you pass it a
string.
The four that are worth it
1. The bug that already happened to you
This is the best ratio of effort to benefit, and it requires no decision at all: every time you fix a bug, write a test that reproduces it before you fix it.
test('an order with no items does not break the shipping calculation', () => {
expect(calculateShipping({ items: [] })).toBe(0);
});
That test has two virtues. You already know the case is real, because it happened. And it stops the bug coming back, which is what bugs actually do in the code that gets touched the most.
If you adopt one single habit from this list, make it this one.
2. Business rules involving money, dates or permissions
These are the ones that do real damage when they fail, and the ones with the most edge cases.
test('a discount cannot make the total negative', () => {
expect(applyDiscount(50, 80)).toBe(0);
});
test('a subscription that expired yesterday no longer grants access', () => {
const yesterday = new Date(Date.now() - 86400000);
expect(hasAccess({ expiresOn: yesterday })).toBe(false);
});
A styling mistake is visible. A miscalculated discount gets billed wrong for months until somebody notices.
3. One test that walks the whole main path
Just one, end to end, of the flow that holds your product up: sign up, buy, publish. Not of one function: of the whole path.
test('a user can buy something', async () => {
const user = await register('ana@example.com');
const cart = await addToCart(user, 'product-1');
const order = await checkout(cart, testCard);
expect(order.status).toBe('confirmed');
expect(await countOrders(user)).toBe(1);
});
It is slower and more annoying to maintain than the others. And it is the only one that catches two parts that work fine on their own but have stopped understanding each other, which is where the most expensive failures come from.
One is enough. If two flows hold your product up, then two.
4. The edges of whatever comes in from outside
Everything that enters from outside your program can arrive in any shape: what a user types, what an API returns, what is in a file.
test('accepts an email with capitals and surrounding spaces', () => {
expect(normaliseEmail(' Ana@Example.COM ')).toBe('ana@example.com');
});
test('does not break if the API leaves out the price field', () => {
expect(() => processProduct({ name: 'x' })).not.toThrow();
});
How to tell whether a test is worth it
One question: if this breaks and nobody notices, what happens?
- Someone gets billed wrong → write it.
- Someone sees another person’s data → write it now.
- A button ends up the wrong colour → no.
And a second one, for the tests you already have: has this test ever failed because of a real bug? A test that has never failed, or that only fails when you change the code on purpose, is costing you maintenance and giving nothing back.
About the coverage percentage
It is the most used metric and the one that measures what matters worst. You can have 90% by running every line without verifying anything:
test('does not break', () => {
processOrder(sampleOrder); // not a single expect
});
That test adds coverage and checks absolutely nothing.
Ten well chosen tests are worth more than two hundred automatic ones. And as a side benefit, ten tests run in two seconds, so you will actually run them. Two hundred take three minutes, and you end up never running them at all.
Comments
Sign in to comment and to like this article.
No comments yet. Be the first to write one.