Skip to main content
A pixel art illustration contrasting untestable code (tangled monolith with buried logic) versus testable code (hexagonal architecture with clean ports for test doubles). Shows how separating infrastructure from domain logic enables easy testing.

CS 3100: Program Design and Implementation II

Lecture 16: Designing for Testability

©2026 Jonathan Bell & Ellen Spertus, CC-BY-SA

Imposter Syndrome Reminder

Pie chart with blue, orange, and green regions.
The legends for the colors are 'People who get imposter syndrome',
'Other people who get imposter syndrome', and 'Literally everyone else
(they also get imposter syndrome)'. Text at the bottom reads:
'Everyone feels like an imposter sometimes, and that's okay.'
At the bottom right is 'errantscience.com'.

Poll: How has this course made you feel?

A. stupid

B. smart

C. challenged

D. scared

E. proud

F. overwhelmed

G. knowledgeable

H. humble

Poll Everywhere QR Code or Logo

Learning Objectives

After this lecture, you will be able to:

  1. Evaluate the testability of a software module using the concepts of observability and controllability
  2. Explain Hexagonal Architecture (Ports and Adapters) and its relationship to testability
  3. Describe properties of good test suites: fast, deterministic, independent, readable
  4. Recognize anti-patterns that lead to untestable code and how to fix them

Testability is a Design Choice

Some systems are more testable than others. Imagine testing a feature that requires:

  • Setting up three different web services
  • Creating five files in specific directories
  • Putting a database in a specific state
  • Then verifying all of those were modified correctly

All of this is doable... but couldn't it be simpler?

Testability isn't luck—it's the result of design decisions.

Two Properties Determine Testability

Two pillars supporting 'Testability': Left pillar 'Observability' shows a scientist examining a transparent glass box with visible internal state. Right pillar 'Controllability' shows a conductor directing dependencies. Without both pillars, the roof collapses.

Observability: Can We See What Happened?

Observability is about how easily we can inspect the results of executing code.

public class TemperatureLogger {
public void logReading(String zoneId, double temperature) {
String timestamp = LocalDateTime.now().toString();
String logEntry = String.format("[%s] Zone %s: %.1f°F",
timestamp, zoneId, temperature);
System.out.println(logEntry); // Where does this go?
}
}

void return — nothing to assert on

System.out.println — output disappears into the ether

⚠️ LocalDateTime.now() — timestamp changes every run (controllability!)

Improving Observability and Controllability

public class TemperatureLogger {
private final List<String> logEntries = new ArrayList<>(); // ① Store results
private final Clock clock; // ② Inject dependency

public TemperatureLogger(Clock clock) {
this.clock = clock;
}

public void logReading(String zoneId, double temperature) {
String timestamp = Instant.now(clock).toString(); // ③ Use injected clock
String logEntry = String.format("[%s] Zone %s: %.1f°F",
timestamp, zoneId, temperature);
logEntries.add(logEntry);
}

public List<String> getLogEntries() { // ④ Expose for inspection
return Collections.unmodifiableList(logEntries);
}
}

✓ ① Store entries in a list — we can inspect them later (observability)

✓ ②③ Inject Clock — tests pass Clock.fixed(...) (controllability)

✓ ④ getLogEntries() — tests can assert on what was logged (observability)

Poll: Which change would MOST improve the testability of this method?

public void processOrder(Order order) {
double tax = order.getTotal() * 0.08;
order.setTax(tax);
database.save(order);
}

A. Make it private

B. Make database a parameter

C. Split it into multiple methods

D. Add a getTax() method to Order

Poll Everywhere QR Code or Logo

One Possible Rewrite for Testability

protected double calculateTax(double amount) {
return amount * .08;
}

protected void addTaxToOrder(Order order) {
order.setTax(calculateTax(order.getTotal()));
}

protected void processOrder(Order order, Database db) {
addTaxToOrder(order);
db.save(order); // save to parameter db
}

public void processOrder(Order order) {
processOrder(order, database); // pass instance variable
}

Controllability: Can We Set Up the Test Scenario?

Controllability is about how easily we can put the system into a specific state for testing.

Low Controllability

public double getCurrentPrice() {
// Creates its own client!
HttpClient client =
HttpClient.newHttpClient();
// Can't substitute test version
}

public boolean isOffPeakHours() {
// Uses system clock!
int hour = LocalTime.now()
.getHour();
// Can't test 3 AM at 2 PM
}

