Arrange, Act, Assert
A test that jumbles setup, logic, and expectations together tells you nothing useful when it fails. You spend more time reverse-engineering the test than fixing the code. The solution is a simple three-act structure: Arrange, Act, Assert.
it('calculates the cart total', () => {
// Arrange
const cart = new Cart();
cart.add(new Item('Apple', 1.00));
cart.add(new Item('Banana', 0.50));
// Act
const total = cart.total();
// Assert
expect(total).toBe(1.50);
});
In the Arrange phase, you set the stage — create the objects, populate the database, configure your mocks. By the end of this phase, the system should be in the exact state the test requires. In the Act phase, a single line triggers the behavior you're testing. If it runs longer than a line or two, the test is covering too much at once and will be hard to diagnose when it breaks. In the Assert phase, you verify one outcome — the return value, the side effect, the record that should have been written. If you find yourself asserting multiple things, the test is probably doing too much.
The discipline is keeping these phases distinct. Here's what it looks like when they bleed together:
it('calculates the cart total', () => {
const cart = new Cart();
cart.add(new Item('Apple', 1.00));
expect(cart.total()).toBe(1.00); // asserting mid-setup
cart.add(new Item('Banana', 0.50));
expect(cart.total()).toBe(1.50);
});
This passes today, but when it fails you can't tell whether the setup is wrong or the logic is. Keeping the phases clean gives each test one job: given this state, when I do this, the result should be this. A suite of tests that each tell that story is the best living documentation a codebase has.