Sign in or create a free account to continue.

CMP-220 · Lesson 12 of 12 · 20 min

Unit testing, test doubles and a refactoring capstone

Dana set one condition at the start of the term: the rebuilt Hub must produce exactly the same counts as Tally.java for every historical day, because customers have years of trend data and a change of even a fraction of a percent would show up in their reports as a false trend. The only practical way to change a large system while keeping that promise is to surround it with automated tests that run in seconds and say, every time, whether anything changed. This final lesson covers unit testing in Java and Python, test doubles for isolating the code under test, test-driven development, and the techniques for getting untested legacy code under test. It ends with the capstone: the step-by-step refactoring of Tally.java into the object model built in this course.

What a unit test is

A unit test is a small, automated check of one unit of behaviour, usually one method or one small class, run in isolation from slow or unpredictable things such as networks, databases, clocks and other threads. Good unit tests are fast, so thousands run in seconds; independent, so they can run in any order; repeatable, giving the same result every time on every machine; and self-checking, reporting pass or fail without anyone reading output. They document behaviour as executable examples, catch regressions the moment they are introduced, and, perhaps most importantly, make people brave enough to improve code. A system with no tests tends to freeze, because every change is frightening.

Most unit tests follow the same three-part shape, often called arrange, act, assert. Arrange the objects and inputs; act by calling the method under test once; assert that the result, or the change in state, is what the specification says. A clear name describes the behaviour, not the method: rejectsLaneOutsideOneToFour says far more than testConstructor2. Each test should check one behaviour, so that a failure points straight at the problem.

JUnit 5 tests: one behaviour per test, arrange-act-assert, boundary values and an expected exception.
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;

class LengthClassifierTest {
    private final Classifier clf = new LengthClassifier();
    private final Instant t = Instant.parse("2026-11-02T07:42:13Z");

    @Test
    void classifiesTypicalCarAsCar() {
        Detection d = new Detection(17, 2, t, 455, 62);              // arrange
        VehicleClass result = clf.classify(d);                      // act
        assertEquals(VehicleClass.CAR, result);                     // assert
    }

    @Test
    void boundaryAt600CmIsLightTruck() {
        assertEquals(VehicleClass.LIGHT_TRUCK, clf.classify(new Detection(17, 2, t, 600, 62)));
        assertEquals(VehicleClass.CAR, clf.classify(new Detection(17, 2, t, 599, 62)));
    }

    @Test
    void detectionRejectsLaneOutsideOneToFour() {
        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
                () -> new Detection(17, 9, t, 455, 62));
        assertTrue(e.getMessage().contains("lane"));
    }
}

The second test shows a habit worth building: test at the boundaries. Off-by-one errors live at the edges of ranges, so test 599 and 600, 49 and 50, lane 0, 1, 4 and 5, an empty batch and a full one. The equivalence-partitioning idea is to pick one representative from each range of inputs that should behave the same, plus the values on each side of every boundary. A handful of well-chosen cases finds most bugs.

Test doubles

Real objects often get in the way of unit tests. The UploadService stores detections in PostgreSQL, publishes events to listeners, asks a clock for the time and sends replies over a socket. A test that used all of these would be slow, would need a database server, and would give different answers depending on the time of day. Because the service receives its collaborators through its constructor, by the dependency injection of lessons 4 and 10, a test can hand it stand-ins instead. Gerard Meszaros named the kinds of stand-in, collectively test doubles. A dummy is passed only to fill a parameter and is never used. A stub returns canned answers, such as a clock that always says 07:44. A fake is a working but simplified implementation, such as an in-memory DetectionStore backed by a HashMap. A mock is pre-programmed with expectations about the calls it should receive and fails the test if they do not happen; a spy simply records the calls so the test can check them afterwards.

Mocking frameworks such as Mockito for Java and unittest.mock for Python generate mocks and spies automatically. Use them with restraint. A test full of mocks that checks exactly which methods were called in which order is tied to the implementation, not the behaviour, and breaks whenever the code is refactored even if the results are unchanged. Prefer fakes and stubs and assertions on outcomes; use mocks to verify interactions that are the behaviour, such as checking that a silent node causes exactly one alert to be published.

