Arrange, act, assert (often shortened to AAA) is a way of laying out a test body in three visible blocks. First you build the situation the behavior needs. Then you do the one thing you’re testing. Then you check the result. That’s the whole rule, and it sounds too obvious to need a name, yet most hard-to-read tests break it in the same way: they set something up, poke it, check it, set up something else, poke again, and check again, all inside one test called orders.
The point of the layout isn’t tidiness. It’s that a failing test should tell you which behavior broke without making you read the code line by line. When each test has exactly one act, the test name plus the assertion message is usually enough to find the bug.
What each block is for
Arrange creates the starting state that matters for this behavior: an order whose status is pending, a repository that already holds it, an empty outbox to capture events. It should contain the values the outcome depends on and as little else as possible. If the order’s total has nothing to do with cancellation, it can come from a default; if the status decides the outcome, it should be written in the test where a reader sees it.
Act is a single call to the thing under test: service.cancel('A-1', 'customer request'). One line is normal. If you find yourself writing two calls here, you’re probably testing two behaviors, or the second call is really part of the arrangement (more on that below).
Assert checks what a caller could observe after that call: the returned value, the stored state, the event that was published, the error that was thrown. It doesn’t re-run the logic to compute the expected answer, and it doesn’t contain if statements. A test with a conditional in its assert block can pass by taking the branch you didn’t think about.
The behavior-driven development crowd calls the same three parts Given, When, Then. The names differ; the structure is identical. If an interviewer asks about one, mention the other.
A tangled test, and what its failure tells you
Here’s an order service with the rules most shops have: a pending order can be cancelled, a shipped one can’t, and cancelling an already cancelled order is a no-op so a double click doesn’t publish two events.
// orders.js
export class OrderService {
constructor(repo, outbox) {
this.repo = repo;
this.outbox = outbox;
}
cancel(orderId, reason) {
const order = this.repo.get(orderId);
if (!order) throw new Error(`order ${orderId} not found`);
if (order.status === 'shipped') throw new Error(`order ${orderId} has already shipped`);
if (order.status === 'cancelled') return order;
const cancelled = { ...order, status: 'cancelled', cancelReason: reason };
this.repo.save(cancelled);
this.outbox.push({ type: 'OrderCancelled', orderId, reason });
return cancelled;
}
}
export class InMemoryOrders {
#rows = new Map();
get(id) { return this.#rows.get(id); }
save(order) { this.#rows.set(order.id, order); }
}Now a test written the way tests often grow, one more check bolted on each time someone touched the code:
test('orders', () => {
const repo = new InMemoryOrders();
const outbox = [];
const service = new OrderService(repo, outbox);
repo.save({ id: 'A-1', status: 'pending', total: 4200 });
service.cancel('A-1', 'customer request');
assert.equal(repo.get('A-1').status, 'cancelled');
repo.save({ id: 'A-2', status: 'shipped', total: 900 });
assert.throws(() => service.cancel('A-2', 'too late'));
service.cancel('A-1', 'again');
assert.equal(outbox.length, 2);
});Running it with node --test --test-reporter=spec tangled.test.js on Node 22:
✖ orders (0.80375ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
1 !== 2
at TestContext.<anonymous> (file://tangled.test.js:15:10)What broke? The report says a number was 1 instead of 2 on line 15. To find out what that means you have to replay three acts in your head. In this case the code is right and the test is wrong: the author assumed the second cancel of A-1 publishes another event, but the service treats it as a no-op. You only discover that after untangling the whole thing. Swap the situation around, where the code is wrong and the test is right, and you have the same detective work in front of you.
The tangled test also shares state between its checks. The shipped order A-2 sits in the same repository as A-1, and the final assertion counts events from every earlier act. If the shipped-order rule regressed and started emitting an event, the count would change for a reason nobody intended to test there.
Rewriting it as three tests
Split by behavior. Each test gets its own arrangement, one act and assertions about that act only. A small helper keeps the arrange blocks short without hiding the values that matter:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { OrderService, InMemoryOrders } from './orders.js';
function serviceWith(...orders) {
const repo = new InMemoryOrders();
for (const order of orders) repo.save(order);
const outbox = [];
return { service: new OrderService(repo, outbox), repo, outbox };
}
test('cancelling a pending order marks it cancelled and emits one event', () => {
// Arrange
const { service, repo, outbox } = serviceWith({ id: 'A-1', status: 'pending', total: 4200 });
// Act
service.cancel('A-1', 'customer request');
// Assert
assert.equal(repo.get('A-1').status, 'cancelled');
assert.deepEqual(outbox, [
{ type: 'OrderCancelled', orderId: 'A-1', reason: 'customer request' },
]);
});
test('a shipped order cannot be cancelled and nothing is emitted', () => {
const { service, repo, outbox } = serviceWith({ id: 'A-2', status: 'shipped', total: 900 });
assert.throws(() => service.cancel('A-2', 'too late'), /already shipped/);
assert.equal(repo.get('A-2').status, 'shipped');
assert.deepEqual(outbox, []);
});
test('cancelling twice emits the event only once', () => {
const { service, outbox } = serviceWith({ id: 'A-3', status: 'pending', total: 1500 });
service.cancel('A-3', 'customer request');
service.cancel('A-3', 'customer request');
assert.equal(outbox.length, 1);
});✔ cancelling a pending order marks it cancelled and emits one event (0.851375ms)
✔ a shipped order cannot be cancelled and nothing is emitted (0.265792ms)
✔ cancelling twice emits the event only once (0.070458ms)
ℹ tests 3
ℹ pass 3
ℹ fail 0The comments in the first test are training wheels. Once a team is used to the layout, blank lines do the same job, which is what the other two tests use.
A few decisions in that rewrite are worth being able to explain out loud.
The first test has two assertions, and that’s fine. “One assertion per test” is a common misreading of AAA. The rule is one behavior per test. Cancelling a pending order has two observable effects, the stored status and the published event, and checking both is how you pin down the behavior completely. deepEqual on the whole outbox is stronger than checking outbox.length === 1, because it also catches a wrong order id or a dropped reason.
The shipped-order test merges act and assert into assert.throws(...). That’s the usual shape for error cases, since the act is what produces the error. The pattern bends there without breaking: there’s still one operation, and the two lines after it check that the failed call left no trace. A test that only checks the throw would pass even if the service saved the cancellation before throwing.
The idempotency test calls cancel twice, which looks like two acts. It isn’t. The first call is arrangement: it puts the order into the cancelled state through the public API. The blank line shows where the arrangement ends. You could arrange the same state by saving { status: 'cancelled' } directly; going through the API is a choice that keeps the test honest if cancellation ever does more than flip a status field.
When the arrange block gets heavy
Real objects need more than three fields, and a 25-line arrange block buries the one line that matters. There are two common fixes, and one common trap.
Builders and helpers like serviceWith let you write only the values the behavior depends on and take defaults for the rest. The rule is that a default must never decide the outcome. If every order built by anOrder() defaults to pending, a test for the pending path should still say status: 'pending' explicitly, because a reader shouldn’t need to open the builder to understand why the test passes.
The trap is moving arrangement into beforeEach. It saves lines, but now the test body starts mid-story. Someone reading assert.equal(outbox.length, 1) has to scroll up, find the hook, and work out which of the six orders it creates is relevant. Shared hooks are fine for plumbing (opening a database connection, resetting a clock), and costly for the values that explain the behavior.
Interview practice
An interviewer shows you this test and asks what you’d change:
test('discounts', () => {
const cart = new Cart();
cart.add({ sku: 'BOOK', price: 2000 });
assert.equal(cart.total(), 2000);
cart.applyCode('SAVE10');
assert.equal(cart.total(), 1800);
cart.applyCode('SAVE10');
assert.equal(cart.total(), 1800);
});Answer
The test has three acts (total, applyCode, applyCode again) and checks after each, so it’s really three behaviors sharing state: a cart total with no codes, a code that takes 10% off, and a code that can’t be applied twice. If the second check fails, the third never runs, and you lose information about the repeat-code rule.
Split it into three tests with names that state the rule: “total is the sum of item prices”, “SAVE10 takes 10% off the total”, “applying the same code twice discounts only once”. Each arranges its own cart. In the third, the first applyCode belongs to the arrange block, separated from the act by a blank line. I’d also ask what “applying twice” should do: silently ignore it, or reject it with an error the UI can show? The current test only checks the total, so an implementation that throws on the second code would fail it, even if throwing is exactly what the product wants. Pinning that down is a product question, and the test should encode the answer.
Related reading
- Software Testing interview questions
- Software Testing MCQs
- Test Data Builders and Object Mothers, for arrange blocks that stay short
- Red, Green, Refactor
- Node.js test runner documentation