High Controllability

private final EnergyPriceApi api;
private final Clock clock;

public EnergyPriceService(
EnergyPriceApi api, Clock clock) {
this.api = api;
this.clock = clock;
}

public double getCurrentPrice() {
return api.fetchCurrentPrice();
}

public boolean isOffPeakHours() {
int hour = LocalTime.now(clock)
.getHour();
return hour >= 22 || hour < 6;
}

Observability: Return Values vs Side Effects

Consider testing a device health checker. How do we observe what happened?


public class DeviceHealthChecker {
private final DeviceRepository repo;

public void checkDevice1(IoTDevice device) {
HealthStatus status = assessHealth(device);
device.setHealthStatus(status); // Mutates input!
repo.save(device); // Side effect!
}

public HealthReport checkDevice2(IoTDevice device) {
HealthStatus status = assessHealth(device);
return new HealthReport(device.getId(), status);
}
}

The second version is easier to test: call the method, check the return value.
There's no need to inspect mutated objects or mock repositories.

Separating Infrastructure from Domain

The most important principle for testability: separate infrastructure from domain code.

Domain Code

  • Business rules and calculations
  • Decisions that define what your system does
  • Pure functions, no side effects
  • Easy to test

Infrastructure Code

  • Databases, APIs, file systems
  • Hardware, network, external services
  • I/O, persistence, communication
  • Requires test doubles

When these are mixed together, testing becomes painful because you can't exercise business logic without involving infrastructure.

Poll: What makes this method hard to test?

public boolean shouldSendAlert(String deviceId) {
double temp = new TemperatureSensor().readTemperature(deviceId);
LocalTime now = LocalTime.now();
return temp > 100.0 && now.getHour() >= 8 && now.getHour() <= 22;
}

A. Its return type is a primitive

B. It creates its dependencies internally

C. It tests multiple conditions

D. It has side effects

Poll Everywhere QR Code or Logo

Mind the Gap (Revisited from L12)

Pixel art split-screen: TOP shows small gap with sturdy bridge between domain and implementation. BOTTOM shows huge gap with technical debris everywhere. Tagline: Mind the Gap.

In L12, we talked about keeping implementation close to the domain model.

The same principle applies to testability: the more infrastructure pollutes your domain, the harder testing becomes.

Mixed Concerns Make Testing Painful

public class EnergyOptimizer {
private static final double LOW_PRICE_THRESHOLD = 0.10;

public void optimizeForPrice(String thermostatId) {
// Infrastructure: API call to get price
HttpClient client = HttpClient.newHttpClient();
HttpRequest priceRequest = HttpRequest.newBuilder()
.uri(URI.create("https://api.gridprices.com/current"))
.header("Authorization", "Bearer " + System.getenv("API_KEY"))
.build();
HttpResponse<String> response =
client.send(priceRequest, HttpResponse.BodyHandlers.ofString());
double currentPrice = Double.parseDouble(response.body());

// Domain: business logic (buried in the middle!)
int powerLevel = currentPrice < LOW_PRICE_THRESHOLD ? 100 : 50;

// Infrastructure: Zigbee protocol command
ZigbeeGateway gateway = new ZigbeeGateway("192.168.1.100");
gateway.connect();
gateway.sendCommand(thermostatId, "SET_POWER", powerLevel);
gateway.disconnect();
}
}

Hexagonal Architecture (Ports and Adapters)

The solution is called Hexagonal Architecture, proposed by Alistair Cockburn (2005)

Hexagonal Architecture diagram for an IoT Smart Home. A central golden hexagon labeled 'Application Core / Domain Logic' contains 'EnergyOptimizer' with the rule 'if (price > threshold) reducePower()' and the caption 'Pure business rules. No technology.' Two ports sit on the hexagon's edges as electrical outlet sockets. On the left, EnergyPricePort has two adapters plugged in: GridPriceApiAdapter (blue, connecting to an Energy Grid API cloud) and StubPriceAdapter (green, labeled 'returns $0.35'). On the right, DeviceControlPort has ZigbeeDeviceAdapter (blue, connecting to a smart thermostat) and SpyDeviceControlAdapter (green, labeled 'records calls'). A callout reads 'Tests plug in here! Same ports, different adapters.' Blue adapters represent production; green adapters represent test doubles.

Why "Hexagonal"?

The hexagon shape emphasizes a key idea: there's no "top" or "bottom".

