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.
đĄ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.
đĄ `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.
đĄ 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.
đĄ 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.
Comments powered by Disqus.