Before writing a test you must decide what to check. The Testing Library principle is short: the more your test resembles real use of the code, the more confidence it gives. You should check observable behavior - what the caller sees: the returned value, the error, the change on screen - not the private details of the internals. The unit of a test is not a file or a class, but one meaningful piece of behavior with an input and an observable output.
This is easiest to see on a pure function - one that depends only on its arguments and touches nothing outside: neither the clock, nor the network, nor global variables. calculateDelivery is exactly that: give it an order, get a number back. Purity is a gift for a test: one input always yields one output, and the check reduces to a table of 'input - expected result' pairs. Such functions are where to begin - they teach the essence of checking without the distraction of mocks and timers.
The body of the test falls into three steps - Arrange-Act-Assert. Arrange assembles the input: an order object with a total and a premium flag. Act is exactly one call of the code: const price = calculateDelivery(order). Assert checks the observable result: expect(price).toBe(300). The three phases are not decoration but a markup of meaning: you see at once what is fed as input, what action is checked, and what outcome counts as correct.
One test describes one behavior, and its name reads like a line of the specification. 'Makes delivery free from 5,000 rubles' is a rule even a non-programmer understands; it survives any refactor of the internals. At the same time one behavior may require several assertions: if the function returns an object with a price and a term, checking both fields in one test is more honest than two separate ones. The rule is not 'one expect per test' but 'one outcome per test'.
Next you choose the cases, and a good set covers three kinds. The normal case is a typical input in the middle of the range: an order of 1,000 rubles, delivery 300. The boundary case is the points where behavior switches: exactly 5,000, where delivery is free, and 4,999 - one ruble below; most bugs hide at the boundaries, things like 'greater than' instead of 'greater than or equal'. The invalid case is an input you must not accept silently: a negative total or null.
When there are several cases but a single checking logic, you do not duplicate them by hand - you use test.each, a table of inputs and expectations. Each row becomes a separate test with a name from the data, so the report shows exactly which pair failed. This is a continuation of the idea of a pure function: its behavior is the table.
Not every piece of code deserves a test, and knowing that matters as much as being able to write checks. A trivial getter, a thin wrapper over a library, a test that repeats the implementation word for word - none adds confidence, yet all add weight through every refactor. Do not test someone else's library - it is not your bug - or private details: a test on a private method turns red on a rename even though behavior did not change. If a test cannot fail for a meaningful reason, it is not written.
The price of a considered choice is a few minutes on the question 'which behavior and which cases' before the first expect. The payoff is a suite that catches real regressions and does not crumble on cosmetic edits; the same principles carry over later to a component - a cart button's observable behavior is its label and its reaction to a click, not its internal state. A typical failure: a hundred tests on getters give pretty coverage and zero caught bugs, while the unchecked boundary '5,000 or 4,999' brings down production.
describe('calculateDelivery', () => {
test('charges 300 for a standard order', () => {
const order = { total: 1_000, premium: false } // Arrange
const price = calculateDelivery(order) // Act
expect(price).toBe(300) // Assert
})
})test.each([
{ total: 1_000, expected: 300 }, // normal case
{ total: 5_000, expected: 0 }, // boundary: free from 5,000
{ total: 4_999, expected: 300 }, // boundary: one ruble below
])('total $total -> delivery $expected', ({ total, expected }) => {
expect(calculateDelivery({ total, premium: false })).toBe(expected)
})