Unit Tests: Fast Feedback on Focused Logic
📑 On this page
- A concrete example: discount boundaries
- Arrange, act, assert
- One behavioral idea per test
- Good test names
- Pure functions are easy to test
- Controlling time
- Controlling randomness
- Dependencies and test doubles
- Test outputs, not private steps
- State-based and interaction-based tests
- Table-driven tests
- Property-based testing
- Error cases
- Avoid excessive setup
- Unit tests and refactoring
- What unit tests miss
- Speed and feedback
- A practical unit-test checklist
- Knowledge check
- The one idea to remember
A unit test checks a focused piece of behavior with controlled inputs and collaborators.
The "unit" might be a function, class, module, or small component. Its useful boundary is determined by behavior and architecture, not one universal size.
Unit tests give fast, precise feedback about local rules, especially boundaries and edge cases.
They cannot prove that databases, networks, frameworks, and components work together correctly.
A concrete example: discount boundaries
A store gives a ten-percent discount when an eligible subtotal is at least 100 currency units.
Useful unit cases include:
99.99receives no discount,100.00receives the discount,- excluded products do not count,
- an empty cart produces zero,
- and rounding follows the currency rule.
These focused examples reveal more than one broad "normal cart" test.
Arrange, act, assert
Many unit tests follow three phases:
- Arrange the inputs and controlled dependencies.
- Act by invoking the behavior.
- Assert the observable result.
The pattern makes the reason for a failure easier to scan. It is a guide, not a requirement to add ceremonial comments to every obvious test.
One behavioral idea per test
A focused test should make its failed expectation clear.
If one test checks ten unrelated rules, the first assertion may stop execution and hide the others. Smaller tests also produce names that describe exactly which condition failed.
Several related assertions about one result can still belong together.
Good test names
A useful name describes behavior and context:
applies discount when eligible subtotal equals thresholdNames such as testDiscount1 or works force readers to inspect setup before understanding the contract.
The name becomes part of failure output and living documentation.
Pure functions are easy to test
A pure function returns a result based only on inputs and does not alter outside state.
It is naturally deterministic:
calculateRenewalDate(startDate, plan)Separating pure business rules from database access, clocks, and network calls makes the core logic easier to test exhaustively.
Controlling time
Code that reads the real clock can produce tests that fail depending on the moment they run.
Instead, pass a clock or current instant into the unit:
isTokenExpired(token, now)The test can then cover exact boundaries such as one millisecond before and at expiration.
Controlling randomness
Random identifiers, shuffling, and sampling make results unpredictable unless controlled.
Options include:
- injecting a seeded random source,
- asserting properties rather than one exact sequence,
- or separating random selection from deterministic decision logic.
Tests should not occasionally fail merely because a rare random value appeared.
Dependencies and test doubles
A unit may depend on an email sender, repository, or payment client.
A test double can provide a controlled response so the test focuses on local behavior. For example, a payment fake returns a decline and the unit should avoid creating an order.
The double should model the boundary needed for the test, not duplicate the entire dependency.
Test outputs, not private steps
Brittle unit tests assert internal implementation:
- a private method was called,
- helper functions ran in one order,
- or an internal collection has a specific shape.
A refactor then breaks tests even when public behavior remains correct.
Prefer observable results: returned values, emitted events, persisted requests, or visible component output.
State-based and interaction-based tests
A state-based test checks the resulting value or state.
An interaction-based test checks communication with a collaborator, such as whether an email request was sent.
Interactions are appropriate when communication is the behavior, but overusing them couples tests to internal choreography.
Table-driven tests
Many rules share one structure with different inputs and expected outputs.
A table can express cases compactly:
| Subtotal | Eligible | Expected discount |
|---|---|---|
| 99.99 | yes | 0 |
| 100.00 | yes | 10 |
| 100.00 | no | 0 |
Each row should still report clearly when it fails.
Property-based testing
Example tests check selected values. Property-based tests generate many values and verify a general property.
For a sorting function, properties might include:
- output length equals input length,
- output is ordered,
- and output contains the same elements.
Generated testing can discover cases humans did not anticipate, while carefully chosen examples remain useful for important named boundaries.
Error cases
Unit tests should cover invalid input and dependency failure where the unit owns the response.
Check:
- error type or result,
- useful context,
- no partial state change,
- and correct cleanup.
An assertion that merely says "some error happened" may miss the wrong failure.
Avoid excessive setup
If every unit test needs dozens of irrelevant objects, the design or fixture strategy may be too broad.
Builders and focused factories can create valid defaults while each test overrides only meaningful fields. Hidden defaults should remain understandable so readers know why the case behaves as expected.
Test readability matters more than clever fixture reuse.
Unit tests and refactoring
A good unit suite supports internal redesign while preserving behavior.
If tests break whenever methods are renamed or logic moves between helpers, they are acting as implementation locks. Refactor-resistant tests define stable contracts at useful boundaries.
Some low-level algorithm tests legitimately target a small implementation boundary; judgment still applies.
What unit tests miss
Unit tests can pass while:
- a database query uses the wrong column type,
- JSON fields do not match an API,
- dependency injection is misconfigured,
- a queue cannot deserialize a message,
- or the browser renders incorrectly.
Integration and broader tests cover these connections.
Speed and feedback
Unit tests should generally run quickly enough to execute during development.
Fast feedback encourages frequent use. Slow tests may belong at a different level or need expensive setup removed.
Speed is valuable, but a fast test with no meaningful assertion creates no useful evidence.
A practical unit-test checklist
Ask:
- What behavior does this test protect?
- Is the setup limited to relevant context?
- Does the assertion observe a real contract?
- Are boundaries and failures represented?
- Is nondeterminism controlled?
- Would an internal refactor preserve the test?
- Which integration risk remains uncovered?
The last question prevents unit confidence from being mistaken for system confidence.
Knowledge check
- Why is "unit" not always equal to one function?
- How does injecting a clock improve expiration tests?
- When is interaction-based testing appropriate?
- Why can testing private method calls make refactoring difficult?
- What important failures can unit tests miss?
The one idea to remember
Unit tests provide fast, precise evidence about focused behavior. Control nondeterminism, emphasize boundaries, assert stable outcomes instead of private steps, and rely on broader tests for real connections.