PLAINFINCH learn by building

Arrange, act, assert

You will write tests in three clear steps, set up, run, check, and use plain numbers that expose a hidden discount bug.

What are the three parts of a test?

Most good tests have the same shape. Arrange sets up the situation: the inputs, the objects, anything the code needs. Act runs the code you are testing, usually one function call. Assert checks the result with expect(). Many developers leave a blank line between the parts, and some add the three words as comments while they get used to it.

The shape makes a test quick to read. Someone opening discount.test.js should see the situation, the call and the answer in a few seconds, without tracing variables up and down the file. When a test fails, the arrange block tells you exactly what was fed in, and the assert block shows what was promised.

test('an order of exactly 30 gets 10% off', () => {
  // Arrange
  const subtotal = 30;

  // Act
  const total = orderTotal(subtotal, false);

  // Assert
  expect(total).toBe(27);
});
FigureThe test 'an order of exactly 30 gets 10% off' in three parts: Arrange sets subtotal = 30, Act calls orderTotal(subtotal, false), Assert expects total toBe 27.The test 'an order of exactly 30 gets 10% off' in three parts: Arrange sets subtotal = 30, Act calls orderTotal(subtotal, false), Assert expects total toBe 27.
Every good test has the same three parts: set up the input, run the code once, check the result. A blank line between them makes the shape easy to see.

Why did the loop test miss the bug?

The starting test checks three subtotals in a loop and works out each expected value with if (subtotal > 30). That is the same comparison the code makes. When the code is wrong, the test is wrong in exactly the same way, so they agree and the test passes. The rule says orders of 30 or more get the discount, and neither file follows it.

There is a second problem. The loop never tries 30, because the list was written by someone thinking of small, medium and large orders. A hand-written test for each situation makes you decide which situations matter, and the boundary at 30 is an obvious one once you write the rule next to the test.

Logic in a test is any if, loop, or calculation that produces the expected value. Each piece is a small program that needs its own tests. Plain values like 27 need none: you can check them against the rule with a pencil.

How do you choose readable test data?

Use the smallest, most obvious numbers that show the rule. An order of 12 is clearly small. An order of 30 is the exact threshold. An order of 20 is the member threshold. With these, 10% off and 5 off are easy to work out in your head, and a reader can check every expected value without a calculator.

Avoid values that hide what is going on. A subtotal of 37.85 makes the expected value 34.07 after rounding, and nobody reading the test can tell at a glance whether that is right. Real-looking data has its place, but in unit tests the job is to make the rule visible.

Name things after what they mean. const isMember = true before the call reads better than a bare true in the argument list, because a reader does not have to remember what the second parameter is.

  • Pick round numbers that make the arithmetic obvious
  • Put each threshold value in its own test
  • Name arguments whose meaning is not clear from the call
  • Write the expected value as a literal, never as a formula

Why should a test act only once?

One call in the act block means one behaviour under test. If a test calls orderTotal three times with different inputs and checks each, a failure in the first check stops the test there, and you never learn whether the other two would have passed. Three tests give you three separate answers.

Some behaviours need several steps to set up, such as signing two people up for a walk before testing that a third is refused. Those earlier calls belong in arrange: they build the situation. Better still, build the situation directly, by creating a walk object that already has two people. The act block then holds the one call whose result you care about, which keeps the test focused on the refusal.

Why must each test arrange its own data?

In the Break It file, one walk object sits at the top of the file and both tests change it. The second test relies on the first having already signed Ada up. Run the second test on its own with .only and it starts with an empty walk, so its result changes. Tests that depend on the order they run in break in confusing ways when someone adds, removes or reorders tests.

Vitest and Jest run tests in a file in the order they are written, by default, and they do not reset your variables between tests. A plain object at the top of the file is shared by every test in it. Create fresh data inside each test, or use beforeEach to rebuild it, which the setup and teardown lesson covers.

test('a full walk turns people away', () => {
  const walk = { place: 'Heron Marsh', spaces: 2, people: ['Ada', 'Ravi'] };

  const accepted = signUp(walk, 'Mo');

  expect(accepted).toBe(false);
});

Is some repetition between tests acceptable?

Yes, more than in ordinary code. Each test should make sense on its own, top to bottom, without scrolling to a helper. Repeating a three-line object literal in four tests is fine when it shows each reader exactly what the test starts with.

When the setup grows long, a small helper that builds data is reasonable, such as makeWalk({ spaces: 2, people: ['Ada'] }). Keep the values that matter to the test visible in the call, and let the helper fill in the rest. A helper that hides the important values makes the test harder to read than the repetition it replaced.

How do you spot weak structure in AI-written tests?

Generated tests often compute expected values with the same formula as the code, expect(orderTotal(40, false)).toBe(40 * 0.9). That looks careful, and it still repeats the code's assumptions. Ask for literal numbers, or replace the formulas yourself after checking them against the rule.

They also tend to build one big shared fixture at the top of the file and let tests modify it, and to loop over a table of inputs with the expected values worked out inside the loop. Look for any line in a test that does arithmetic or decides something, and for any const at file level that a test changes. Each one is a place where the test can be wrong without anyone noticing.

Real tables of cases are sometimes useful, and test.each gives each row its own name in Results. The edge cases lesson shows how, with the expected values written out in the table instead of calculated.

Field notes

  • Arrange sets up the data, act runs the code once, assert checks the result.
  • A test that copies the code's logic also copies the code's mistakes.
  • Write expected values as plain numbers you can check with a pencil.
  • Each test should create its own data so it passes on its own.

Easy to get wrong

The loop recalculates the answer with the code's own logic, so it agrees with the code even when both are wrong. A literal value is checked against the rule, once, by a person.

Shared data that tests change makes each test depend on the ones before it. Run one alone or in a different order and it fails for reasons unrelated to the code.

An expected value built from the same sum as the code can never disagree with it. The test passes whatever spacesLeft does with that sum.

Questions people ask

Do I need to write the Arrange, Act, Assert comments?

No. Blank lines between the three parts are enough once the habit sticks. The comments help while you are learning and in long tests.

What is given, when, then?

The same idea with different words, popular in behaviour-driven development: given a situation, when something happens, then expect a result. Use whichever names your team prefers.

Can the act and assert be on one line?

Yes, for short tests: expect(orderTotal(30, false)).toBe(27) acts and asserts at once and reads well. Split them when the call is long or you want to check several things about one result.

Is it ever fine to use a variable in the expected value?

A named constant from the arrange block is fine, such as expect(names).toContain(newMember). Arithmetic or logic that recomputes the answer is what to avoid.

Keep going