← Concepts & practices
Concept Testing, debugging, and measurement

Invariants and example tests

Name what must stay true, then challenge its edges.

A seat reservation starts with a reasonable check: accept a request when the new booking fits inside the venue’s capacity. Then an input the happy path never named reserves negative seats and produces an impossible state. The fix is not “add more assertions” in the abstract. State the invariant, choose examples at its boundaries, and make rejected commands leave the state you can still trust.

TypeScriptGo One venue · one state rule · four edges.

01 / The idea

A passing example is not the whole contract.

The venue begins with capacity 4 and 2 seats booked. reserve(2) should succeed; reserve(3) should not. Those are examples: concrete stories with names a reviewer can repeat.

The broader rule is 0 ≤ booked ≤ capacity. It must hold after every accepted command and after every rejected command. That is the invariant. It catches a negative quantity, a release that underflows the counter, and the next command nobody has written yet.

Write the rule first; use examples to make its edges impossible to misunderstand.

validbooked 2capacity 4
→
invalidbooked −1negative seats cannot be real
A direct capacity check can accept an input that crosses the state rule it never named.
Read the starting checkTypeScript · fair first answer, incomplete contract
seats.ts · starting check
// This first version works for positive quantities that fit. Its contract is incomplete.
export function directReserve(state: Venue, quantity: number): Venue | null {
	if (state.booked + quantity > state.capacity) return null;
	return { ...state, booked: state.booked + quantity };
}

export function directRelease(state: Venue, quantity: number): Venue {
	return { ...state, booked: state.booked - quantity };
}

The starting function is useful for its first positive case. It becomes dangerous when the quantity itself has no contract. A test that only repeats the happy path can pass while booked becomes negative.

This is why “the tests pass” needs a second question: which claim did they check? A test can be accurate and still narrow. The job is to make the important claim broad enough, then choose small examples that show where it is most likely to fail.

02 / See the shape

Start with the claim, then name the examples.

The smallest honest mechanism is a state transition plus a predicate that says whether the state is valid. The basic form returns named errors and the original state on rejection; the practical form adds the transition check every test can reuse. At the call site, examples exercise the last available seat, the first over-capacity request, invalid input, and an over-release.

The contract names the invariant, returns named errors, and hands back the original state on rejection.

TypeScriptReading
seats.ts
export function invariantHolds(state: Venue): boolean {
	return (
		Number.isInteger(state.capacity) &&
		state.capacity >= 0 &&
		Number.isInteger(state.booked) &&
		state.booked >= 0 &&
		state.booked <= state.capacity
	);
}

export function reserve(state: Venue, quantity: number): SeatResult {
	if (!Number.isInteger(quantity) || quantity <= 0) {
		return { ok: false, state, error: 'invalid-quantity' };
	}
	if (state.booked + quantity > state.capacity) {
		return { ok: false, state, error: 'capacity-exceeded' };
	}
	const next = { ...state, booked: state.booked + quantity };
	return { ok: true, state: next };
}

export function release(state: Venue, quantity: number): SeatResult {
	if (!Number.isInteger(quantity) || quantity <= 0) {
		return { ok: false, state, error: 'invalid-quantity' };
	}
	if (quantity > state.booked) {
		return { ok: false, state, error: 'release-exceeds-booked' };
	}
	return { ok: true, state: { ...state, booked: state.booked - quantity } };
}
GoAlongside
seats.go
func InvariantHolds(state Venue) bool {
	return state.Capacity >= 0 && state.Booked >= 0 && state.Booked <= state.Capacity
}

func Reserve(state Venue, quantity int) Result {
	if quantity <= 0 {
		return Result{State: state, Error: ErrInvalidQuantity.Error()}
	}
	if state.Booked+quantity > state.Capacity {
		return Result{State: state, Error: ErrCapacityExceeded.Error()}
	}
	state.Booked += quantity
	return Result{OK: true, State: state}
}

func Release(state Venue, quantity int) Result {
	if quantity <= 0 {
		return Result{State: state, Error: ErrInvalidQuantity.Error()}
	}
	if quantity > state.Booked {
		return Result{State: state, Error: ErrReleaseExceedsBooked.Error()}
	}
	state.Booked -= quantity
	return Result{OK: true, State: state}
}
Reading the TypeScriptA predicate, a result, and a copy

invariantHolds is deliberately small: it says what every valid Venue must satisfy. SeatResult carries either a new state or the old state with a named error, so a caller can test atomic rejection without reaching into private fields.

