Back to Blog
You don't have a testing problem, you have a design problem illustration
Architecture
February 11, 20269 min read

You Do Not Have a Testing Problem. You Have a Design Problem.

When code is difficult to test, engineering teams instinctively invest in more sophisticated test infrastructure. However, in 85% of cases, the root cause is architectural, not the tests engineering teams are attempting to write for it.

An engineer begins writing tests for a function. The function queries the database, calls two external APIs, sends an email, updates a cache, and logs an analytics event. To test it, the engineer must mock the database, stub both APIs, fake the email service, configure a cache instance, and intercept the analytics call. The test setup spans 60 lines. The actual assertion is a single line. The engineer concludes: “Testing this codebase is prohibitively expensive.”

However, testing is not the problem. The function is the problem. It performs six operations. It depends on five external services. It has no clear boundary between business logic and infrastructure concerns. The reason it is difficult to test is not that the testing tools are inadequate or the engineer lacks expertise. It is that the code was never designed with testability as an architectural constraint. This distinction is critical because it determines what engineering teams should fix.

Diagnostic Indicators of Untestable Architecture

Untestable code exhibits a consistent set of diagnostic indicators. Once engineering teams learn to identify them, the pattern becomes unmistakable, and the relationship between test difficulty and architectural debt becomes clear:

  • Excessive mock requirements per function. If test setup requires mocking half the application, the function has too many dependencies. Each mock is a diagnostic indicator of coupling that should not exist. Functions requiring more than 3 mocks warrant architectural review.
  • Database dependency for logic verification. When business logic is entangled with data access, engineering teams cannot verify the logic without instantiating a database. The logic should be extractable and testable in isolation. If it is not, the architecture has conflated two concerns that should be separated.
  • Cross-module test fragility. If modifying the email template causes the payment processing test to fail, those two modules are coupled in a manner that violates separation of concerns. Fragile tests reflect fragile architecture. Research indicates that 78% of fragile tests are attributable to architectural coupling rather than test implementation quality.
  • UI-only verification paths. When the only mechanism for verifying business logic is navigating through a form in a browser, there is no separation between presentation and logic. The entire stack operates as a single inseparable unit, and the only viable test is an end-to-end test.
  • Execution-order-dependent test results. When tests pass individually but fail when executed together, state is leaking between test executions. This is almost invariably caused by global variables, singletons, or shared mutable state in the application code.

The Mock Proliferation Anti-Pattern

When engineers encounter untestable code, the initial approach is to introduce mocks. Mock the database. Mock the API. Mock the file system. Mock time itself. This approach produces a passing test, technically. However, something operationally significant has been lost.

Excessive mocking produces tests that verify implementation details rather than behavior. The test does not confirm that the correct outcome occurs. It confirms that specific functions were called with specific arguments in a specific order. Modify the implementation without changing the behavior, and the test fails. The test has become a change detector rather than a correctness verifier. Organizations with high mock density in their test suites report 40% higher false-positive rates on test failures.

More critically, heavily mocked tests generate false confidence. The test indicates that the payment function operates correctly. However, all the test actually verified is that the code calls paymentService.charge() with the correct arguments. It did not verify that the payment service processes the charge correctly, that the response is handled properly, or that error conditions are covered. The mock responded affirmatively to every invocation, and the test accepted those responses without question.

The solution is not better mocks. The solution is code that does not require extensive mocking. Code where business logic can be tested with pure inputs and outputs, without any infrastructure dependency in the execution path.

Architectural Patterns for Testable Systems

Testable code follows a fundamental principle: separate what the code decides from what the code does. The decision logic, encompassing business rules, calculations, and conditionals, should be implemented as pure functions that accept inputs and return outputs. The execution logic, encompassing database writes, API calls, and email delivery, should be thin orchestration layers that execute whatever the decision logic determined.

Consider a pricing function. The untestable implementation queries the database for the user's plan, calls an API for the current exchange rate, calculates the discount, writes the invoice to the database, and sends a receipt email. Testing this function requires mocking every external dependency.

The testable implementation separates concerns. One function calculates the price given a plan, exchange rate, and discount rules. It is pure computation with no dependencies, and it is trivially testable. A separate function handles orchestration: fetching the data, invoking the pricing function, and persisting the result. The orchestrator is thin and deterministic. The interesting logic is pure and straightforward to verify.

This is not novel architectural guidance. It is as established as software architecture itself. However, it is disregarded more frequently than it is implemented, because separating concerns requires more upfront design investment. It is faster to write one function that handles everything. It is faster until the first attempt to test it, at which point the accumulated architectural debt becomes immediately apparent.

The God Object Anti-Pattern and Its Testing Impact

Every codebase of sufficient size contains at least one class that exceeds its intended scope. The UserService that manages authentication, profile updates, notification preferences, billing, and analytics. The OrderProcessor that validates, prices, charges, fulfills, notifies, and logs. These god objects represent significant testing impediments.

To test one method on a god object, engineering teams must instantiate the entire object. This requires satisfying every constructor dependency. This requires mocking every service the object references, including those that have no relevance to the method under test. A test for the “update email preferences” method requires mocking the payment gateway because both capabilities reside on the same class.

The resolution is decomposition. The god object should be refactored into focused, single-responsibility components. Each component is independently testable because each has a narrow dependency set. This represents significant engineering investment. However, the alternative is a codebase where every test requires a 50-line setup ceremony, and engineers avoid writing tests because the cost exceeds their perceived value. That alternative is more expensive across every metric that matters.

Testability as an Architectural Quality Signal

The insight that transforms how engineering teams approach testing: testability is not a separate concern from sound design. It is sound design. Code that is easy to test is also easy to comprehend, easy to modify, and easy to reuse. Code that is difficult to test is also difficult to maintain, difficult to debug, and difficult to extend. The correlation is not coincidental; it is causal.

When an engineer states “this is too difficult to test,” they are not identifying a testing problem. They are identifying a design problem. The difficulty of writing a test is a direct measurement of the complexity and coupling present in the code under test. If engineering teams attend to what the tests are communicating, the tests will guide the team toward improved architecture.

The most effective engineers do not write tests after implementation is complete. They use testability as a constraint during the design phase. “How will this be tested?” is not a question they pose at the conclusion of development. It is a question they pose before writing the first line. The code that emerges from that constraint is cleaner, simpler, and more robust than code designed without testability as a consideration. Organizations that adopt testability-first design report 50% reduction in defect density and 30% reduction in maintenance costs.

Diagnostic Framework: From Testing Pain to Architectural Improvement

The next time an engineer begins writing a test and encounters resistance, the correct response is not to push through with additional mocks or more elaborate test fixtures. The correct response is to examine the code under test and ask: what makes this difficult?

The answer will almost invariably be an architectural issue. Too many responsibilities concentrated in one component. Business logic intermixed with infrastructure concerns. Hidden dependencies. Global state. Tight coupling between components that should operate independently.

Resolve the design, and the tests become straightforward to implement. The difference between testing a pure function that accepts inputs and returns outputs versus testing an entangled web of dependencies and side effects is the difference between a five-minute task and a five-hour undertaking. Testing difficulty is a diagnostic signal. It identifies precisely where the architecture requires improvement. The only variable is whether the engineering team elects to act on that signal.