When embarking on unit testing, developers often encounter challenges in testing code interacting with system boundaries. These boundaries, crucial interfaces for data input and output, frequently involve external dependencies like command-line interfaces or HTTP APIs. The initial hurdle lies in devising tests that circumvent direct service usage, aiming for decoupled tests free from external intricacies. Ignoring these boundary interactions in unit tests introduces significant risks, diminishes software flexibility, and escalates future modification costs. This oversight impacts not only the application’s reliability but also its adaptability to evolving business needs.
Initially, mocking seems like a viable solution. However, employing mocking frameworks can still present difficulties, especially when examples lean towards mocking method calls of external API interaction libraries. This approach necessitates deep knowledge of library internals and tightly couples code to library specifics. Consequently, swapping external services becomes cumbersome, violating SOLID principles and failing to address core business concerns.
The root of these issues often stems from the abundance of resources on why to unit test, overshadowing guidance on how to unit test effectively, particularly at system boundaries. This article aims to clarify the concept of boundary code, emphasize the advantages of boundary-aware design for testability, pinpoint areas for unit test improvement, and offer practical examples in Python and Java. By focusing on these critical areas, developers can enhance their approach to unit testing, ensuring more robust and maintainable codebases.
What is Boundary Code?
Boundary code encompasses any system component interacting with the external world, handling both input reception and output transmission. Drawing from Hexagonal Architecture, any component acting as a “port” is considered a boundary. These boundaries fall into two primary categories:
-
External Systems: Interactions with external systems are readily identifiable as boundary components. Examples include databases, file systems, REST APIs, and hardware interfaces.
-
Standard Libraries and Packages: Less obvious but equally critical are standard libraries and packages. While designed to simplify development with functionalities like filesystem I/O, console I/O, and HTTP clients, these are inherently boundary components due to their role in managing input and output operations.
Alt text: Diagram illustrating Hexagonal Architecture, emphasizing ports and adapters as key elements defining system boundaries for input and output.
Understanding these categories is the first step in effectively managing boundary cases in coding. Recognizing both explicit external system interactions and implicit standard library usages as boundaries allows for a more comprehensive approach to unit testing and system design.
Why Test Boundary Code?
Neglecting to properly test code at system boundaries leads to several detrimental consequences that can significantly impact software development and maintenance:
-
Deferred Decision Making Becomes Impossible: Without tested boundaries, early design choices become rigid. The inability to easily test and modify boundary interactions restricts the evolution of the system in response to new requirements or changing environments.
-
Increased Risk: Untested boundary code is a major source of potential failures. These interfaces are critical points of interaction with the outside world, and lack of testing increases the likelihood of unexpected issues arising in production, leading to system instability and data corruption.
-
Reduced Flexibility: Code tightly coupled to external systems or standard libraries without proper abstraction becomes inflexible. Adapting to new technologies, switching external services, or even upgrading libraries becomes a complex and risky undertaking.
-
Elevated Cost of Changes: The accumulation of risk and reduced flexibility directly translates to higher costs when changes are necessary. Debugging issues in untested boundary code is time-consuming, and modifying tightly coupled systems can trigger cascading changes throughout the codebase, making maintenance expensive and error-prone.
Effective testing of code boundaries is not just about verifying functionality; it’s about enabling agility, reducing long-term costs, and ensuring the software can adapt to future demands. As the saying goes, “take care of the edges, and the rest takes care of itself.” In software, these ‘edges’ are the boundaries, and their robustness dictates the overall health and maintainability of the system.
How Can Unit Testing at Code Boundaries Be Improved?
The key to improving unit testing at code boundaries, whether dealing with external systems or standard libraries, lies in employing design patterns that promote decoupling and testability. The Adapter Pattern and the Humble Object test pattern are particularly effective in pushing the complexity of boundary interactions to the system’s periphery, making core logic easier to test.
Unit Testing Code Boundaries with External Systems
Testing code that interacts with external systems presents unique challenges due to the non-deterministic nature of these services. External systems introduce side effects, which are inherently at odds with the deterministic principles of unit testing. Direct interaction with external APIs in unit tests leads to brittle tests that are slow, unreliable, and dependent on external infrastructure.
The solution is to introduce an Adapter – an interface that abstracts the external system’s specifics and provides a simplified, testable interface for the application code. This adapter wraps the complexities of interacting with the external service, offering methods that represent the functionalities needed by the application. The actual implementation of this interface then encapsulates the calls to the real external API. For testing purposes, mock implementations of the adapter can be created to simulate various scenarios without relying on the actual external system.
Consider an application that downloads CSV files from Amazon S3. The code might initially look like this:
import io
import os
import boto3
def download_csv(filename):
s3_client = boto3.session.Session().client(
service_name='s3',
endpoint_url=os.environ['S3_URL']
)
with io.BytesIO() as data:
s3_client.download_fileobj(
os.environ['BUCKET_NAME'], filename, data
)
return io.StringIO(data.getvalue().decode('UTF-8'))
Alt text: Code snippet in Python showing direct interaction with Amazon S3 to download a CSV file, lacking abstraction for testability.
This direct approach tightly couples the application code to the S3 API, making unit testing difficult. To enhance testability and prepare for potential service migrations (e.g., to Microsoft Azure), the Adapter pattern can be applied:
import io
import os
import boto3
class S3Repository(object):
def download_csv(self, filename):
s3_client = boto3.session.Session().client(
service_name='s3',
endpoint_url=os.environ['S3_URL']
)
with io.BytesIO() as data:
s3_client.download_fileobj(
os.environ['BUCKET_NAME'], filename, data
)
return io.StringIO(data.getvalue().decode('UTF-8'))
Alt text: Python code example showcasing the S3Repository class, an adapter encapsulating S3 interaction logic to improve testability.
The download_csv()
function is now a method within the S3Repository
class, acting as the Adapter. The complexity of S3 interaction is encapsulated within this class, transforming clients of this class into Humble Objects. During tests, a mock S3Repository
can be substituted for the real one, decoupling tests from the actual S3 service:
import io
def example(filename, repository):
return repository.download_csv(filename)
class TestRequiringS3Repository(object):
def test_example(self):
repository = MockS3Repository()
csv = example("filename.csv", repository)
assert csv.read() == "Some,Test,Datan1,2,3"
assert repository.download_csv_called_with_filename == "filename.csv"
class MockS3Repository(object):
def __init__(self):
self.download_csv_called_with_filename = ""
def download_csv(self, filename):
self.download_csv_called_with_filename = filename
return io.StringIO("Some,Test,Datan1,2,3")
Alt text: Python unit test example using MockS3Repository to isolate testing from actual Amazon S3, demonstrating testability improvement.
This approach decouples unit tests from boto3 and Amazon S3, allowing behavior testing without needing S3 configurations. Different mock implementations can simulate various scenarios, such as file not found or network issues. When migrating to Azure, only a new adapter implementation is required, minimizing code changes. The actual S3 interaction code within S3Repository
should be tested through integration tests, verifying its functionality in at least one real-world scenario, not every possible edge case.
Unit Testing Code Boundaries with Standard Libraries
The same principles apply when testing code using standard libraries for I/O operations. Although standard library APIs are generally stable, wrapping them with adapters significantly simplifies boundary testing.
Consider a simple Java application that prompts for a user’s name and counts the letters:
@Test
void tellsAshleyHeHasSixLettersInHisName() {
MockConsole console = new MockConsole("Ashley");
CliApp app = new CliApp(console);
app.run();
assertTrue(console.displayedMessageWas("Ashley has 6 letters"));
}
Alt text: Java unit test example for a console application, utilizing MockConsole to simulate user input and verify output messages.
To achieve this testability, a Console
interface is introduced as an Adapter for Java’s I/O functionalities, making it a Humble Object:
public interface Console {
String getName();
void displayMessage(String message);
}
public class MockConsole implements Console {
private String input;
private String message;
public MockConsole(String input) {
this.input = input;
}
@Override
public String getName() {
return input;
}
@Override
public void displayMessage(String message) {
this.message = message;
}
public boolean displayedMessageWas(String expectedMessage) {
return message.equals(expectedMessage);
}
}
Alt text: Java code snippet defining the Console interface and its MockConsole implementation, abstracting console I/O for testability.
The application logic, now decoupled from direct I/O operations, becomes:
public class CliApp {
private Console console;
public CliApp(Console console) {
this.console = console;
}
public void run() {
String name = console.getName();
int numberOfLetters = name.length();
console.displayMessage(String.format("Ashley has %d letters", numberOfLetters));
}
}
Alt text: Java CliApp code example demonstrating the use of the Console interface, decoupling application logic from concrete console implementations.
By using the Console
interface, the application logic is isolated from specific I/O libraries, resulting in more robust tests and greater flexibility. While this example is simple, the power of adapters becomes evident when dealing with more complex standard libraries like sockets, where abstraction can significantly simplify testing and improve code maintainability.
Key Takeaways for Boundary Case Coding
Mastering unit testing, particularly at code boundaries, is crucial for developers. Improving how we handle boundary code through unit testing enhances software quality significantly. By adopting the Adapter and Humble Object patterns, we design software that is inherently more testable, leading to reduced risk, increased flexibility, and lower costs of change. This approach not only improves the immediate quality of code but also contributes to the long-term maintainability and adaptability of software systems. Embracing these practices is essential for building robust and resilient applications that can stand the test of time and evolving requirements.