Home Testable Code - Convenience vs Discipline
Post
Cancel

Testable Code - Convenience vs Discipline

🌑 Outline

In the early days of his career, Architect Weaver had one question:

Why does an experienced developer inject everything and add so many layers when it can be directly accessed as a shared resource?

Once he goes through the code to fix a bug related to Analytics not being tracked in production for the Product Detail screen, he discovers something unexpected: the ConsoleAnalyticsTracker was accidentally injected instead of the ProductionAnalyticsTracker. This caused analytics data to go missing in production.

The AnalyticsTracker is a protocol that defines how events are tracked:

1
2
3
4
// MARK: - AnalyticsTracker Protocol
protocol AnalyticsTracker {
    func track(event: String)
}

The ProductDetailViewModel depends on this tracker, but it gets it from a dependency container, like so:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// MARK: - Dependency Container
struct ProductDetailViewModelDependency {
    let resolve: () -> AnalyticsTracker
}

// MARK: - ProductDetailViewModel
class ProductDetailViewModel {
    private let analyticsTracker: AnalyticsTracker

    init(dependency: ProductDetailViewModelDependency) {
        self.analyticsTracker = dependency.resolve()
    }

    func userViewedProduct() {
        analyticsTracker.track(event: "ProductViewed")
    }
}
1
2
3
4
// ❌ Injected in production by mistake
let dependency = AnalyticsTracker {
    ConsoleAnalyticsTracker()
}

To identify the mismatch in the injected AnalyticsTracker, Weaver finds it a bit complex—he needs to trace through the dependency container and the view’s initialisation flow. He wonders if it would have been simpler to just access the analytics tracker directly wherever it was needed, like a shared instance or singleton. After all, that would have made the bug much easier to spot.

1
2
3
4
5
6
7
8
// MARK: - ProductDetailViewModel
class ProductDetailViewModel {
    private let analyticsTracker = ProductionAnalyticsTracker.shared

    func userViewedProduct() {
        analyticsTracker.track(event: "ProductViewed")
    }
}

As he grew in his career, he began to understand why the shared instance approach, though convenient, came with its pitfalls.

🌥️ Problem with Shared Instances

At first glance, using a shared instance like ProductionAnalyticsTracker.shared seemed simple and easy to debug—it required no setup, no extra wiring, and everything just worked. But as Weaver worked on more complex features, he began to notice subtle drawbacks:

    Unmockable Shared Dependency:
While executing unit tests for ProductDetailViewModel, he found analytics events were still tracked—something that shouldn’t happen. The cause was a shared tracker that couldn’t be mocked, so real events were logged, creating noisy and unreliable test results.

    One Test Breaks Another:
Further, he noticed that some analytics events weren’t being tracked at all. Curious, he investigated and discovered that in one of the test cases, trackingEnabled was set to false to simulate an opt-out scenario. Because the ProductionAnalyticsTracker was a shared instance, this flag persisted—even outside the test—silently disabling analytics tracking in other parts of the app.

    Parallel Tests Go Wrong:
Weaver noticed that some tests running in parallel would pass at times and fail at others—classic flaky behavior. The culprit, once again, was the shared state in ProductionAnalyticsTracker. While one test was mutating the tracker’s state, another was trying to use it, leading to unpredictable results..

    Hard to Extend:
Later, when a requirement came to integrate a new analytics tool alongside the existing one, the current setup proved extremely rigid. Since ProductionAnalyticsTracker.shared was hardcoded and used throughout the app, adding or swapping an implementation meant making sweeping changes across many files.

After experiencing so many hidden pitfalls of shared state, Weaver realized that the codebase needed a new direction—one that offered testability, flexibility, and control, yet remained simple at its core.

“Convenience in the present can come at a hidden cost in the long run.”

🌜 Designing for Testability

When Weaver realized all these problems with shared state, he finally understood why his seniors always wrote layered code that sometimes felt tough to follow. The purpose was clear: separation of concerns, flexible dependency injection, and above all, reliable testing. That’s when he began focusing on designing for testability—not as an afterthought, but as a core design principle.

Over time, he developed a set of principles that made his code naturally testable:

    Prefer Dependency Injection:
A module often relies on multiple dependencies. Instead of creating or accessing them directly, Weaver began injecting these dependencies. This decouples the code, allowing real services to be swapped with mocks during testing and improving both testability and maintainability, as each component can evolve independently.

Not Testable
 class ProductDetailViewModel {
   let tracker = FirebaseTracker()
 }
Testable
 
 class ProductDetailViewModel {
   let tracker: FirebaseTracker
   init(tracker: FirebaseTracker) { 
      self.tracker = tracker
   }
 }

💡By injecting FirebaseTracker, Weaver decoupled the dependency from internal creation. Although still tied to a concrete class, this allowed controlled testing, and he is still working toward fully enabling mocks and swappable implementations.

    Use Protocols to Abstract Dependencies:
