Unit Testing and Testable Design

Learning Objectives

Write Arrange–Act–Assert tests for classes, including invariants and boundaries.

Distinguish state-based from interaction-based tests.

Use fixtures, builders and parameterized tests; keep tests deterministic.

Prerequisites: Modules 3, 22.

Intuition and Definition

A unit test checks one unit of behaviour (often one class or function) in isolation, quickly and deterministically.

Arrange–Act–Assert (AAA): set up objects, perform one action, check the outcome.

State-based test: assert on the resulting state or return value.

Interaction-based test: assert that collaborators were called correctly (with a spy or mock).

Boundary tests: values at and around limits (0, 1, max, max+1).

Invalid-state tests: constructors and operations reject inputs that would break invariants.

Parameterized tests: one test body, many input/expected pairs.

Why It Matters

Tests pin behaviour so you can refactor safely, document intent with examples, and catch regressions in seconds. Classes that are hard to test are usually badly designed (hidden dependencies, too many responsibilities).

Syntax and Language Notes

GoogleTest, Catch2, doctest, Boost.Test JUnit 5, AssertJ, Mockito xUnit, NUnit, MSTest, FluentAssertions, Moq/NSubstitute pytest, unittest, hypothesis Vitest, Jest, node:test

Complete Example

A tiny self-contained test harness (no framework needed) to show the structure; the same tests map one-to-one onto GoogleTest TEST/EXPECT_EQ macros.

The same style in Python with unittest (pytest would be shorter still):

#include <functional>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>

class SpeedLimiter {                              // unit under test
    int limit_;
public:
    explicit SpeedLimiter(int limit) : limit_(limit) {
        if (limit < 30 || limit > 250) throw std::invalid_argument("limit out of range");
    }
    int clamp(int requested) const { return requested < 0 ? 0 : (requested > limit_ ? limit_ : requested); }
    bool warn(int current) const { return current > limit_; }
};

// ---- minimal harness ----
int failures = 0, total = 0;
void check(bool cond, const std::string& what) { ++total; if (!cond) { ++failures; std::cout << "  FAIL: " << what << "\n"; } }
void test(const std::string& name, const std::function<void()>& body) {
    int before = failures;
    try { body(); } catch (const std::exception& e) { ++failures; std::cout << "  FAIL (exception " << e.what() << ")\n"; }
    std::cout << (failures == before ? "[pass] " : "[FAIL] ") << name << "\n";
}

int main() {
    test("clamps above the limit", [] {
        SpeedLimiter lim(120);                    // Arrange
        int v = lim.clamp(150);                   // Act
        check(v == 
…
[pass] clamps above the limit
[pass] boundary values
[pass] warns only above the limit
[pass] rejects invalid limits
10/10 checks passed
import unittest, io

class SpeedLimiter:
    def __init__(self, limit):
        if not 30 <= limit <= 250: raise ValueError("limit out of range")
        self.limit = limit
    def clamp(self, v): return max(0, min(v, self.limit))

class SpeedLimiterTest(unittest.TestCase):
    def test_boundaries(self):
        lim = SpeedLimiter(120)
        for given, expected in [(-1, 0), (0, 0), (120, 120), (121, 120)]:
            with self.subTest(given=given):
                self.assertEqual(lim.clamp(given), expected)
    def test_invalid_limit(self):
        with self.assertRaises(ValueError):
            SpeedLimiter(10)

stream = io.StringIO()
result = unittest.TextTestRunner(stream=stream, verbosity=0).run(unittest.defaultTestLoader.loadTestsFromTestCase(SpeedLimiterTest))
print("ran", result.testsRun, "tests, failures:", len(result.failures), "errors:", len(result.errors))
ran 2 tests, failures: 0 errors: 0

Step-by-Step Walkthrough

Each test has one reason to fail and a descriptive name ("clamps above the limit").

Boundary cases (−1, 0, limit−1, limit, limit+1) catch off-by-one errors (> vs >=).

Invalid-state tests prove the constructor protects the invariant.

Nothing depends on time, randomness, the network or test order — tests are deterministic.

Common Mistakes

Testing implementation details (private methods, exact call sequences) — tests break on every refactor.

Several unrelated behaviours in one test. Non-deterministic tests (real clocks, random data without seeds, shared global state) — "flaky" tests.

Assertions without messages that make failures hard to diagnose.

No tests for error paths.

Trade-offs and Alternatives

Unit tests are fast and precise but can't prove that components work together — combine with integration and end-to-end tests (the test pyramid). Over-mocked unit tests give false confidence.

Real-World Use

CI pipelines run thousands of unit tests per commit; safety standards (ISO 26262, DO-178C) require documented unit verification with coverage targets.

Practice

Q1. (Easy) What does AAA stand for?

Answer: Arrange, Act, Assert.

Q2. (Medium) Which inputs would you test for clamp(v) with limit 120?

Answer: Negative, 0, a normal value, 119, 120, 121 and a very large value.

Q3. (Advanced) A test uses std::chrono::system_clock::now() inside the class under test and fails at midnight. How do you fix the design?

Answer: Inject a clock abstraction (Module 22, 67); tests pass a fixed clock so time-dependent behaviour is deterministic.

Interview Question

Q1. "What makes a class easy to unit test?" Answer: Explicit dependencies (injected, behind small interfaces), deterministic behaviour (no hidden time/randomness/globals), small focused responsibilities, observable outcomes (return values or queryable state), constructors that validate and leave objects valid, and side effects isolated at the edges. Hard-to-test classes usually violate SRP or DIP.

Quick Quiz

Q1. State-based vs interaction-based test?

Answer: State-based checks results/state; interaction-based checks calls made to collaborators.

Q2. What is a flaky test?

Answer: One that passes or fails intermittently without code changes.

Q3. Why test boundaries?

Answer: Off-by-one and comparison errors cluster at limits.

Summary

AAA, one behaviour per test, boundaries and invalid states, deterministic and fast.

Testability is a design property.

Related: Modules 65–67.