Introduction
In 2008, a Google Talk delved into the intricacies of writing untestable code. While the topic may seem counterintuitive, understanding what makes code difficult to test can guide us toward better programming practices. Here’s a summary of the key points discussed.
The Intrinsic Knowledge of Writing Hard-to-Test Code
Many developers, when asked how to write untestable code, struggle to articulate it despite often encountering such code. It’s akin to a spider weaving a web instinctively. Some common characteristics of untestable code include:
- Making things private
- Using the final keyword extensively
- Long, monolithic methods
- Non-deterministic behavior
- Mixing business logic with the new operator
- Performing work in constructors
- Global state and singletons
- Static methods
These practices intertwine dependencies and state, making isolation for testing challenging.
The Real Issues of Unit Testing
The heart of the problem lies in separating business logic from object creation and global state. Here are some primary issues:
- Mixing the new operator with business logic: This practice embeds dependencies directly into your code, making it hard to isolate components for testing.
- Global state and singletons: These introduce non-deterministic behavior, leading to unpredictable test outcomes.
- Static methods: While simple leaf static methods like
Math.abs()
are easy to test, complex ones higher in the call hierarchy become problematic because they lack seams for interception.
The Challenge of Procedural Programming and Deep Inheritance
Procedural programming lacks polymorphism, making it hard to isolate code for testing. Deep inheritance hierarchies pose a similar problem; testing a class at the bottom of a hierarchy inadvertently tests all its ancestors.
The Misconception About Writing Tests
Contrary to popular belief, there’s no secret sauce to writing tests. The real secret lies in writing testable code. Most people mistakenly assume they can write code first and then test it, but this often results in untestable code.
Unit Testing as the Solution
Unit testing should be integrated from the start, with the same developers writing both the code and the tests. This ensures the code is written in a testable manner.
Levels of Testing
- Scenario Tests: Test the whole application as a unit. These tests are slow, flaky, and hard to maintain. They often involve complex setups and numerous variables, leading to unreliable results.
- Functional Tests: Test subsystems of the application with simulators for external dependencies. These are more reliable than scenario tests but still not ideal for pinpointing specific issues.
- Unit Tests: Test individual classes in isolation. They are fast, reliable, and provide clear feedback on failures. Unit tests should be the foundation of your testing strategy.
Dependency Injection and Test-Driven Development
To write testable code, embrace dependency injection. This practice separates object creation from business logic, allowing you to inject dependencies. Test-driven development (TDD) further reinforces this approach, ensuring code is written with testing in mind from the outset.
Practical Tips for Writing Testable Code
- Avoid static methods: They lack seams for interception.
- Separate object construction from business logic: Use dependency injection to manage dependencies.
- Write small, focused methods: Long methods with multiple responsibilities are hard to test.
- Limit global state and singletons: These introduce non-determinism and coupling.
- Use interfaces and polymorphism: They provide seams for injecting mocks or stubs.
Conclusion
Understanding what makes code hard to test is the first step towards writing testable code. By adopting practices like dependency injection and TDD, and focusing on unit tests, you can ensure your codebase remains maintainable and robust. Remember, the goal is not just to write tests but to write code that can be tested easily and reliably.
Leave a Reply