Reading the GoValue copies and explicit errors
seats.go · invariant contract
func InvariantHolds(state Venue) bool {
	return state.Capacity >= 0 && state.Booked >= 0 && state.Booked <= state.Capacity
}

func Reserve(state Venue, quantity int) Result {
	if quantity <= 0 {
		return Result{State: state, Error: ErrInvalidQuantity.Error()}
	}
	if state.Booked+quantity > state.Capacity {
		return Result{State: state, Error: ErrCapacityExceeded.Error()}
	}
	state.Booked += quantity
	return Result{OK: true, State: state}
}

func Release(state Venue, quantity int) Result {
	if quantity <= 0 {
		return Result{State: state, Error: ErrInvalidQuantity.Error()}
	}
	if quantity > state.Booked {
		return Result{State: state, Error: ErrReleaseExceedsBooked.Error()}
	}
	state.Booked -= quantity
	return Result{OK: true, State: state}
}

Go updates a value copy and returns it with an error string. The representation differs, but the cases are the same: exact capacity is accepted, invalid commands are rejected, and the state rule remains visible in a named function.

03 / Follow the state

Watch a tidy check cross an unnamed boundary.

Guess what happens before each operation. The frames come from the same seat functions shown above: first the starting check, then the invariant and atomic rejection rule.

Invariants

A passing example is not the whole contract.

starting check reserve(2)
before2 / 4booked / capacity
→
after… / 4checking result

Invariantpending

Rejected statepending

starting check: reserve(2); invariant holds.

01/ 04
Reserve the exact remaining capacity

The happy path works.

With 2 of 4 seats booked, the original capacity check accepts exactly 2 more.

Reduced motion: choose a scene to see its completed state.

Read this scene

With 2 of 4 seats booked, the original capacity check accepts exactly 2 more.

starting check. Starting state: 2 of 4 booked. reserve(2) is accepted; state is 4 booked. Invariant holds. Rejected state unchanged: true.

Watch restarts when you return. Step through keeps your selected step. Try it starts a fresh test model.

What the invariant buys you

Now put names on what you watched. Each benefit points at a claim or example on this page.

State you can trust
0 ≤ booked ≤ capacity gives every reader the same boundary instead of making each method invent one.
Readable edges
reserve(3) from 2 booked explains exactly where capacity stops, while reserve(-3) explains the invalid-input case.
Atomic rejection
The rejected result carries state: before, so callers do not have to guess whether a partial write happened.
Refactor freedom
Tests assert the state and outcome that callers need, not whether an internal helper used a particular branch or arithmetic expression.

Those benefits have costs. Section 08 names the tests that become noisy or misleading when the claim is not chosen carefully.

04 / Try a decision

Choose the evidence before you choose the assertion.

A tidy-looking test can still leave a state rule unprotected. Pick the plan that gives the venue owner a general claim and readable boundary examples.

Which test plan best protects the seat-reservation contract?

The venue must keep 0 ≤ booked ≤ capacity, and rejected commands must not change state.

05 / Give it a real job

Let the domain own the rule and the test name the risk.

The reservation module creates or receives a Venue, applies commands, and returns a result that callers can act on. The tests do not need a database to check capacity, invalid quantities, or no-partial-write behavior. An integration test can later verify storage, but it should not be the only place the invariant is exercised.

Keep the examples small and intentional: zero, negative, the exact boundary, one beyond it, and the state after rejection. When a new command arrives, ask which invariant it can threaten and add the smallest example that makes that threat visible.

Reservation module

Owns the rule

invariantHolds and the atomic rejection live beside reserve and release, so every new command meets them.

Unit tests

Own the edges

Exact capacity, one over, a negative quantity, an over-release, each with the state it leaves behind.

Integration test

Owns storage

Checks that the saved venue matches what the module returned. It doesn’t re-derive the seat rule.

Build UIs?Every “seats left” label trusts an invariant, and a loose API response makes you own it.

Where it already is in your components

A component that prints capacity - booked as “seats left” is leaning on 0 ≤ booked ≤ capacity without saying so. In the textbook version it receives a SeatView from the reservation module, whose tests already own that rule. The component’s own tests only need to check what it renders for the view it gets.

When you have to own it

