Detroit vs London schools of unit testing
Are you a classicist or a mockist when it comes to unit testing?
Unit tests are designed to verify a single unit of behavior in the most isolated condition possible. This isolation ensures that the test does not depend on any external factors and solely verifies the unit's behavior.
But what exactly is a unit?
The definition of a "unit" has been such a polarizing question, that it led to the formation of two schools of unit testing:
Detroit (Classicist) school
London (Mockist) school
So what’s the difference?
The Detroit school (classicist) argues that
Unit tests themselves need to be isolated, not necessarily the units.
A unit under test is more about a unit of behavior, rather than just code.
Only shared dependencies (those which can make tests affect each other) should be replaced with test doubles.
The London school (Mockist) believes that
Units under test (often classes) should be isolated from each other.
All dependencies should be replaced with test doubles (like mocks or stubs) in tests (except for immutable dependencies).
Deep-dive comparison
Classicist (Detroit School)
State-Based Testing
Focuses on the state of the application after a unit's execution, verifying outcomes based on the input and the final state.
This approach is less about how the result is achieved and more about ensuring the end state is correct.
Integration of Collaborators
Rather than mocking dependencies, favors using real instances of objects or in-memory test doubles, aiming to test units in a context that closely mirrors how they operate within the full application.
This can lead to tests that are more integrative and less isolated.
Refactoring Flexibility
A hallmark of the Classicist approach is its emphasis on refactoring.
Since tests are not tightly coupled to the implementation details, developers can refactor code with confidence as long as the external behavior remains unchanged.
Critiques of Mocking
Detroit school argues that excessive mocking can lead to brittle tests that are overly tied to the implementation details, potentially leading to false positives and hindering refactoring efforts.
Mockist (London School)
Interaction-Based Testing
Prioritizes testing every class interaction meticulously, using mocks extensively to ensure that objects communicate correctly with their dependencies.
This approach focuses on the pathways through the code rather than the final state.
Isolation
By mocking dependencies, it aims to isolate the unit under test, ensuring that failures are localized to the unit itself rather than being influenced by external factors or the behavior of dependencies.
Behavior Verification
Tests in the Mockist approach often verify that certain methods are called with the expected arguments, emphasizing the behavior over the state.
This can make the tests more sensitive to changes in the code's structure.
Refactoring Constraints
While Mockist tests can be very precise in ensuring correct interactions, they can also become tightly coupled to the code's structure, making refactoring more challenging without breaking tests.
This is a significant critique of the approach, as it may limit the ability to evolve the codebase.
Simplicity in Mocked Behavior
Mocks are designed to be as simple as possible, only providing the minimum required to make a test pass, which can lead to a mismatch if the behavior of the class changes but the mocks in other classes' tests are not updated.
Increased Interfaces for Mocking
May result in more interfaces in production code to facilitate mocking, tying tests closely to the code's architecture and potentially increasing the refactoring or rework cost.
Maintenance Cost
High, particularly if classes are tightly coupled. The approach promotes projects with loosely coupled, independent classes to keep maintenance manageable.
Choosing Between the Schools
London School for Libraries: Well-suited for library development where classes are loosely coupled, and the behavior of public APIs needs clear definition and stability.
The low maintenance cost and ease of use of mocks make it an attractive choice for ensuring API behavior contracts.
Detroit School for Applications and Microservices: Ideal for applications where business objectives dictate rapid responsiveness to change. The emphasis on comprehensive behavior verification and less frequent need to alter test code in response to production code refactoring makes it suitable for projects with dynamic requirements.
Balancing Trade-offs
False Positives and Refactoring: Mockist approaches may lead to higher instances of false positives in tests, where tests fail not due to bugs but due to changes in the implementation details. This can slow down development and make the test suite harder to maintain.
Black Box vs. White Box Testing: The Classicist approach aligns with black box testing, focusing on the inputs and outputs without regard to internal workings. In contrast, the Mockist approach is akin to white box testing, with a deep understanding of the internal pathways of the code being necessary to write effective tests.
Debugging and Design Freedom: Mockist testing can offer more granular insights during debugging but may restrict design flexibility due to its prescriptive nature. The Classicist approach, while less granular, offers more design freedom and facilitates easier refactoring.
Ultimately, the decision between these schools should be guided by the project's specific requirements and the development team's preferences. A hybrid approach might even be the most effective in certain contexts, leveraging the strengths of both methodologies:
Combining Approaches for Comprehensive Testing
Use Mockist techniques for unit testing individual components and Classicist strategies for integration and system tests to ensure overall behavior correctness.
Evolving Strategies Over Time
Start with a Mockist approach in the early stages of development when interfaces are more volatile, and transition to Classicist methods as the software stabilizes.
Conclusion
The debate between Classicist and Mockist approaches in TDD is not about which is universally better but about understanding the trade-offs and advantages of both.
What each school calls "units" should not merely represent units of code but units of behavior — actions or sequences that are meaningful within the problem domain and recognizable as valuable by stakeholders, including those in business roles.
The significance of a unit lies not in its size or the number of classes it encompasses but in its relevance and utility in solving a real-world problem.
Whether a behavior spans multiple classes, a single class, or merely a fraction of a method, the focus remains on its purpose and contribution to the application's overall functionality.
The key is not to adhere rigidly to one school but to understand the benefits and limitations of each approach and pragmatically use them based on the specific needs of the project.
P.S. If you enjoyed this post, share it with your friends and colleagues.
When it comes to unit tests, I've mostly preferred the Mockist approach or the London one as you've called it...🙂
Reading the article, however, I wonder if both can be used together.
And would the classic approach be more like integration tests?
What do you think about it?
Great explanation, btw!