Skip to content

Unit Testing

Core Idea

Examples and diagrams in this page follow the shared Hypothetical Scenario.

Unit testing is the fastest feedback mechanism in a testing strategy. A unit test validates one small behavior in isolation, with deterministic inputs and deterministic outcomes. When written well, unit tests reduce regression risk, improve confidence during refactoring, and make development loops faster.

A practical definition of high-quality unit tests:

  • reliable: failures indicate a real defect
  • fast: each test runs in milliseconds
  • isolated: no dependency on network, disk, clocks, or external process state

In the scenario platform, unit tests should protect pure logic such as eligibility rules, normalization routines, scoring primitives, and policy checks. They should not depend on live APIs, databases, or filesystem fixtures.

Conceptual Overview

Why Unit Testing Matters

Unit testing has clear tradeoffs. It increases implementation effort, but reduces defect cost and debugging cost later. The return is usually high when tests remain fast and trustworthy.

Strong unit testing provides:

  • early detection of logic defects
  • safer refactoring with behavior checks
  • faster local validation before integration tests
  • executable documentation for expected behavior

Design Blocks

Unit testing typically includes three components:

  1. System under test (SUT): the smallest behavior unit being validated.
  2. Test framework/runner: the harness that executes tests and reports failures.
  3. Test code: small behavior-focused scenarios with clear arrange, act, and assert phases.

The SUT should stay narrow. When a unit test needs broad setup, the production unit is often too large or too coupled.

Techniques for Testable Design

Abstraction

Abstraction replaces concrete infrastructure details with stable behavioral contracts. In unit tests, this allows replacement of hard dependencies with fakes or simulators. The goal is not abstraction for its own sake; the goal is controllable behavior boundaries.

Dependency Injection

Dependency injection removes hard construction coupling from the SUT. Dependencies are supplied from outside, making isolation practical. Overuse can also add complexity, so dependency surfaces should stay intentional and cohesive.

Test-Driven Development

Test-driven development (TDD) is a design workflow: write one failing behavior test, implement the minimum change, then refactor safely. Used well, TDD tends to produce smaller units with clearer contracts.

Best Practices

Arrange / Act / Assert

A readable unit test structure:

  • Arrange: setup inputs, test doubles, and state
  • Act: execute one behavior in the SUT
  • Assert: verify one expected outcome

This structure improves readability and failure diagnosis.

Keep Tests Small

Unit tests should remain short and focused. Prefer one assert per test function. When a test requires many asserts, it often signals one of two issues:

  • the test is validating too many behaviors at once
  • the system under test is doing too much and may violate single-responsibility boundaries

Multiple asserts are problematic because they reduce failure clarity. A single failed assertion can hide later assertions, and diagnostics become slower because reviewers must infer which behavior actually broke. They also encourage larger setups, which makes tests harder to read and maintain. If you feel pressure to assert many outcomes, split the test by behavior or refactor the production code into smaller units.

Naming Convention

Use descriptive names such as UnitName_StateUnderTest_ExpectedResult. A good name communicates intent without opening the test body. This is especially valuable in CI failure logs.

Folder Structure and Test Grouping

Keep test files organized around units, not around arbitrary folders. Two reliable structures are common:

  1. Co-located tests near production code for fast navigation.
  2. Dedicated test root mirroring production structure for clear separation.

Examples:

  • Python: src/recommendation/scoring.py with tests/recommendation/test_scoring.py
  • TypeScript: src/scoring/score.ts with src/scoring/score.test.ts or tests/scoring/score.test.ts
  • Rust: src/scoring.rs with inline mod tests plus integration tests in tests/scoring_tests.rs

Group tests that target the same unit into a single class, module, or suite:

  • class-based frameworks: one test class per unit (ScoreCalculatorTests)
  • JavaScript/TypeScript: one describe("ScoreCalculator", ...) block per unit
  • Rust: one module per unit under #[cfg(test)] mod tests

Avoid large "miscellaneous" test files that mix many unrelated units. That pattern usually creates hidden coupling and brittle fixtures.

SOLID-Aligned Unit Testing Practices

Unit tests are strongest when production design follows SOLID:

  • Single Responsibility Principle: one unit, one reason to change, one behavior claim per test
  • Open/Closed Principle: extend behavior through abstractions and protect with contract-style tests
  • Liskov Substitution Principle: run shared behavior tests for all implementations of the same abstraction
  • Interface Segregation Principle: smaller interfaces reduce mocking complexity and fixture size
  • Dependency Inversion Principle: inject dependencies so units can be isolated with deterministic test doubles

When a test is hard to write, treat that as design feedback. Difficult tests often reveal high coupling or unclear responsibilities in the code.

Anti-Patterns to Avoid

  • sleeps and timing delays to make tests pass
  • reading expected values from disk
  • calling third-party APIs from unit tests
  • large test helper layers that hide behavior intent

These patterns usually break one or more unit-test properties: reliability, speed, or isolation.

Tools and Frameworks

Framework choice should follow project constraints and ecosystem fit. Open-source examples include:

  • pytest, unittest (Python)
  • cargo test with Rust built-in test harness, rstest, proptest, cargo-nextest (Rust)
  • JUnit (Java)
  • Jest, Vitest, Mocha + Chai (JavaScript/TypeScript)
  • Go built-in testing
  • xUnit/NUnit (C#)

For faster loops, watch-based runners and open-source tooling such as watchexec, entr, or framework-native watch modes can keep feedback immediate.

Unit Tests and Other Test Types

Unit tests are necessary but not sufficient. They cannot prove integration behavior with real dependencies. That is why they must be complemented by Smoke Testing and Integration and Functional Testing.

Computing History

Unit testing practices matured with the xUnit family of frameworks in the late 1990s and early 2000s. Test-first and TDD workflows then pushed teams toward smaller, behavior-oriented units and stronger feedback loops. Over time, mature teams learned that unit tests are most effective when combined with higher-level integration and functional checks.

Sources: Beck (2002) and Meszaros (2007)

Quote

"Unit testing is a fundamental tool in every developer's toolbox."

Source: Engineering Fundamentals Playbook, Unit Testing

Practice Checklist

  • Keep unit tests deterministic and isolated from external systems.
  • Target one behavior claim per test whenever practical.
  • Prefer a single assert per test function; split tests when multiple assertions indicate mixed behavior claims.
  • Treat repeated multi-assert tests as a design smell that may indicate an oversized unit.
  • Use arrange/act/assert structure for readability.
  • Name tests with state and expected outcome.
  • Group tests by unit (class/module/suite) and keep file structure aligned with production ownership.
  • Prefer explicit test doubles over hidden global fixtures.
  • Avoid sleeps, disk reads, and live API calls in unit tests.
  • Keep setup small; large setup indicates overly coupled units.
  • Use SOLID as a testability guide, especially SRP and DIP for isolation and maintainability.
  • Run unit suites continuously in local development and CI.
  • Add regression tests for every high-severity defect.
  • Revisit unit boundaries when tests become slow or flaky.

Written by: Pedro Guzmán

See References for complete APA-style bibliographic entries used on this page.