Test-driven development

Test-driven development, TDD, reverses the usual order. In each short cycle you first write a failing test for a small piece of behaviour that does not exist yet (red), then write the simplest code that makes it pass (green), then improve the design of both the code and the tests while keeping every test passing (refactor). The cycle takes minutes. Kent Beck, who popularized TDD around 2000, emphasized that the tests drive the design: code written to be tested first tends to have small units, clear interfaces and injected dependencies, because otherwise it is hard to test. TDD is not the only way to get good tests, and many teams write tests alongside or just after the code, but the red-green-refactor rhythm is a valuable discipline, especially for logic with many cases, such as classification rules.

Getting legacy code under test

Tally.java has no tests and was not written to be tested: it reads the real clock, writes to the real database and keeps its data in static fields. Michael Feathers, in Working Effectively with Legacy Code (2004), defined legacy code simply as code without tests, and described how to change it safely. The first step is to write characterization tests: tests that capture what the code actually does today, right or wrong, so that any change in behaviour is detected. For the Hub, the team ran Tally.java on 40 archived days of uploads and saved every output count, 2.3 million numbers in all, as the expected results. The new code must match them exactly; where the old code was wrong, such as the eight months of shuffled axle counts from lesson 1, the difference is investigated, documented and approved deliberately rather than introduced by accident.

The second step is to find seams, Feathers's word for places where behaviour can be changed without editing the code there. In Java the most useful seams are constructor parameters and interfaces. Jonah's first refactoring of Tally.java was tiny: replace the calls to System.currentTimeMillis() with calls to a Clock field, initialized to the system clock by default. Nothing changed in production, but tests could now set the time. Each small refactoring of this kind is made in a separate commit, with the full characterization suite run before and after.

Refactoring

Refactoring is changing the internal structure of code without changing its external behaviour, in small, safe steps, each verified by the tests. Martin Fowler's catalogue names dozens of these steps; the capstone uses a handful. Extract method pulls a fragment into a well-named method. Extract class moves a group of related fields and methods into a new class, the main tool of the single responsibility principle. Replace parallel arrays with an object turns nodeId[i], lane[i] and so on into one Detection. Replace conditional with polymorphism turns a switch on a type code into subclasses or strategies. Introduce parameter object replaces a long list of parameters that always travel together with one object. CMPUT-301 covers refactoring at team scale, together with code review, continuous integration and the broader testing strategy; here we apply it to one system.

Worked problem

The capstone: refactoring Tally.java

Plan and carry out the refactoring of Tally.java into the object model of this course, keeping the counts identical. Record the steps, the tests that protected each one and the result.

Show solution
  1. Step 0, safety net: run Tally.java on 40 archived days and record 2.3 million output counts as characterization tests. Add a Clock field as the first seam.
  2. Step 1, replace parallel arrays with an object: introduce the immutable Detection class (lesson 2) and convert the arrays one method at a time. Two methods that had forgotten the axle array are found by the tests and reported to Dana.
  3. Step 2, extract class: move parsing into UploadParser, with the exception hierarchy of lesson 8, and storage into DetectionStore behind an interface (dependency inversion, lesson 10).
  4. Step 3, replace conditional with polymorphism: the 400-line classify becomes the Classifier strategy family (lessons 7 and 11), and model switches become the CounterNode hierarchy (lesson 5).
  5. Step 4, introduce the node state machine and the observer-based alerts (lesson 11), replacing the status codes and the hard-coded paging calls.
  6. Step 5, extract the report formats into ReportGenerator subclasses (template method) and delete the static fields; every collaborator is now injected at start-up.
  7. Result: 2,300 lines in one class become 31 classes averaging 74 lines, covered by 640 unit tests that run in 4 seconds plus the characterization suite, which matches all 2.3 million counts except the documented axle correction.

