Building systems with components that can be easily tested in isolation is crucial for producing quality software. I’ve found that certain architectural patterns consistently lead to more testable code. Let me share what I’ve learned from years of implementing these patterns across various projects.
When I first started coding professionally, I often created tightly coupled systems that were nearly impossible to test properly. After many painful debugging sessions, I realized that testability isn’t an afterthought—it’s a direct outcome of good architecture.
Testability as an Architectural Concern
Writing testable code begins with understanding that testability is primarily an architectural problem. Code that’s difficult to test typically suffers from tight coupling, hidden dependencies, and poor separation of concerns.
Consider how different a typical testing experience might be with these two implementations:
// Hard-to-test implementation with concrete dependencies
public class PaymentProcessor {
private PaymentGateway gateway = new StripeGateway();
public boolean processPayment(double amount, String creditCard) {
return gateway.charge(amount, creditCard);
}
}
// Highly testable implementation with injected dependencies
public class PaymentProcessor {
private final PaymentGateway gateway;
public PaymentProcessor(PaymentGateway gateway) {
this.gateway = gateway;
}
public boolean processPayment(double amount, String creditCard) {
return gateway.charge(amount, creditCard);
}
}
The second implementation enables us to inject a test double for the PaymentGateway, while the first version makes this nearly impossible without resorting to complex reflection or bytecode manipulation.
Dependency Injection: The Foundation of Testable Code
Dependency injection is perhaps the single most important pattern for creating testable code. By providing components with their dependencies rather than having them create or find their dependencies, we create natural seams where test doubles can be inserted.
I’ve seen three main forms of dependency injection that each have their place:
- Constructor injection (shown above) - dependencies are provided when the object is created
- Method injection - dependencies are provided to specific methods
- Property injection - dependencies are set through setters
Constructor injection is generally preferred as it ensures the object is always in a valid state with all required dependencies.
// C# example with constructor injection
public class OrderService
{
private readonly IOrderRepository _repository;
private readonly IPaymentProcessor _paymentProcessor;
private readonly IEmailSender _emailSender;
public OrderService(
IOrderRepository repository,
IPaymentProcessor paymentProcessor,
IEmailSender emailSender)
{
_repository = repository;
_paymentProcessor = paymentProcessor;
_emailSender = emailSender;
}
public void PlaceOrder(Order order)
{
_repository.Save(order);
_paymentProcessor.ProcessPayment(order.Payment);
_emailSender.SendOrderConfirmation(order);
}
}
With this structure, testing the OrderService becomes straightforward:
[Fact]
public void PlaceOrder_SavesOrderAndProcessesPayment()
{
// Arrange
var mockRepository = new Mock<IOrderRepository>();
var mockPaymentProcessor = new Mock<IPaymentProcessor>();
var mockEmailSender = new Mock<IEmailSender>();
var service = new OrderService(
mockRepository.Object,
mockPaymentProcessor.Object,
mockEmailSender.Object);
var order = new Order { /* ... */ };
// Act
service.PlaceOrder(order);
// Assert
mockRepository.Verify(r => r.Save(order), Times.Once);
mockPaymentProcessor.Verify(p => p.ProcessPayment(order.Payment), Times.Once);
mockEmailSender.Verify(e => e.SendOrderConfirmation(order), Times.Once);
}
Interface Segregation for Flexible Testing
The Interface Segregation Principle (ISP) states that clients should not be forced to depend on interfaces they don’t use. This principle is particularly beneficial for testing because it allows for more precise test doubles.
Consider a repository interface that handles both reading and writing:
// Overly broad interface
public interface UserRepository {
User findById(long id);
List<User> findAll();
void save(User user);
void delete(User user);
}
When testing a service that only reads users, we have to implement or mock methods we don’t care about. By segregating interfaces, we create more focused components:
public interface UserReader {
User findById(long id);
List<User> findAll();
}
public interface UserWriter {
void save(User user);
void delete(User user);
}
public interface UserRepository extends UserReader, UserWriter {
// Combines both interfaces for implementations that do both
}
Now a service can depend only on UserReader if it only reads, making the tests clearer and more focused.
Pure Functions and Immutability
Pure functions are those that have no side effects and always return the same output for the same input. These are inherently testable because they don’t depend on or modify external state.
I’ve found that designing systems to maximize the use of pure functions significantly improves testability:
// Impure function - depends on external state
let total = 0;
function addToTotal(value) {
total += value;
return total;
}
// Pure function - all inputs are explicit, no side effects
function add(a, b) {
return a + b;
}
Similarly, immutable objects make testing more predictable because their state cannot change after creation:
// Mutable
public class Customer {
private String name;
public void setName(String name) {
this.name = name;
}
}
// Immutable
public class Customer {
private final String name;
public Customer(String name) {
this.name = name;
}
public Customer withName(String newName) {
return new Customer(newName);
}
}
The Humble Object Pattern
The Humble Object pattern separates behavior that’s hard to test from behavior that’s easy to test. This is particularly useful when dealing with UI components, database access, or external services.
For example, in a web application, we can separate the request handling logic from the business logic:
# Hard-to-test controller with business logic
class UserController:
def create_user(self, request):
# Validate input
if not request.json['email'] or not request.json['password']:
return {'error': 'Missing fields'}, 400
# Business logic
if User.query.filter_by(email=request.json['email']).first():
return {'error': 'Email already exists'}, 409
user = User(email=request.json['email'])
user.set_password(request.json['password'])
db.session.add(user)
db.session.commit()
return {'id': user.id}, 201
# Testable separation of concerns
class UserService:
def create_user(self, email, password):
if User.query.filter_by(email=email).first():
raise DuplicateEmailError()
user = User(email=email)
user.set_password(password)
db.session.add(user)
db.session.commit()
return user.id
class UserController:
def __init__(self, user_service):
self.user_service = user_service
def create_user(self, request):
# Validate input
if not request.json['email'] or not request.json['password']:
return {'error': 'Missing fields'}, 400
# Delegate to service
try:
user_id = self.user_service.create_user(
request.json['email'],
request.json['password']
)
return {'id': user_id}, 201
except DuplicateEmailError:
return {'error': 'Email already exists'}, 409
The Repository Pattern for Data Access
The Repository pattern provides an abstraction layer over data storage, making it easier to test components that need to access data without being tied to a specific database implementation.
public interface IProductRepository
{
Product GetById(int id);
IEnumerable<Product> GetAll();
void Add(Product product);
void Update(Product product);
void Delete(int id);
}
public class SqlProductRepository : IProductRepository
{
private readonly DbContext _context;
public SqlProductRepository(DbContext context)
{
_context = context;
}
public Product GetById(int id)
{
return _context.Products.Find(id);
}
// Other implementations...
}
// In tests, we can use an in-memory implementation
public class InMemoryProductRepository : IProductRepository
{
private readonly Dictionary<int, Product> _products = new Dictionary<int, Product>();
public Product GetById(int id)
{
return _products.TryGetValue(id, out var product) ? product : null;
}
// Other implementations...
}
Command Query Responsibility Segregation (CQRS)
CQRS separates operations that read data (queries) from operations that update data (commands). This separation can make testing easier because queries typically have different requirements than commands.
// Query side
public interface IOrderQueries
{
OrderDto GetOrderById(int orderId);
IEnumerable<OrderSummaryDto> GetOrdersByCustomer(int customerId);
}
// Command side
public interface IOrderCommands
{
void CreateOrder(CreateOrderCommand command);
void CancelOrder(CancelOrderCommand command);
}
With this separation, you can optimize the read and write models differently and test them independently.
Boundary Isolation with Adapters
External dependencies often represent the most challenging aspects of testing. The Adapter pattern creates a clear boundary between your application and external systems:
# Direct use of an external API
def get_weather(city):
response = requests.get(f"https://weather-api.example.com/current?city={city}")
data = response.json()
return data["temperature"]
# Using an adapter
class WeatherService:
def get_temperature(self, city):
pass # Interface
class LiveWeatherService(WeatherService):
def get_temperature(self, city):
response = requests.get(f"https://weather-api.example.com/current?city={city}")
data = response.json()
return data["temperature"]
class MockWeatherService(WeatherService):
def get_temperature(self, city):
return 72 # Fixed value for testing
This pattern is closely related to the Hexagonal Architecture (Ports and Adapters) approach, where the core application logic is insulated from external concerns.
Behavior-Driven Development and Test-First Design
I’ve found that starting with the tests can lead to more testable designs. When you think about how you want to test the code, you naturally create interfaces and structures that are more testable.
For instance, consider how you might approach developing a new feature:
// Test-first approach
describe('ShoppingCart', () => {
it('should add items to the cart', () => {
// Arrange
const cart = new ShoppingCart();
const item = { id: 1, name: 'Product', price: 10 };
// Act
cart.addItem(item);
// Assert
expect(cart.items.length).toBe(1);
expect(cart.items[0]).toEqual(item);
});
it('should calculate total price', () => {
// Arrange
const cart = new ShoppingCart();
cart.addItem({ id: 1, name: 'Product A', price: 10 });
cart.addItem({ id: 2, name: 'Product B', price: 15 });
// Act
const total = cart.calculateTotal();
// Assert
expect(total).toBe(25);
});
});
Writing these tests first forces you to think about how the ShoppingCart class should be structured and what its public API should look like, leading to a more testable design.
Inversion of Control Containers
Managing dependencies can become complex as applications grow. Inversion of Control (IoC) containers help manage this complexity while maintaining testability:
// Registering dependencies
services.AddScoped<IOrderRepository, SqlOrderRepository>();
services.AddScoped<IPaymentGateway, StripePaymentGateway>();
services.AddScoped<IOrderProcessor, OrderProcessor>();
// In production code, dependencies are resolved automatically
public class OrderController
{
private readonly IOrderProcessor _orderProcessor;
public OrderController(IOrderProcessor orderProcessor)
{
_orderProcessor = orderProcessor;
}
}
// In tests, we can override registrations
services.AddScoped<IOrderRepository, MockOrderRepository>();
services.AddScoped<IPaymentGateway, MockPaymentGateway>();
Testing Different Architectural Layers
Different layers of your application will have different testing strategies. Here’s how I approach testing various layers:
- Domain Layer - Focus on unit tests with minimal mocking
- Application Services - Test the orchestration with mocked dependencies
- Infrastructure Layer - Focus on integration tests with real dependencies
- UI Layer - Use UI-specific testing frameworks (like Selenium or Cypress)
For example, testing a domain entity might look like:
@Test
public void order_shouldCalculateCorrectTotal() {
// Arrange
Order order = new Order();
order.addLineItem(new LineItem("Product A", 2, 10.0));
order.addLineItem(new LineItem("Product B", 1, 15.0));
// Act
double total = order.calculateTotal();
// Assert
assertEquals(35.0, total, 0.001);
}
While testing an application service might involve more mocking:
@Test
public void placeOrder_shouldCreateOrderAndProcessPayment() {
// Arrange
OrderRequest request = new OrderRequest(/* ... */);
OrderRepository mockRepository = mock(OrderRepository.class);
PaymentService mockPayment = mock(PaymentService.class);
OrderService service = new OrderService(mockRepository, mockPayment);
// Act
OrderResult result = service.placeOrder(request);
// Assert
verify(mockRepository).save(any(Order.class));
verify(mockPayment).processPayment(any(PaymentDetails.class));
assertTrue(result.isSuccessful());
}
Dealing with Legacy Code
Often we need to test code that wasn’t designed with testability in mind. Several techniques can help here:
- The Strangler Fig Pattern - Gradually replace parts of the legacy system with testable components
- Seam Creation - Identify places where behavior can be modified without changing the code
- Characterization Testing - Create tests that document current behavior before making changes
// Legacy code
public class LegacyService {
private static final Database db = Database.getInstance();
public void doSomething() {
// Lots of logic with direct database calls
}
}
// Adding a seam for testing
public class LegacyServiceWithSeam {
private Database db;
public LegacyServiceWithSeam() {
this(Database.getInstance());
}
// Test seam
LegacyServiceWithSeam(Database db) {
this.db = db;
}
public void doSomething() {
// Same logic but using the injected database
}
}
Balancing Test Coverage with Development Speed
While striving for testable architecture, we must balance test coverage with development velocity. I recommend focusing on testing:
- Core business logic with high complexity
- Code that changes frequently
- Areas with a history of defects
- Public APIs and boundaries
It’s better to have thorough tests for critical components than shallow tests for everything.
Conclusion
Creating testable architecture isn’t just about making testing easier—it’s about creating systems that are more maintainable, understandable, and resilient to change. The patterns I’ve discussed—dependency injection, interface segregation, adapters, and more—all contribute to systems that can be confidently modified and extended.
By thinking about testability from the start of your design process, you’ll naturally create better architecture. The time invested in testable design is repaid many times over throughout the life of your software.
In my experience, the most successful projects are those where testability is treated as a first-class architectural concern. By applying these patterns consistently, you can build systems that are both powerful and maintainable.