Weaver realized that relying directly on a concrete class locked his code into a specific implementation, making testing and reuse harder. By designing against protocols (interfaces), he could substitute real implementations with mocks or fakes during tests, improving flexibility and testability.

Not Testable
 class ProductDetailViewModel {
   let tracker: FirebaseTracker
   init(tracker: FirebaseTracker) { 
      self.tracker = tracker
   }
Testable
 protocol EventTracker { 
   func sendAnalytics(_ data: String) 
 }

 struct FirebaseTracker:EventTracker {
   func sendAnalytics(_ data: String) {
      // Firebase implementation
   }
 }
 class ProductDetailViewModel {
   let tracker: EventTracker
   init(tracker: EventTracker) {
     self.tracker = tracker
   }
 }
 // Usage
 let tracker = FirebaseTracker()
 let viewModel = 
     ProductDetailViewModel(tracker: tracker)

💡 `EventTracker` protocol allows injecting `FirebaseTracker` or a `MockTracker`, isolating the ViewModel behaviour for testing. Protocols give the power of substitution. Weaver could now inject a MockTracker during tests and isolate behavior easily/

    Design for Small,Independent Units:
Weaver soon realised that large classes like ProductDetailViewModel trying to do too much were harder to test, read, and change. Breaking them into focused units—each handling one responsibility—made the system more flexible. These smaller pieces could be tested in isolation, reused, and composed like building blocks, leading to better structure and long-term agility.

Not Testable
 class ProductDetailViewModel {
    let productService = ProductService()
    let addCartService = AddCartService()
    
    let productDatabase = ProductDatabase()
    let cartDatabase = CartDatabase()

    func loadProduct() { /* ... */ }
    private func saveProduct() { /* ... */ }
    func addCart() { /* ... */ }
 }
Testable
 protocol ProductRepository {
    func getProduct() -> Product
    //save is internal to the repository
 }

 protocol CartRepository {
    func add(product: Product)
 }
 // ViewModel with injected repositories
 class ProductDetailViewModel {
    let productRepository: ProductRepository
    let cartRepository: CartRepository
    init(productRepository: ProductRepository, 
	      cartRepository: CartRepository) {
   }
 }

💡 Here, ProductDetailViewModel delegates product and cart operations to ProductRepository and CartRepository. This keeps responsibilities separate, makes each part easier to test in isolation, allows swapping implementations without touching the ViewModel, and improves maintainability and clarity.

    Reuse via Composition Over Inheritance:
Weaver once extended base classes to share logic — it felt efficient at first. Over time, however, small changes in those base classes began breaking subclasses in unexpected ways. The inheritance hierarchy grew tight and rigid. He realised that inheritance works best for modelling is-a relationships, but for code reuse and flexibility, composition often serves better.

Not Testable
 class EventTracker { 
   func track(event: String) { 
     // Firebase logic 
   }
 }

 class ProductDetailViewModel: EventTracker {
   func didTapBuy() {
     track(event: "buy_clicked")
   }
 }
Testable
 protocol EventTracker { 
   func sendAnalytics(_ data: String) 
 }

 struct FirebaseTracker: EventTracker {
   func sendAnalytics(_ data: String) {
      // Firebase implementation
   }
 }

 class ProductDetailViewModel {
   let tracker: EventTracker //Composition
   
   init(tracker: EventTracker) {
     self.tracker = tracker
   }
   
   func didTapBuy() {
     tracker.sendAnalytics("buy_clicked")
   }
 }

💡 ProductDetailViewModel now composes a EventTracker instead of inheriting from it. With the inheritance approach, the inherited EventTracker was impossible to swap, but now mocks can be injected, enabling flexible reuse and testability.

These four principles lay the groundwork for creating software that’s easier to test and maintain. But testability is not just about making tests possible—it’s about building a stronger, more adaptable foundation for your codebase.

🌑 Beyond Tests: A Foundation for Better Code

Over time, Weaver observed that these principles didn’t just make his code easier to test—they made the entire codebase clearer, more modular, and easier to maintain. Refactoring became far less risky.
For example, when a customer requested replacing Firebase Analytics with Adobe Analytics, the change was straightforward. Weaver created a new AdobeTracker struct that implemented the existing EventTracker interface and injected it into the relevant modules.
Thanks to the clear abstractions and dependency injection already in place, he only needed to add the new analytics adapter and update the configuration—without touching any core business logic. In striving for testability, he had unintentionally built software that was adaptable, sustainable, and ready for growth.

“Well-tested code is well-crafted code.”

🧘 Final Thoughts

Architect Weaver believes that designing for testability is like building a house on a strong foundation—future earthquakes (changes) won’t shake it apart. It saves you from the late-night bug hunts, makes refactoring less risky, and ensures that adding new features will not impact other parts of the code.While these first four principles laid the groundwork, there’s more to explore. In Part 2, Weaver will dive into additional principles that make code even more flexible, maintainable, and resilient.

This post is licensed under CC BY 4.0 by the author.

Premature Abstraction in Software Design

-

Comments powered by Disqus.