Unit Testing

7 min read

Reading Progress0%
Software Engineering Practices Index
Tier 1 -- Foundations
Tier 2 -- Core Practices
Tier 3 -- Platform & Delivery
Software Engineering Practices Index
Tier 1 -- Foundations
Tier 2 -- Core Practices
Tier 3 -- Platform & Delivery

Unit Testing

What and Why

A unit test exercises a small, isolated piece of code — typically a single function or method — and asserts that it behaves correctly for a given input. "Isolated" means no database, no network, no filesystem, no external service.

You encounter unit tests as the foundational layer of any test suite. They run in milliseconds, give you precise failure signals, and can catch regressions the instant you make a change — before a PR, before CI, before deployment.

Why they matter in production:

  • Fast feedback: a suite of 500 unit tests should finish in under 2 seconds. That tightness of loop changes how you code.
  • Refactoring confidence: when behavior is pinned by tests, you can restructure internals without fear of silent breakage.
  • Documentation: well-named tests describe what the code is supposed to do. They're often the first thing a new team member reads.
  • Regression prevention: bugs fixed without a test tend to reappear. A test turns a bug fix into a permanent contract.

QUICK CHECK

Your team refactors a payment processing module, reorganizing internal helper functions without changing any public behavior. Shortly after merging, a bug surfaces in production that the refactor silently introduced. Which practice would have most directly prevented this outcome?

Choose one answer

Core Concepts

What a Unit Is

A unit is the smallest thing you can usefully test in isolation. For a backend service, that usually means:

  • A pure function (no side effects, same input → same output)
  • A method on a struct/class that manipulates internal state
  • A logic layer that doesn't depend on I/O

It does NOT mean testing private implementation details. Test behavior, not internals.

// Good unit: pure function, testable in isolation
func calculateDiscount(orderTotal float64, membershipTier string) float64 {
    switch membershipTier {
    case "gold":
        return orderTotal * 0.15
    case "silver":
        return orderTotal * 0.10
    default:
        return 0
    }
}
// Hard to unit test: tightly coupled to I/O
func processOrder(orderID string) error {
    order, err := db.GetOrder(orderID)  // database call baked in
    // ...
}

The fix for the second case is dependency injection — pass the database dependency in so tests can swap it out.

Anatomy of a Test

Every test has three parts, often called Arrange-Act-Assert (AAA) or Given-When-Then:

func TestCalculateDiscount_GoldMember(t *testing.T) {
    // Arrange — set up inputs and expected state
    orderTotal := 100.0
    membershipTier := "gold"
    expectedDiscount := 15.0

    // Act — call the unit under test
    actualDiscount := calculateDiscount(orderTotal, membershipTier)

    // Assert — verify the outcome
    if actualDiscount != expectedDiscount {
        t.Errorf("expected %v, got %v", expectedDiscount, actualDiscount)
    }
}

Keep each test focused on one behavior. If your Arrange section is 20 lines, the unit probably has too many responsibilities.

Test Naming

Test names are documentation. They should communicate:

  1. What is being tested
  2. Under what condition
  3. What the expected outcome is

Common pattern: Test<Unit>_<Scenario>_<ExpectedBehavior>

TestCalculateDiscount_GoldMember_Returns15Percent
TestCalculateDiscount_UnknownTier_ReturnsZero
TestParseJWT_ExpiredToken_ReturnsError
TestParseJWT_ValidToken_ReturnsUserClaims

A failing test with a name like TestParseJWT_ExpiredToken_ReturnsError tells you exactly what broke. A test named TestParseJWT or Test3 tells you nothing.

Test Coverage

Coverage measures how much of your code is executed by tests. It's useful as a signal, not a target.

  • 100% coverage doesn't mean your code is correct — it means every line ran during tests.
  • Low coverage (below ~70%) usually signals undertested logic paths.
  • Coverage gaps in critical paths (auth, billing, data integrity) are high-risk.

The dangerous misconception: "we have 90% coverage" → "we're well tested." Coverage says nothing about whether your assertions are meaningful. A test that calls a function and asserts nothing contributes to coverage without providing safety.

Testing Edge Cases

Production bugs cluster at edges:

  • Empty inputs, nil/null values
  • Boundary values (0, -1, max int, empty string)
  • Unexpected types or formats
  • Concurrent access
  • Error return paths

For every unit, ask: what inputs could cause unexpected behavior? Test those explicitly.


QUICK CHECK

A developer writes a test that calls processPayment(order) and never calls any assertion method — no expected value is checked. The test passes every time. What is the most accurate statement about this test's value?

Choose one answer

How It Works in Practice

Go Example

// calculator.go
package billing

func ApplyTax(amount float64, taxRate float64) float64 {
    if taxRate < 0 || taxRate > 1 {
        return amount
    }
    return amount + (amount * taxRate)
}
// calculator_test.go
package billing

import "testing"

func TestApplyTax_StandardRate_AddsTax(t *testing.T) {
    result := ApplyTax(100.0, 0.08)
    if result != 108.0 {
        t.Errorf("expected 108.0, got %f", result)
    }
}

func TestApplyTax_ZeroRate_ReturnsOriginal(t *testing.T) {
    result := ApplyTax(100.0, 0.0)
    if result != 100.0 {
        t.Errorf("expected 100.0, got %f", result)
    }
}

func TestApplyTax_NegativeRate_ReturnsOriginal(t *testing.T) {
    result := ApplyTax(100.0, -0.05)
    if result != 100.0 {
        t.Errorf("expected 100.0, got %f", result)
    }
}

func TestApplyTax_RateAboveOne_ReturnsOriginal(t *testing.T) {
    result := ApplyTax(100.0, 1.5)
    if result != 100.0 {
        t.Errorf("expected 100.0, got %f", result)
    }
}