Now the component reads a loose public venue where either field may be missing. Somebody has to decide what a missing booked or a count above capacity means, and the wild version decides quietly: ?? 0 and Math.max(0, …) turn an overbooked venue into “0 seats left”. The state rule is broken and the page looks fine.

Owning it means writing the rule down where the data arrives: parse the response into a SeatView or an error, and give that parser the same boundary examples as reserve: exactly full, one over, a missing field.

The UI receives a tested seat projection and renders the display without rechecking domain rules.

ReactAlready in your code
textbook.tsx · tested projection
type SeatView = { capacity: number; booked: number; label: string };
type ReservationApp = { getSeatView(): SeatView };

export function SeatSummary({ app }: { app: ReservationApp }) {
	const venue = app.getSeatView();
	return (
		<p aria-label="seats remaining">
			{venue.label}: {venue.capacity - venue.booked} seats left
		</p>
	);
}

06 / Recognize it elsewhere

The same testing move appears in ordinary collection code.

When you see a collection, ask what must remain true after each operation. The vocabulary changes; the move does not.

Familiar code with a claim behind it
CodeInvariant or exampleBoundary worth naming
Queue enqueue / dequeueSize is never negative and never exceeds capacity.Dequeue empty; enqueue the item that fills the queue exactly.
Money transferDebit and credit preserve the total across an accepted transfer.Insufficient funds; zero amount; transfer fails without one-sided movement.
Parser resultA successful parsed value satisfies its type-level or domain assumptions.Empty input, malformed token, and trailing data are handled deliberately.
UI form stateDisplayed validity agrees with the submitted value.Submit while invalid; failed save keeps the unsaved value visible.

07 / Already in your toolbox

Libraries give you assertion tools; you still choose the claim.

Array.prototype.every is a handy way to check a predicate across a collection, expect makes an example’s expected result readable, and Go’s testing.T gives a test a place to report the evidence. None of them decides which states matter.

JavaScript

every

Useful for “all items satisfy this predicate.” It is an assertion mechanism, not an invariant by itself.

Vitest

expect

Useful for a named example such as exact capacity or a specific error. Keep the message close to the boundary.

Go testing

t.Fatalf

Useful when a precondition makes the rest of the example meaningless. It should report the state that violated the claim.

08 / The parts to watch

Good test words can still protect the wrong thing.

Only the happy pathThe first example works

Keep the happy path; add the exact boundary and the first invalid input beside it. The suite should tell the next reader where acceptance stops.

Assertions coupled to stepsThe code changes, the contract does not

Prefer state, outcome, error kind, and observable interaction. Do not freeze a private helper sequence unless another component depends on that sequence.

A broad invariant with no examplesTrue but hard to read

An invariant like “the state is valid” is valuable only when it names the variables. Examples make the boundary concrete and diagnose the failure quickly.

Missing unchanged-state checksRejection can still mutate

When a command fails, compare the returned state with the input state. A correct error with a partial write is still a broken transition.

09 / Make the call

Use the smallest test that protects the widest useful claim.

Which evidence belongs in the suite?
SituationStarting design wins when…Invariant + examples wins when…
A pure calculation has one stable outputthe input domain is genuinely closed and the example is the readable contract.the output must satisfy a relation across many inputs or edge cases.
A short-lived script owns the whole statethe cost of another result type or predicate is larger than the risk.the state crosses a boundary or will gain more commands.
A stateful domain operation rejects inputits callers are trusted internal code that never sends a zero, negative, or oversized quantity, so one happy-path example documents everything that can happen.the rule and unchanged-state behavior need to hold after every command.

Reach for examples when a named scenario is the behavior you want a reader to understand.

Keep an invariant when the same rule must survive many states, commands, or future examples.

Keep this questionAsk it when a test passes too easily.

What must stay true, which input first threatens it, and what state should remain when the operation refuses?

10 / Take the idea with you

Explain the seat rule without saying “invariant.”

“Booked never goes below zero or above capacity. Overbooking returns an error and leaves the venue unchanged.” A reviewer knows exactly what to inspect from that. When they want the words, they are invariant, boundary example, and atomic rejection.

Before moving on, jot down why reserve(-3) slipped past the starting check, which four examples sit at the edges of the seat rule, and one rule in your own code that every test assumes and none of them states. A cart quantity or a pagination offset counts.

Connections to follow nextRelated lessons

Take the seat module into your editor. Add a transfer command that moves seats between two venues, then write the examples that prove a rejected transfer leaves both venues unchanged.

Back to Concepts & practices →