← All posts
6 min read

Unit Tests: Fast Feedback on Focused Logic

#technology#unit-tests#software-testing#quality
📑 On this page

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.99 receives no discount,
  • 100.00 receives 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:

  1. Arrange the inputs and controlled dependencies.
  2. Act by invoking the behavior.
  3. 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 threshold

Names 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:

SubtotalEligibleExpected discount
99.99yes0
100.00yes10
100.00no0

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:

  1. What behavior does this test protect?
  2. Is the setup limited to relevant context?
  3. Does the assertion observe a real contract?
  4. Are boundaries and failures represented?
  5. Is nondeterminism controlled?
  6. Would an internal refactor preserve the test?
  7. Which integration risk remains uncovered?

The last question prevents unit confidence from being mistaken for system confidence.

Knowledge check

  1. Why is "unit" not always equal to one function?
  2. How does injecting a clock improve expiration tests?
  3. When is interaction-based testing appropriate?
  4. Why can testing private method calls make refactoring difficult?
  5. 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.