The insight:

  • Your business logic sits in the center
  • Everything else (databases, APIs, hardware, UI) is outside
  • Each external thing connects through a "port"
  • The direction is always inward (toward domain) and outward (toward infrastructure)

Tests are just another "outside" thing! They plug in through the same ports as real infrastructure.

The Three Layers: Core

Core: Knows nothing about databases, APIs, or hardware—only the domain.

Ports: Interfaces that describe what the domain needs, not how to get it.

Adapters: Implementations of the interfaces to talk to specific technologies

The Three Layers: Ports

Core: Knows nothing about databases, APIs, or hardware—only the domain.

Ports: Interfaces that describe what the domain needs, not how to get it.

Adapters: Implementations of the interfaces to talk to specific technologies

The Three Layers: Adapters

Core: Knows nothing about databases, APIs, or hardware—only the domain.

Ports: Interfaces that describe what the domain needs, not how to get it.

Adapters: Implementations of the interfaces to talk to specific technologies

Ports Define What the Domain Needs

Ports are interfaces defined by the domain, describing what it needs from the outside world.

// Port: How we get current energy price (single method = can use lambda in tests!)
public interface EnergyPricePort {
double getCurrentPricePerKWh();
}

// Port: How we control a device's power level
public interface DeviceControlPort {
void setDevicePower(String deviceId, int powerPercent);
}

Note: Functional interfaces can be implemented with lambdas in tests.

The Domain Uses Ports (Abstractions), Not Technologies (Concretions)

public class EnergyOptimizer {
private static final double LOW_PRICE_THRESHOLD = 0.10;
private final EnergyPricePort priceService;
private final DeviceControlPort deviceControl;

public EnergyOptimizer(EnergyPricePort priceService,
DeviceControlPort deviceControl) {
this.priceService = priceService;
this.deviceControl = deviceControl;
}

public void optimizeForPrice(String thermostatId) {
double currentPrice = priceService.getCurrentPricePerKWh();
if (currentPrice < LOW_PRICE_THRESHOLD) {
// Energy is cheap — pre-heat the home
deviceControl.setDevicePower(thermostatId, 100);
} else {
deviceControl.setDevicePower(thermostatId, 50);
}
}
}

✓ No HttpClient, no Connection, no DataSource—only interfaces.

Adapters Connect to Real Infrastructure

// Adapter: Gets prices from a real API
public class GridPriceApiAdapter
implements EnergyPricePort {

private final HttpClient httpClient;
private final String apiKey;

@Override
public double getCurrentPricePerKWh() {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(
"https://api.gridprices.com/current"))
.header("Authorization",
"Bearer " + apiKey)
.build();
// ... parse response and return price
}
}

Adapters know the technology and implement the ports to meet the domain's needs.

Testing with Hexagonal Architecture

@Test
void runsThermostatAtFullPowerWhenPriceIsLow() {
// Implement the functional interface with a lambda.
EnergyPricePort stubPrices = () -> 0.05;

// Implement DeviceControlPort.
SpyDeviceControlAdapter spyDevices =
new SpyDeviceControlAdapter();

// EnergyOptimizer is the class under test.
EnergyOptimizer optimizer =
new EnergyOptimizer(stubPrices, spyDevices);

// This is the method under test.
optimizer.optimizeForPrice("thermostat-1");

// Make sure the power level was set correctly.
assertEquals(100,
spyDevices.getPowerLevel("thermostat-1"));
}

Ports as a Design Technique

When developing with Hexagonal Architecture, let ports emerge from the domain.

  1. Start with the business logic you're trying to implement
  2. When you notice it needs something from the outside world, define an interface
  3. The interface describes what you need, not how to get it
  4. Implement adapters later (or use test doubles)

This approach keeps you focused on the business problem without getting distracted by infrastructure details.

Hexagonal Architecture Minimizes Coupling

Remember the coupling types from L7? Hexagonal Architecture addresses each one:

Coupling TypeHow Hexagonal Architecture Addresses It
DataPorts pass only the data needed—getCurrentPricePerKWh() returns a double, not HttpResponse
StampDomain types (EnergyPreferences) are defined by the domain, not dictated by external APIs
ControlAdapters don't pass flags that control domain logic
CommonNo shared global state between domain and infrastructure
ContentImpossible—adapters can't access domain internals

Hexagonal Architecture = Modularity at Scale

This is the same modularity principle from L6 applied at the system level:

L6 PrincipleHexagonal Application
Modules have well-defined interfacesPorts are interfaces defined by domain
Implementation is hiddenAdapters hide technology details
Modules don't depend on other modules' internalsDomain doesn't know about adapters

Low coupling and high cohesion don't just make code easier to change—they make it easier to test.

Testability Aligns with Good Design

Testability isn't a separate concern—it's a natural consequence of following the principles from L6-L8:

Design PrincipleTestability Benefit
Low Coupling (L7)Dependencies can be substituted with test doubles
High Cohesion (L7)Each class has a clear purpose → focused tests
Information Hiding (L6)Implementation changes don't break tests
Dependency Inversion (L8)Inject test doubles instead of real dependencies
Single Responsibility (L8)Fewer behaviors to test per class
Interface Segregation (L8)Smaller interfaces → simpler test doubles

If your code is hard to test, it probably violates one of these principles.

Testability Can Be in Tension with Other Goals

Designing for testability sometimes involves tradeoffs:

GoalTension with Testability
SimplicityTestability may require more interfaces, abstractions, and indirection
EncapsulationObservability sometimes requires exposing internal state
PerformanceDependency injection adds indirection
Reduced BoilerplateConstructor injection requires more code than new

These tensions are usually worth it for non-trivial systems, but not every class needs hexagonal architecture!

When to Invest in Testability

Not all code needs the same level of testability investment:

High Investment

  • Complex business rules
  • Code with many edge cases
  • Integration points with external systems
  • Code that changes frequently
  • Code where bugs are expensive

Low Investment

  • Simple data transfer objects
  • Straightforward delegation
  • Configuration and wiring code
  • Stable, well-tested libraries
  • Code that rarely changes

Apply hexagonal architecture where it provides value. Don't over-abstract simple code.

Complexity

Two-panel cartoon entitled 'Complexity'.
The left panel is labeled 'solution engineering' and shows happy stick figures engineering a complex
system with pulleys, counterweights, and hamster wheels to bridge a crevasse.
The right panel is labeled 'problem solving' and shows a solitary happy stick figure
in front of a crevasse spanned by a board.
The cartoon is dated July 22, 2022 and is labeled monkeyuser.com.

Properties of Good Test Suites

PropertyWhat It MeansHow Hexagonal Enables It
FastTests run in millisecondsDomain tests have no I/O
DeterministicSame result every timeInjectable Clock, Random, etc.
IndependentTests don't affect each otherNo shared infrastructure state
ReadableTests document behaviorGiven/When/Then structure

If tests take 30 minutes, developers won't run them often.
If tests take 30 seconds, they'll run them after every change.

Readable Tests Tell a Story

@Test
void reducesNonEssentialDevices_whenEnergyPriceExceedsThreshold() {
// Given: energy price is high and user has set a threshold
priceService.setCurrentPrice(0.35); // Stub returns high price
preferences.setHighPriceThreshold(0.25);
devices.addDevice(new Device("living-room-light", false)); // non-essential
devices.addDevice(new Device("refrigerator", true)); // essential

// When: the optimizer runs
optimizer.optimizeForPrice("home-123");

// Then: only non-essential devices are reduced
assertEquals(50, devices.getPowerLevel("living-room-light"));
assertEquals(100, devices.getPowerLevel("refrigerator")); // unchanged
}

The test reads like a specification. No HTTP setup, no database seeds—just the business scenario.

Anti-Patterns That Kill Testability

  • Static methods — Can't be substituted with test doubles
  • Singletons — Global state that leaks between tests
  • Private methods wanting tests — Sign the class is doing too much
  • Complex boolean conditions — Require exponential test cases

Common thread: All violate Dependency Inversion or Single Responsibility.

Anti-Pattern: Static Methods

Static methods are globally accessible, which means you can't substitute test implementations.

Hard to test

public class ScheduledTask {
public void runIfDue() {
// Static call - can't stub!
if (TimeUtils.isBusinessHours()) {
doWork();
}
}
}

We can wrap the method isBusinessHours() in a class...

Testable

public class ScheduledTask {
private final BusinessHoursChecker
hoursChecker;

public ScheduledTask(
BusinessHoursChecker hoursChecker) {
this.hoursChecker = hoursChecker;
}

public void runIfDue() {
if (hoursChecker.isBusinessHours()) {
doWork();
}
}
}

The Singleton Pattern

Singleton ensures only one instance of a class exists, accessed through a static method.