Answer: Small, tested steps turned a frightening file into a set of focused classes without a single wrong count reaching a customer. Each step used an idea from this course, and each was protected by tests written before the change.

A UML class diagram of the refactored Hub. An UploadService class sits in the centre and depends on four interfaces: UploadParser, DetectionStore, Classifier and NodeEventListener. A NodeContext class composes a NodeState interface realized by Active, Silent and InMaintenance, among others. An abstract CounterNode class is extended by LoopNode, RadarNode and CameraNode. Classifier is realized by LengthClassifier, AxleClassifier and CameraClassifier, and DetectionStore by PostgresStore and InMemoryStore. A Clock interface is injected into UploadService and NodeContext. Detection is drawn as an immutable value class used by most of the others.
The refactored design: small classes connected through interfaces, with collaborators injected at start-up.

A test suite in Python

The example below writes a small classifier and node with an injected clock, a fake store and a spy listener, then runs a unittest suite in-process and prints the results. See how every test controls time and storage completely, so each runs in microseconds and gives the same answer on every machine, and how the spy checks the one interaction that matters: a silent node produces exactly one alert.

import io, unittest
from datetime import datetime, timedelta

def classify(length_cm):
    if not 50 <= length_cm <= 3000:
        raise ValueError(f"length {length_cm} out of range")
    for limit, cls in [(280, "motorcycle"), (600, "car"), (900, "light truck"), (1400, "bus")]:
        if length_cm < limit:
            return cls
    return "heavy truck"

class FakeStore:                                   # fake: real logic, in memory
    def __init__(self): self.batches = {}
    def save(self, batch_id, records):
        if batch_id in self.batches: return 0
        self.batches[batch_id] = records; return len(records)

class SpyListener:                                 # spy: records calls for later checks
    def __init__(self): self.calls = []
    def __call__(self, event, node_id): self.calls.append((event, node_id))

class Node:
    def __init__(self, node_id, clock, store, listener):
        self.node_id, self.clock, self.store, self.listener = node_id, clock, store, listener
        self.last, self.silent = clock(), False
    def upload(self, batch_id, lengths):
        self.last, self.silent = self.clock(), False
        return self.store.save(batch_id, [classify(x) for x in lengths])
    def check(self):
        if not self.silent and self.clock() - self.last > timedelta(minutes=30):
            self.silent = True
            self.listener("silent", self.node_id)

class ClassifyTests(unittest.TestCase):
    def test_typical_car(self):
        self.assertEqual(classify(455), "car")
    def test_boundaries(self):
        self.assertEqual([classify(x) for x in (279, 280, 599, 600)],
                         ["motorcycle", "car", "car", "light truck"])
    def test_rejects_impossible_length(self):
        with self.assertRaises(ValueError):
            classify(20)

class NodeTests(unittest.TestCase):
    def setUp(self):                               # arrange shared fixtures
        self.now = datetime(2026, 11, 2, 7, 0)
        self.store, self.spy = FakeStore(), SpyListener()
        self.node = Node(4417, lambda: self.now, self.store, self.spy)   # stub clock
    def test_resent_batch_is_stored_once(self):
        self.assertEqual(self.node.upload("b1", [455, 1720]), 2)
        self.assertEqual(self.node.upload("b1", [455, 1720]), 0)
    def test_silence_raises_exactly_one_alert(self):
        self.now += timedelta(minutes=31); self.node.check(); self.node.check()
        self.assertEqual(self.spy.calls, [("silent", 4417)])
    def test_no_alert_before_thirty_minutes(self):
        self.now += timedelta(minutes=30); self.node.check()
        self.assertEqual(self.spy.calls, [])

suite = unittest.TestSuite()
for case in (ClassifyTests, NodeTests):
    suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(case))
result = unittest.TextTestRunner(stream=io.StringIO(), verbosity=0).run(suite)
print(f"ran {result.testsRun} tests: {len(result.failures)} failures, {len(result.errors)} errors")
for case in (ClassifyTests, NodeTests):
    print(case.__name__, sorted(n for n in dir(case) if n.startswith("test_")))
