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, CC-BY-SA

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 void

⚠️ 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)

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;
}

❌ Left: Dependencies created inside methods — can't substitute them

✓ Right: Dependencies injected via constructor — tests provide stubs

💡 This is Dependency Inversion Principle (L8) in action!

Observability: Return Values vs Side Effects

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

// Low Observability: modifies external state, returns void
public class DeviceHealthChecker {
private final DeviceRepository repo;

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

// High Observability: returns result, no side effects on input
public class DeviceHealthChecker {
public HealthReport checkDevice(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. 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.

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 SmartThermostat {
public void adjustForComfort(String zoneId, double targetTemp) {
// Infrastructure: database access
Connection conn = DriverManager.getConnection(
"jdbc:mysql://localhost/iot", "root", "");
PreparedStatement ps = conn.prepareStatement(
"SELECT temperature FROM sensors WHERE zone_id = ?");
ps.setString(1, zoneId);
ResultSet rs = ps.executeQuery();
rs.next();
double currentTemp = rs.getDouble("temperature");

// Domain: business logic (buried in the middle!)
double delta = targetTemp - currentTemp;
String action = Math.abs(delta) <= 0.5 ? "NONE"
: delta > 0 ? "HEAT" : "COOL";

// Infrastructure: web service call
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://hvac-service/zones/" + zoneId + "/" + action))
.POST(HttpRequest.BodyPublishers.noBody()).build();
client.send(request, HttpResponse.BodyHandlers.discarding());
}
}

Hexagonal Architecture (Ports and Adapters)

The solution is formalized as Hexagonal Architecture, proposed by Alistair Cockburn in 2005.

Hexagonal Architecture: Central hexagon 'Application Core' with ports (interfaces) on edges. Outside, adapters (DatabaseAdapter, APIAdapter, TestAdapter) plug into ports and connect to external systems. Adapters are swappable without changing the core.

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.

(Ironically, we'll visualize this left-to-right in diagrams because that's easier to draw. The hexagon shape is conceptual, not literal.)

The Three Layers

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: Know how 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);
}

// Port: How we get user preferences for a home
public interface UserPreferencesPort {
EnergyPreferences getPreferences(String homeId);
}

Notice: Single-method interfaces can be implemented with lambdas in tests!

The Domain Uses Ports, Not Technologies

public class EnergyOptimizer {
private final EnergyPricePort priceService;
private final DeviceControlPort deviceControl;
private final UserPreferencesPort preferences;

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

public void optimizeForPrice(String homeId) {
double currentPrice = priceService.getCurrentPricePerKWh();
EnergyPreferences prefs = preferences.getPreferences(homeId);

if (currentPrice > prefs.highPriceThreshold()) {
reduceNonEssentialDevices();
} else if (currentPrice < prefs.lowPriceThreshold()) {
preconditionHome();
}
}
}

✓ 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
}
}

// Adapter: Controls devices via Zigbee protocol
public class ZigbeeDeviceAdapter implements DeviceControlPort {
private final ZigbeeGateway gateway;

@Override
public void setDevicePower(String deviceId, int powerPercent) {
// Zigbee protocol commands
}
}

Adapters know the technology. Domain says what it needs; adapters know how to get it.

Testing with Hexagonal Architecture

@Test
void reducesNonEssentialDevicesWhenPriceIsHigh() {
// Simple in-memory implementations — no real infrastructure!
EnergyPricePort stubPrices = () -> 0.35; // High price (lambda!)

List<ControllableDevice> devices = List.of(
new ControllableDevice("light-1", true, 100), // non-essential
new ControllableDevice("fridge", false, 100) // essential
);
SpyDeviceControl spyDevices = new SpyDeviceControl(devices);

UserPreferencesPort stubPrefs = (homeId) ->
new EnergyPreferences(0.25, 0.10); // high=0.25, low=0.10

EnergyOptimizer optimizer = new EnergyOptimizer(
stubPrices, spyDevices, stubPrefs);

optimizer.optimizeForPrice("home-123");

// Verify only non-essential devices were reduced
assertEquals(50, spyDevices.getPowerLevel("light-1"));
assertEquals(100, spyDevices.getPowerLevel("fridge")); // unchanged
}

Extracted Domain Logic is Trivially Testable

// Domain: pure business logic, easily testable
public class ComfortCalculator {
public HVACAction calculateAction(double currentTemp, double targetTemp) {
double delta = targetTemp - currentTemp;
if (Math.abs(delta) <= 0.5) {
return HVACAction.NONE;
} else if (delta > 0) {
return HVACAction.HEAT;
} else {
return HVACAction.COOL;
}
}
}
@Test void heatsWhenBelowTarget() {
ComfortCalculator calc = new ComfortCalculator();
assertEquals(HVACAction.HEAT, calc.calculateAction(68.0, 72.0));
}

@Test void coolsWhenAboveTarget() {
ComfortCalculator calc = new ComfortCalculator();
assertEquals(HVACAction.COOL, calc.calculateAction(76.0, 72.0));
}

@Test void doesNothingWithinThreshold() {
ComfortCalculator calc = new ComfortCalculator();
assertEquals(HVACAction.NONE, calc.calculateAction(72.3, 72.0));
}

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.

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();
}
}
}

Testable

public class ScheduledTask {
private final BusinessHoursChecker
hoursChecker;

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

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

Wrapping Static Methods

Java provides Clock for time, but what about System.getenv()? You can't substitute a static method call.

The Problem

public class ApiClient {
public void connect() {
// Can't substitute this in tests!
String key = System.getenv("API_KEY");
// ...
}
}

The Solution: Wrap It

public interface EnvironmentProvider {
String getEnv(String name);
}

public class ApiClient {
private final EnvironmentProvider env;

public void connect() {
String key = env.getEnv("API_KEY");
}
}

Even better: a higher-level CredentialProvider that hides how credentials are obtained:

public interface CredentialProvider {
String getApiKey(); // Domain concept, not "getEnv"
}
// Production: reads from env vars, secrets manager, or config file
// Tests: returns a fixed test key

Anti-Pattern: Singletons

Singletons are globally accessible state that can't be replaced per-test.

// Singleton - only one instance, globally accessible
public class DatabaseConnection {
private static DatabaseConnection instance;

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

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

⚠ Every test uses the same connection. State leaks between tests.

Anti-Pattern: Private Methods That Want Testing

If you feel the urge to test a private method directly, it's often a sign that it should be 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;
}

// Complex logic we want to test!
private HealthStatus assessHealth(
IoTDevice device) {
// Battery, signal, contact time...
// Many branches 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;
}
// Each predicate tested individually

Breaking conditions into named methods doesn't reduce complexity, but it spreads out the testing burden and improves 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.