
CS 3100: Program Design and Implementation II
Lecture 16: Designing for Testability
©2026 Jonathan Bell & Ellen Spertus, CC-BY-SA
Imposter Syndrome Reminder

Poll: How has this course made you feel?
A. stupid
B. smart
C. challenged
D. scared
E. proud
F. overwhelmed
G. knowledgeable
H. humble

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

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

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

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.
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.
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.
- 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.
Complexity

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

![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.](/cs3100-public-resources/img/lectures/web/l16-xkcd-imposter-syndrome.png)