What Is a Unit? Arrange-Act-Assert
The overview called a unit test the confidence machine: a small, fast, deterministic check that some slice of your code does what you claim. That leaves one word doing an enormous amount of work — slice. What, exactly, is the thing a unit test tests? If the answer is “a function,” then a program with four hundred functions needs four hundred test files and most of them are pointless. If the answer is “a class,” then you cannot unit-test a plain function at all. Neither answer survives contact with real code.
This page fixes the definition, and then gives every test the same readable skeleton. Once you can name the unit precisely and pour every test into the same three-part mold — Arrange, Act, Assert — your suite stops being a pile of assertions and becomes a set of executable sentences that describe what the system does.
Two questions run underneath everything below, and it is worth holding them in mind:
- What am I actually testing? — the answer must be a behavior with a boundary, never “line 42 of
analyze.” - Would this test still pass after a refactor that changed no behavior? — if the honest answer is no, the test is bound to the wrong thing.
Get those two right and the rest of this part is detail.
A unit is a behavior, not a function
Section titled “A unit is a behavior, not a function”The most useful definition in practice is this: a unit is an observable behavior reachable through a stable boundary. Read that in three pieces.
- Observable — you can see the result from outside. A return value, a thrown error, a changed field you can read back, a call made to a collaborator. If nothing observable changes, there is no behavior to assert, and nothing to test.
- Behavior — a rule the code promises to obey. “Blank lines are skipped.” “A level filter keeps that level and everything more severe.” “Strict mode aborts on the first line it cannot parse.” Each of those is one promise, independent of how the code is arranged internally.
- Stable boundary — the seam you call through. It is the thing that stays put while the implementation churns underneath it.
analyze(lines, filter, strict),store.set(key, value),parse_line(raw)— each is an entry point whose shape you commit to, even as you refactor everything behind it.
Notice what this definition does not say: it does not say a unit is one function. logwise’s analyze() is a single boundary, but internally it loops, calls parse_line, folds a histogram, and filters a vector. That is a lot of functions. Yet a test for “counts levels and skips blanks” targets the behavior, and it does not care how many private helpers analyze splits into tomorrow. Bind your tests to behavior behind a boundary and refactors leave them green. Bind them to private functions and every refactor breaks a hundred tests that were never about behavior in the first place.
the boundary you test THROUGH the mess you DON'T name in tests ───────────────────────────── ──────────────────────────────── analyze(lines, filter, strict) ──► loop · trim · parse_line · fold · filter │ ▲ │ observable outputs │ implementation may change freely ▼ │ as long as the behavior holds Report { summary, matches } / LogErrorA boundary can be small (parse_line, one function) or wide (analyze, a subsystem). What makes it a unit boundary is not size — it is that you can drive it deterministically, in memory, in milliseconds, and read its result. store.set() on an in-memory key-value store is a unit boundary; store.set() that reaches across a real network to a running database is not — that is an integration test, and it belongs to a different chapter.
A few concrete boundaries, so the definition stops feeling abstract:
analyze(lines, filter, strict)— a subsystem boundary. One entry point, many internal steps, observable via aReportor aLogError.parse_line(raw)— a function boundary. One line in, aLogLineor a parse error out; nothing hidden.store.set(key, value)thenstore.get(key)— a stateful object boundary. The behavior is observed by reading back what you wrote.Filter::matches(line)— a predicate boundary. Pure input to purebool; the easiest kind of unit to test there is.
Every one of those is a unit, and not one of them is defined by “it is a single function.” They are defined by “you can drive it and observe the result.”
How do you find the boundary in code you did not write? Ask what the callers depend on. The public method a caller invokes, the value it gets back, the error it must handle — that trio is the contract, and the contract is the boundary. Everything the caller cannot observe (a private field, an internal helper, the order of a loop) is implementation, and implementation is precisely what a good test refuses to name. A rule of thumb: if renaming a private function forces you to edit a test, that test was reaching under the boundary and will punish you at every refactor.
Arrange-Act-Assert: the shape of every test
Section titled “Arrange-Act-Assert: the shape of every test”Give the unit a definition and you still need the test itself to have a shape. The one that has survived decades, across every language, is Arrange-Act-Assert (AAA), sometimes called Given-When-Then:
ARRANGE build the world the behavior needs — inputs, fixtures, the object under test ACT perform exactly one action on the boundary ASSERT check exactly one behavior actually happenedThe power is in the discipline: one Act, and assertions about one behavior. Arrange sets the stage, Act pulls the trigger once, Assert checks the consequence. When you read a test and can point to those three regions, you can understand it in seconds — and when it fails, the Act line tells you exactly what was being exercised.
Here is a real logwise test, annotated with its three phases:
#[test]fn level_filter_keeps_that_level_and_above() { // ARRANGE — a filter that keeps WARN and everything more severe let filter = Filter { min_level: Some(Level::Warn), ..Default::default() };
// ACT — run the analyzer once, over the shared sample let report = analyze_sample(&filter);
// ASSERT — one behavior: WARN + ERROR pass, nothing below WARN does assert_eq!(report.summary.matched, 2); assert!(report.matches.iter().all(|l| l.level >= Level::Warn));}The two assertions look like two checks, but they are two facets of one behavior: “a level filter keeps that level and above.” The count proves how many passed; the all(...) proves nothing below the threshold slipped through. Together they pin one promise. That is the rule — one test asserts one behavior — not “one test has one assertion.”
Each phase has a job, and confusing them is where readable tests go to die:
- Arrange builds, it does not assert. No
assertbelongs above the Act line. If you feel the urge to check something before acting, that check is really a second test about the setup — write it separately. - Act does one thing, it does not set up. If the Act line is doing five things, four of them are Arrange in disguise, and the test’s name can no longer describe a single behavior.
- Assert checks the consequence, it does not act again. An assertion that mutates state (calls
set, advances a clock) has smuggled a second Act into the Assert phase, and the test now proves two things badly instead of one thing well.
You will also see AAA written as Given-When-Then — Given the world, When an action, Then a consequence. It is the same skeleton in prose clothing; the behavior-driven-development community favors it because “Given/When/Then” reads aloud as a specification. Whichever labels you use, the three regions and the single Act are identical.
How each logwise test maps onto AAA
Section titled “How each logwise test maps onto AAA”Every test in logwise’s analyze.rs follows the same skeleton, and the shared analyze_sample helper is the Arrange step factored out:
test Arrange Act Assert ─────────────────────────────────────────── ─────────────────── ───────────────── ────────────────────── counts_levels_and_skips_blanks_and_malformed default filter analyze_sample totals + per-level counts level_filter_keeps_that_level_and_above min_level = Warn analyze_sample matched == 2, all >= Warn pattern_filter_matches_substrings pattern = "request" analyze_sample matched == 1, message strict_mode_fails_on_the_first_bad_line strict = true analyze(..,true) Err at line_no == 6Four rows, one shape. A reader scanning this file does not have to re-learn how each test is built — the Arrange differs, the skeleton does not.
Under the hood — why the Act phase must be exactly one action
Section titled “Under the hood — why the Act phase must be exactly one action”The “single Act” rule is not cosmetic; it protects the meaning of the test. Suppose you Act twice in one test — call analyze once with a level filter and again with a pattern filter — and then assert about both. Now ask: if the test fails, which Act broke? You cannot tell from the report, because the test’s name can only describe one thing, and its failure line points at an assertion, not at which of the two runs produced the bad value. You have re-created the mega-test problem inside a single test.
A subtler trap is state leaking between two Acts. When the object under test is mutable — logwise’s analyze is not, but a store is — a first Act changes the object, and the second Act now runs against a world the Arrange never described. Consider a key-value store:
WRONG — two Acts, hidden dependency RIGHT — one Act per test ─────────────────────────────────── ──────────────────────── store.set("k", "a"); // Act 1 #[test] set_then_get_returns_the_value store.set("k", "b"); // Act 2 #[test] overwriting_a_key_keeps_the_last_write assert_eq!(store.get("k"), Some("b")); // each: one Arrange, one Act, one Assert // is this testing set? or overwrite? // the name matches the single actionThe left test is two behaviors — “set stores a value” and “a second set overwrites the first” — wearing the disguise of one. Split into two tests named for their single Act, and each becomes an executable sentence whose failure names the exact broken promise. Keep the Act singular and the whole AAA discipline holds together; let it multiply and the readability, the naming, and the failure-localization all quietly collapse.
One test, one behavior — and why they split
Section titled “One test, one behavior — and why they split”The requirement to split tests by behavior is not stylistic tidiness; it is about what a red bar tells you. Consider two of logwise’s real tests:
counts_levels_and_skips_blanks_and_malformed— proves the accounting: blank lines don’t count, malformed lines are tallied separately, and each level’s tally is right.level_filter_keeps_that_level_and_above— proves the filtering: severity thresholds include the threshold and everything above it.
These are separate tests for separate reasons. Counting and filtering are two different promises that fail for two different causes. If you fused them into one giant test_analyze(), a failure would tell you only “something in analyze is wrong” — you would still have to open the test and read which assertion tripped. Kept apart, the name of the failing test is the diagnosis: the counting broke, or the filtering broke, but never both-or-neither. A well-split suite turns a red bar into a sentence.
There is a second, quieter payoff. When counting and filtering live in separate tests, you can change the filtering logic and watch exactly one test go red. Fused, both go red together and you have lost the signal that told you which behavior you disturbed.
Name tests as sentences about behavior
Section titled “Name tests as sentences about behavior”If a test pins one behavior, its name should state that behavior. Compare:
named after the method named after the behavior ────────────────────── ──────────────────────── test_analyze counts_levels_and_skips_blanks_and_malformed test_analyze_2 level_filter_keeps_that_level_and_above test_filter strict_mode_fails_on_the_first_bad_line testStrict missing_file_reports_an_errorThe left column names the code. The right column names the promise. Read the right column top to bottom and you get a specification of logwise in plain English — no source required. This is why the house names read like strict_mode_fails_on_the_first_bad_line: subject, verb, and the salient condition, phrased so that when it fails the report literally prints the broken promise. A good test name is an executable sentence; a bad one is a serial number.
A quick test for a good name: could a person who has never seen the code guess what breaks if this test goes red? strict_mode_fails_on_the_first_bad_line — yes. test_analyze_2 — no.
There is a structural bonus hiding in this convention. When a name is forced to be a single sentence, it resists the mega-test: you cannot honestly name a test that does five unrelated things, because there is no one sentence for it. The naming rule and the one-behavior rule enforce each other. If you find yourself reaching for “and” in a test name — parses_and_filters_and_counts — that “and” is the suite telling you to split. The best names in logwise sometimes do carry an “and” (counts_levels_and_skips_blanks_and_malformed), but only because those clauses are facets of one behavior — the accounting — not three independent promises. The judgment call is whether the “and” joins two views of one rule or two separate rules; the former is fine, the latter is a split waiting to happen.
Sociable vs solitary: how much neighborhood may a unit touch?
Section titled “Sociable vs solitary: how much neighborhood may a unit touch?”One question decides how you build the Arrange step: when you test analyze(), may its real collaborators — parse_line, Filter::matches, the fold — run too, or must you replace them with stand-ins? There are two schools, and both are legitimate.
- Solitary (isolationist) — the unit under test runs alone. Every collaborator is replaced by a test double so that only the code inside the boundary can cause a failure. A solitary test of
analyzewould feed it a fake parser and assert only onanalyze’s own logic. - Sociable — the unit under test is exercised with its real neighbors, as long as they are fast and deterministic. logwise’s
analyzetests are sociable: they call the realparse_lineand the realFilter, because those are pure, in-memory, and instant. The “unit” here is the behavior of the whole small neighborhood reachable throughanalyze.
SOLITARY SOCIABLE ──────── ──────── analyze ──► [fake parse_line] analyze ──► real parse_line └─► [fake filter] └─► real filter fails only if analyze is wrong fails if anything in the cluster is wrong precise blame, more setup realistic, less setup, wider blameThe trade-off is precision versus realism. Solitary tests point a sharp finger — if one fails, the fault is inside the boundary — but they cost more setup and can pass while the real collaboration is broken. Sociable tests are cheaper and catch integration mistakes between the neighbors, but a failure could live anywhere in the cluster. The deciding rule: go sociable when the neighbors are pure, fast, and deterministic (as logwise’s are); reach for solitary doubles the moment a neighbor is slow, non-deterministic, or has side effects — a clock, a network, a disk. That boundary is exactly what the pure vs impure and isolating dependencies pages are about.
Be aware that the two schools have a real cost when they collide inside one suite. A solitary purist will double everything and end up asserting that analyze “called the parser,” which is a claim about internal structure, not behavior — such tests go red on every refactor that reorganizes the collaboration, even when the observable output never changed. A sociable purist, meanwhile, can build a test whose Arrange quietly depends on a real clock or a real filesystem and then wonder why it flakes at midnight or on a full disk. Neither school is wrong; the discipline is to choose consciously per collaborator rather than by habit. logwise chose sociable for parse_line and Filter because they are pure; it would choose solitary the instant analyze had to read the file itself instead of receiving lines as an already-materialized iterator. Notice that analyze’s signature — taking lines: impl Iterator<Item = &str> rather than a path — is a deliberate design choice that keeps the boundary sociable-friendly: the impurity of reading a file lives in the caller, and the testable behavior stays pure. Designing for testability and defining the unit are the same act performed from two directions.
You now have the two tools this part is built on: a precise definition of the unit — an observable behavior behind a stable boundary — and the shape every test takes, Arrange-Act-Assert, one Act and one behavior per test, named as a sentence. The next page, The FIRST Properties of a Good Test, takes these well-shaped tests and asks what makes a whole suite of them trustworthy: fast, isolated, repeatable, self-validating, and timely. And once you know what a good unit test looks like, tests as living documentation shows why the sentence-names you wrote here are the most honest specification your code will ever have.
The architect’s lens
Section titled “The architect’s lens”- Why does it exist? Because “test the code” is uselessly vague. Defining a unit as an observable behavior behind a stable boundary, and pouring each test into Arrange-Act-Assert, turns a vague instinct into a repeatable, readable practice.
- What problem does it solve? Two: tests bound to private functions that shatter on every refactor, and tangled mega-tests whose failures name nothing. Behavior-bound tests survive refactors; AAA-shaped, single-behavior tests localize failures to a sentence.
- What are the trade-offs? Solitary tests give precise blame at the cost of setup and realism; sociable tests are cheap and realistic but blame a whole neighborhood. One-behavior-per-test means more tests to write and name.
- When should I avoid it? Never as a whole, but don’t force solitary isolation onto pure, fast collaborators — that is setup with no payoff — and don’t split a genuinely atomic behavior into artificially tiny tests just to hit “one assert.”
- What breaks if I remove it? Without a boundary, tests couple to internals and rot on contact with refactors. Without AAA and one-behavior tests, a red bar tells you something is wrong but not what — you lose the whole diagnostic value the overview promised.
Check your understanding
Section titled “Check your understanding”- Why is “a unit is one function” a poor working definition? Give an example from logwise where the unit spans several functions.
- Name the three phases of AAA and state the single rule that keeps a test readable. Which phase should happen exactly once?
counts_levels_and_skips_blanks_and_malformedandlevel_filter_keeps_that_level_and_abovecould have been one test. Give two concrete reasons the house keeps them separate.- Rewrite the test name
test_analyze_2as a behavior sentence for the case “an empty filter matches every parsed line.” What makes your version better? - logwise’s
analyzetests call the realparse_line. Is that sociable or solitary? Under what condition should you switch to the other style?
Show answers
- Because a single behavior often runs through many functions, and a single function may expose no behavior worth asserting.
analyze()is one boundary but internally loops, trims blanks, callsparse_line, folds a histogram, and filters a vector — the unit is the behavior reachable throughanalyze, not any one of those helpers. - Arrange (build the world), Act (perform one action on the boundary), Assert (check one behavior). The rule: one Act and assertions about one behavior. Act should happen exactly once.
- They pin two independent promises (accounting vs filtering) that fail for different causes, so keeping them apart makes the failing test name the broken behavior; and it lets you change filtering and see exactly one test go red, preserving the signal about which behavior you disturbed.
- For example
empty_filter_matches_every_parsed_line. It states the promise as a sentence, so a reader who has never seen the code knows what breaks when it goes red — a serial number liketest_analyze_2conveys nothing. - Sociable — the real, pure, fast, deterministic collaborators run alongside the unit. Switch to solitary (replace a neighbor with a test double) the moment a collaborator becomes slow, non-deterministic, or side-effecting, such as a clock, network, or disk.