Output
ran 6 tests: 0 failures, 0 errors
ClassifyTests ['test_boundaries', 'test_rejects_impossible_length', 'test_typical_car']
NodeTests ['test_no_alert_before_thirty_minutes', 'test_resent_batch_is_stored_once', 'test_silence_raises_exactly_one_alert']

How much testing is enough?

Code coverage tools report which lines and branches the tests executed. Coverage is a useful map of what is untested, and a module at 30 percent coverage almost certainly has untested behaviour. But high coverage does not prove good tests: a test that runs a method and asserts nothing covers lines without checking them. Aim for meaningful tests of every rule and boundary, use coverage to find gaps, and remember the testing pyramid: many fast unit tests at the base, fewer integration tests that check components working together, such as the real PostgreSQL store, and a small number of end-to-end tests at the top that exercise the whole system as customers do. Property-based and mutation testing, which CMPUT-301 introduces, push further by generating inputs automatically and by checking whether the tests notice deliberately inserted bugs.

Jonah's term ends with the old Tally.java deleted from the repository. In its place is a design he can explain class by class: immutable detections guarding their own rules, nodes in a small inheritance hierarchy with an explicit lifecycle, classifiers and report formats as interchangeable strategies, alerts flowing to observers, operator actions as auditable commands, everything wired together by injection and protected by tests. None of these ideas is specific to Java or to traffic counting. They are how large software systems in every field, from aircraft maintenance planning to medical devices to the operating systems in CMP-320, are kept understandable and changeable for decades.

Common mistakes

  • MistakeWriting tests that depend on the real clock, network or database.

    InsteadInject collaborators and use stubs and fakes, so unit tests are fast and repeatable.

  • MistakeChecking many behaviours in one long test.

    InsteadTest one behaviour per test with a descriptive name, so a failure points to the cause.

  • MistakeOver-mocking: asserting every internal call.

    InsteadAssert outcomes; mock only interactions that are themselves the required behaviour.

  • MistakeRefactoring legacy code before it has tests.

    InsteadWrite characterization tests first, then refactor in small steps, running them after each step.

  • MistakeTreating high coverage as proof of quality.

    InsteadCoverage shows what ran, not what was checked; make sure tests assert meaningful results.

Summary and key terms

  • Unit tests are fast, independent, repeatable and self-checking checks of small units of behaviour, written as arrange, act, assert.
  • Test boundaries and representative values from each equivalence class.
  • Test doubles (dummies, stubs, fakes, mocks, spies) replace slow or unpredictable collaborators; injection makes them possible.
  • TDD cycles through red, green and refactor; characterization tests and seams make legacy code safe to change.
  • Refactoring changes structure without changing behaviour, in small tested steps such as extract class and replace conditional with polymorphism.
Unit test
An automated check of one small unit of behaviour, run in isolation.
Test double
A stand-in for a real collaborator in a test, such as a stub, fake or mock.
Stub
A test double that returns predetermined answers.
Fake
A working but simplified implementation used in tests, such as an in-memory store.
Characterization test
A test that records what existing code currently does, to detect any change.
Refactoring
Changing code's internal structure without changing its external behaviour.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Name the three parts of a typical unit test.

    Show answer

    Arrange, act, assert.

  2. Which boundary values would you test for a lane number valid from 1 to 4?

    Show answer

    0, 1, 4 and 5 (plus a typical value such as 2).

  3. What kind of test double is an in-memory DetectionStore backed by a HashMap?

    Show answer

    A fake.

  4. Why were characterization tests written before refactoring Tally.java?

    Show answer

    To capture its current behaviour, so any change in the counts would be detected during refactoring.

  5. Give two refactorings used in the capstone and the problem each solved.

    Show answer

    Replace parallel arrays with an object (misaligned fields); replace conditional with polymorphism (scattered model and classification switches).

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz