
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:
- Evaluate the testability of a software module using the concepts of observability and controllability
- Explain Hexagonal Architecture (Ports and Adapters) and its relationship to testability
- Describe properties of good test suites: fast, deterministic, independent, readable
- 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

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)

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.

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.
- Start with the business logic you're trying to implement
- When you notice it needs something from the outside world, define an interface
- The interface describes what you need, not how to get it
- 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 Type | How Hexagonal Architecture Addresses It |
|---|---|
| Data | Ports pass only the data needed—getCurrentPricePerKWh() returns a double, not HttpResponse |
| Stamp | Domain types (EnergyPreferences) are defined by the domain, not dictated by external APIs |
| Control | Adapters don't pass flags that control domain logic |
| Common | No shared global state between domain and infrastructure |
| Content | Impossible—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 Principle | Hexagonal Application |
|---|---|
| Modules have well-defined interfaces | Ports are interfaces defined by domain |
| Implementation is hidden | Adapters hide technology details |
| Modules don't depend on other modules' internals | Domain 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 Principle | Testability 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:
| Goal | Tension with Testability |
|---|---|
| Simplicity | Testability may require more interfaces, abstractions, and indirection |
| Encapsulation | Observability sometimes requires exposing internal state |
| Performance | Dependency injection adds indirection |
| Reduced Boilerplate | Constructor 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
| Property | What It Means | How Hexagonal Enables It |
|---|---|---|
| Fast | Tests run in milliseconds | Domain tests have no I/O |
| Deterministic | Same result every time | Injectable Clock, Random, etc. |
| Independent | Tests don't affect each other | No shared infrastructure state |
| Readable | Tests document behavior | Given/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-Pattern | Problem | Solution |
|---|---|---|
| Static methods | Can't substitute | Wrap in injectable interface |
| Singletons | Global state, can't replace | Inject dependency instead |
| Private methods wanting tests | Class doing too much | Extract to own class |
| Complex booleans | Too many branches | Extract 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.