Before writing a test, it is worth answering what exactly it checks. The professional answer is observable behavior: what the calling code sees through the public boundary. A test of a private detail (an internal field, the order of private calls) breaks on any refactor, even when behavior did not change, and turns from insurance into a nuisance. Test the contract, not the internals.
A test's unit is not necessarily one function. It is a piece of code with a clear input-output boundary: a pure function, a service method, a request handler. The clearer the boundary, the simpler the test: you feed the input, read the output, compare it with the expectation. A good candidate for the first test is a pure function like calculateDelivery: it has no hidden state, and the result depends only on the arguments.
A test's structure rests on three phases - Arrange, Act, Assert. Arrange prepares data and environment, Act performs one action under test, Assert compares the result with the expectation. It is worth separating the phases explicitly: then the test reads top to bottom without jumps, and it is immediately clear what was prepared, what was called and what is checked. Mixed phases are the first sign of a test that is hard to understand when it fails.
One test describes one behavior. Several assertions are fine if they are all about one outcome - for example, checking different fields of one result. But five different scenarios in one test are five reasons to fail under one name: it is unclear what exactly broke. Different outcomes go into separate tests with telling names.
Cases are chosen deliberately, not by a single happy path. It helps to take four classes: normal, boundary (exactly 5000, zero, an empty list), invalid (a negative amount) and failure (the function must throw). It is the boundaries that catch most bugs: naive code is often correct in the middle of the range and wrong right at the edge. It helps to state the expectation for each class before writing the code - then the tests drive the implementation rather than being fitted to what is already there.
When the same scenario is checked over different inputs, parameterization via test.each helps. A table of inputs and expectations removes copy-paste and makes edge cases explicit: they are visible as a list rather than hidden in a loop. Each table row is a separate test with its own name, so a failure points at a specific case rather than the whole set.
It is worth writing a test's name as a statement about the condition and the observable outcome: 'makes delivery free from 5000'. Then the list of tests reads as a specification of the module's behavior, not as a set of technical labels. It also disciplines the author: if the outcome is hard to name in one phrase, the test probably checks several things at once and it is time to split it.
Finally, not every piece of code deserves a test. A trivial getter, a thin wrapper with no logic, or a third-party library's behavior are of little value to test: you are checking code that is not yours and spending time on brittle tests. A test's value is in the regression it catches, not in the line count or coverage percent. Write a test where a bug is likely and costly, and skip it where there is nothing to check.
describe('calculateDelivery', () => {
test('makes delivery free from 5000', () => {
const order = orderBuilder().withTotal(5_000).build() // Arrange
const price = calculateDelivery(order) // Act
expect(price).toBe(0) // Assert
})
})test.each([
{ total: 1_000, expected: 300 }, // normal
{ total: 4_999, expected: 300 }, // boundary
{ total: 5_000, expected: 0 }, // boundary
])('costs $expected for a total of $total', ({ total, expected }) => {
expect(calculateDelivery(orderBuilder().withTotal(total).build())).toBe(expected)
})