top of page

Designing Systems That Test Themselves

  • ShiftQuality Contributor
  • Oct 10, 2025
  • 5 min read

Most testing conversations focus on the tests: which framework, how many, what coverage percentage. This is like discussing paint colors before the house has walls. The architecture determines how testable the system is. No amount of testing discipline compensates for a design that resists verification.

A system designed for testability is not just easier to test. It is better. The same properties that make code testable — clear boundaries, explicit dependencies, separation of concerns — are the properties that make code maintainable, debuggable, and resilient. Testability is not an add-on. It is a quality signal.

This post is about designing systems where testing is a natural consequence of the architecture, not a fight against it.

Why Some Systems Resist Testing

You have seen it. A class that instantiates its own database connection. A method that calls three external APIs, processes the results, and writes to a file, all in fifty lines. A controller that contains business logic, data access, and email sending in the same function.

These systems work. They also cannot be tested without standing up the entire world around them. Want to test the business logic? You need a database, an API, and a file system. Want to verify one behavior? You trigger six side effects.

The root cause is coupling. The code has fused together concerns that should be separate. Business logic is tangled with infrastructure. Decision-making is tangled with data access. The result is a system where you cannot exercise one part without activating all the others.

This is not a testing problem. It is a design problem. And the fix happens at the architecture level, not the test level.

The Dependency Inversion Principle

The single most impactful design principle for testability is dependency inversion: depend on abstractions, not implementations.

In practice, this means a service that needs to send email does not create an SMTP client inside itself. It receives an IEmailSender interface through its constructor. The real implementation sends actual emails. The test implementation records what was sent without touching a mail server.

public class OrderService
{
    private readonly IEmailSender _emailSender;
    private readonly IOrderRepository _repository;

    public OrderService(IEmailSender emailSender, IOrderRepository repository)
    {
        _emailSender = emailSender;
        _repository = repository;
    }

    public async Task<OrderResult> PlaceOrder(Order order)
    {
        var saved = await _repository.Save(order);
        await _emailSender.SendConfirmation(order.CustomerEmail, saved.Id);
        return new OrderResult(saved.Id, success: true);
    }
}

Testing this service requires no database and no email server. You provide in-memory implementations of IOrderRepository and IEmailSender, call PlaceOrder, and verify the behavior. The test runs in milliseconds, exercises the actual business logic, and is completely isolated from infrastructure.

This is not over-engineering. It is the minimum design necessary for a system where the business logic can be verified independently of the infrastructure. The alternative — integration tests that require real databases and real email servers — is slower, flakier, and harder to maintain.

Boundaries Are the Architecture

A testable system has clear boundaries between components. Each boundary defines what crosses it — data in, data out — and hides how the work is done behind the boundary.

Think of boundaries in three layers:

The domain layer contains business rules and logic. It has no dependencies on frameworks, databases, or external services. It is pure logic. This layer is trivially testable because it depends on nothing.

The application layer orchestrates workflows. It calls domain logic, coordinates between services, and manages transactions. It depends on abstractions of infrastructure — interfaces, not implementations.

The infrastructure layer implements the abstractions. Database access, HTTP clients, file I/O, message queues. This is the layer that talks to the outside world.

When these layers are properly separated, you can test the domain layer with simple unit tests, the application layer with injected fakes, and the infrastructure layer with targeted integration tests. Each layer is tested at the appropriate level of isolation, and the full system is tested end-to-end at the highest level.

When these layers are mixed — when a domain object talks to a database, or a controller contains business rules — the boundaries dissolve, and testing requires the entire stack for every scenario.

Design Patterns That Help

Several well-known patterns exist specifically to improve testability. They are not academic exercises. They are engineering solutions to the problem of verifying complex behavior.

Repository pattern isolates data access behind an interface. Business logic asks the repository for data and never knows whether it came from a database, an in-memory collection, or a flat file. Tests provide in-memory repositories that behave identically to the real thing, minus the infrastructure.

Strategy pattern extracts decision-making into interchangeable implementations. A pricing service might use different discount strategies depending on the customer type. Each strategy is independently testable. The service that selects and applies strategies is independently testable. The combinations are testable.

Event-driven architecture decouples producers from consumers. A service publishes an event ("order placed") without knowing who handles it. The handler ("send confirmation email") operates independently. Each side is testable in isolation. The integration — does publishing an event trigger the handler? — is a narrow integration test.

None of these patterns exist for testability alone. They also improve maintainability, flexibility, and clarity. Testability is a side effect of good design.

The Test Pyramid Follows the Architecture

The testing pyramid — many unit tests, fewer integration tests, fewer end-to-end tests — is a description of what naturally happens when the architecture supports testability.

In a well-designed system, the domain layer is large and rich with logic. It produces many fast, isolated unit tests. The application layer coordinates between a handful of components. It produces a moderate number of integration tests. The full system has a few critical user paths. Those produce a small number of end-to-end tests.

When teams report an inverted pyramid — many E2E tests, few unit tests — it is almost always an architecture problem, not a discipline problem. The team writes E2E tests because the components cannot be tested in isolation. They cannot be tested in isolation because the boundaries are not clean.

Fixing the pyramid means fixing the architecture. Once the boundaries are clean, the unit tests write themselves.

The Takeaway

Testability is a design property. You do not achieve it by writing more tests. You achieve it by designing systems with clear boundaries, explicit dependencies, and separated concerns.

The effort invested in testable architecture pays dividends beyond testing: the system is easier to understand, easier to change, and easier to debug. The test suite is faster, more reliable, and more focused. The team spends less time fighting the code and more time building features.

Design for testability first. The tests follow.

Next in the "Quality Architecture" learning path: We'll cover observability by design — building systems that tell you what they are doing, why they are doing it, and when something goes wrong, without bolting on monitoring after the fact.

Comments


bottom of page