public class DatabaseConnection {
private static DatabaseConnection instance;

private DatabaseConnection() {
// private constructor — no one else can create one
}

public static DatabaseConnection getInstance() {
if (instance == null) {
instance = new DatabaseConnection();
}
return instance;
}

public Result query(String sql) { /* ... */ }
}

This is good for efficiency but bad for safety.

Why Singletons Hurt Testing

public class UserService {
public User findUser(String id) {
// Can't substitute this for tests!
return DatabaseConnection.getInstance()
.query("SELECT ...");
}
}

⚠ Same static call problem — you can't swap in a test double.

But singletons add another issue: shared mutable state. One test's data leaks into the next.

Anti-Pattern: Private Methods Needing Testing

If you feel the urge to test a private method directly, consider putting it in its own class.

Hard to test

public class DeviceHealthChecker {
public HealthReport checkAll(
List<IoTDevice> devices) {
HealthReport report = new HealthReport();
for (IoTDevice device : devices) {
report.add(device.getId(),
assessHealth(device));
}
return report;
}

private HealthStatus assessHealth(
IoTDevice device) {
// complex logic to test
}
}

Testable

// Extracted to its own class
public class DeviceHealthAssessor {
public HealthStatus assess(
double batteryPercent,
int signalStrength,
long minutesSinceContact) {
if (minutesSinceContact > 60)
return HealthStatus.OFFLINE;
if (batteryPercent < 10)
return HealthStatus.CRITICAL;
if (batteryPercent < 25 ||
signalStrength < 20)
return HealthStatus.WARNING;
return HealthStatus.HEALTHY;
}
}

Connection to SRP (L8): DeviceHealthChecker was doing two things. Health assessment deserves its own class.

Not an Anti-Pattern: Integration Points

Some classes coordinate multiple components. This isn't an anti-pattern—it's an integration point.

// This class coordinates the home automation workflow
public class HomeAutomationController {
public HomeAutomationController(
SensorNetwork sensors,
DeviceController devices,
EnergyPriceService pricing,
WeatherService weather,
NotificationService notifier) { ... }

public void optimizeHome(String homeId) {
// Orchestrates all the components
}
}

You can still mock the dependencies—but verify coordination behavior, not just "was this method called?"

Many dependencies ≠ bad design. Coordination is this class's job.

Anti-Pattern: Complex Boolean Conditions

Complex conditions require many test cases to cover all branches.

Hard to test

// How many tests to cover this?
if ((device.isOnline() &&
device.batteryLevel() > 20) ||
(device.isPowered() &&
!device.isInSleepMode()) ||
(device.isEssential() &&
emergencyMode)) {
// ...
}

Testable

private boolean canReceiveCommands(
IoTDevice device) {
return hasSufficientBattery(device)
|| hasReliablePower(device)
|| isRequiredInEmergency(device);
}

private boolean hasSufficientBattery(
IoTDevice device) {
return device.isOnline() &&
device.batteryLevel() > 20;
}

Breaking conditions into named methods spreads out the complexity, increasing testability and readability.

Anti-Pattern Summary

Anti-PatternProblemSolution
Static methodsCan't substituteWrap in injectable interface
SingletonsGlobal state, can't replaceInject dependency instead
Private methods wanting testsClass doing too muchExtract to own class
Complex booleansToo many branchesExtract named predicates

Summary

  • Testability is a design choice—not an afterthought
  • Observability = can we see what happened? (return values, accessible state)
  • Controllability = can we set up the scenario? (dependency injection)
  • Hexagonal Architecture separates domain logic from infrastructure
  • Ports are interfaces defined by domain; Adapters implement them
  • Good tests are fast, deterministic, independent, and readable
  • Avoid static methods, singletons, hidden dependencies, tight coupling

The same principles that make code easy to change make it easy to test.

Bonus Slide

Tweet from brombo reading: 'This is what every diagram in my software design class looks like'.
Top part of diagram, labeled 'problem': Rectangles named 'thing 1' and 'thing 2' with lots of squiggly arrows between them.
Bottom part of diagram, labeled 'solution': Same rectangles with neat arrows to and from a new rectangle named 'thing in the middle'.Single-panel XKCD cartoon:
[Megan points at Ponytail and introduces her to Cueball.]
Megan: This is Dr. Adams. She's a social psychologist and the world's top expert on impostor syndrome.
Dr. Adams: Haha, don't be silly! There are lots of scholars who have made more significant…
Dr. Adams: …Oh my God.