Run them:

go test ./billing/...
go test -v ./billing/...          # verbose — shows test names
go test -run TestApplyTax ./...   # run specific test
go test -cover ./...              # show coverage

Python Example (pytest)

# billing.py
def apply_tax(amount: float, tax_rate: float) -> float:
    if tax_rate < 0 or tax_rate > 1:
        return amount
    return amount + (amount * tax_rate)
# test_billing.py
import pytest
from billing import apply_tax

def test_apply_tax_standard_rate_adds_tax():
    assert apply_tax(100.0, 0.08) == pytest.approx(108.0)

def test_apply_tax_zero_rate_returns_original():
    assert apply_tax(100.0, 0.0) == 100.0

def test_apply_tax_negative_rate_returns_original():
    assert apply_tax(100.0, -0.05) == 100.0

@pytest.mark.parametrize("rate,expected", [
    (0.08, 108.0),
    (0.0, 100.0),
    (-0.05, 100.0),
    (1.5, 100.0),
])
def test_apply_tax_parametrized(rate, expected):
    assert apply_tax(100.0, rate) == pytest.approx(expected)
pytest
pytest -v                         # verbose
pytest -k "test_apply_tax"        # filter by name
pytest --cov=billing              # coverage report

Table-Driven Tests (Go)

Table-driven tests reduce boilerplate when testing one function across many inputs:

func TestApplyTax(t *testing.T) {
    tests := []struct {
        name     string
        amount   float64
        taxRate  float64
        expected float64
    }{
        {"standard 8% rate", 100.0, 0.08, 108.0},
        {"zero rate", 100.0, 0.0, 100.0},
        {"negative rate", 100.0, -0.05, 100.0},
        {"rate above one", 100.0, 1.5, 100.0},
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            result := ApplyTax(tc.amount, tc.taxRate)
            if result != tc.expected {
                t.Errorf("expected %f, got %f", tc.expected, result)
            }
        })
    }
}

Adding a new test case is one line. Failure output names the case that failed.


QUICK CHECK

A Go developer has written four separate test functions for a CalculateDiscount function, each testing a different input scenario (standard rate, zero rate, negative rate, rate above maximum). She wants to add five more edge cases. Which approach would best reduce boilerplate while keeping failure output descriptive enough to identify which specific case failed?

Choose one answer

Common Mistakes

Testing implementation details instead of behavior. If your test breaks every time you rename a private variable or change an internal data structure, the tests are coupled to the wrong thing. Test what the function returns and what side effects it produces — not how it does it internally.

Asserting too much in one test. A test that checks 5 different behaviors will fail with an ambiguous message. One assertion per test (or one logical behavior per test) gives you precise failure signals.

Not testing error paths. Most unit tests only cover the happy path. Error branches — invalid inputs, missing values, unexpected formats — are where bugs live. Test err != nil returns explicitly.

Flaky tests that depend on time or randomness. Tests that use time.Now() or rand directly will fail intermittently. Inject clocks and random sources as dependencies so tests can control them.

Coverage gaming. Writing tests that execute lines without asserting anything meaningful inflates coverage numbers but provides no safety net. "Tests that don't fail when they should" are worse than no tests — they give false confidence.


QUICK CHECK

A test suite for a payment service has 95% line coverage. A developer notices that many tests call functions but never assert on return values or state changes — they only verify the code doesn't panic. What is the most accurate assessment of this test suite?

Choose one answer

Tradeoffs

Speed vs. realism. Unit tests are fast because they're isolated. But isolation means you can't catch bugs that only appear when components interact. Unit tests and integration tests serve different purposes — don't treat unit tests as a complete substitute.

Test quantity vs. maintenance cost. More tests means more code to maintain. Tests tied to internal structure become a drag during refactors. Keep tests focused on stable behavior (the public API) and you can refactor freely beneath them.

Strict isolation vs. pragmatism. Some engineers insist every unit test must mock all dependencies. In practice, calling real pure helper functions in tests is fine — the goal of isolation is to avoid I/O and non-determinism, not to prohibit all composition.

TDD vs. test-after. Writing tests first (TDD) pushes you toward better-designed interfaces. Writing tests after is faster initially but often results in code that's harder to test. Both approaches produce tests — the difference is in design influence.


QUICK CHECK

A team has a large codebase with hundreds of unit tests that mock every internal helper function, including pure utility functions with no I/O or side effects. During a major refactor, the team finds they must update dozens of tests even though the public behavior hasn't changed. What is the most likely root cause of this maintenance burden?

Choose one answer

Quick Reference

Structure of a test:
  Arrange  → set up inputs and expectations
  Act      → call the unit under test
  Assert   → verify the outcome

Test name pattern:
  Test<Unit>_<Condition>_<ExpectedBehavior>

What to test:
  ✓ Happy path (expected input → expected output)
  ✓ Edge cases (empty, nil, zero, max values)
  ✓ Error paths (invalid input → error returned)
  ✓ Boundary conditions

What NOT to test:
  ✗ Private internals / implementation details
  ✗ Third-party library behavior
  ✗ Framework wiring (that's integration testing)

Run commands:
  Go:     go test ./...           # run all tests
          go test -cover ./...    # with coverage
  Python: pytest                  # run all tests
          pytest --cov            # with coverage
  Java:   mvn test / gradle test
  Node:   jest / vitest
QUICK CHECK

A developer writes a unit test that verifies how a third-party payment library processes a credit card charge. According to standard unit testing practices, what is the problem with this approach?

Choose one answer
Glossary History

Click dotted jargon to save explanations here.

Glossary History

Click dotted jargon to save explanations here.