From interaction-based to state-based testing by Mark Seemann
Indiscriminate use of Mocks and Stubs can lead to brittle test suites. A more functional design can make state-based testing easier, leading to more robust test suites.
The original premise of Refactoring was that in order to refactor, you must have a trustworthy suite of unit tests, so that you can be confident that you didn't break any functionality.
The idea is that you can change how the code is organised, and as long as you don't break any tests, all is good. The experience that most people seem to have, though, is that when they change something in the code, tests break.
"to refactor, the essential precondition is [...] solid tests"
This is a well-known test smell. In xUnit Test Patterns this is called Fragile Test, and it's often caused by Overspecified Software. Even if you follow the proper practice of using Mocks for Commands, Stubs for Queries, you can still end up with a code base where the tests are highly coupled to implementation details of the software.
The cause is often that when relying on Mocks and Stubs, test verification hinges on how the System Under Test (SUT) interacts with its dependencies. For that reason, we can call such tests interaction-based tests. For more information, watch my Pluralsight course Advanced Unit Testing.
Lessons from functional programming #
Another way to verify the outcome of a test is to inspect the state of the system after exercising the SUT. We can, quite naturally, call this state-based testing. In object-oriented design, this can lead to other problems. Nat Pryce has pointed out that state-based testing breaks encapsulation.
Interestingly, in his article, Nat Pryce concludes:
"I have come to think of object oriented programming as an inversion of functional programming. In a lazy functional language data is pulled through functions that transform the data and combine it into a single result. In an object oriented program, data is pushed out in messages to objects that transform the data and push it out to other objects for further processing."That's an impressively perceptive observation to make in 2004. I wish I was that perspicacious, but I only reached a similar conclusion ten years later.
Functional programming is based on the fundamental principle of referential transparency, which, among other things, means that data must be immutable. Thus, no objects change state. Instead, functions can return data that contains immutable state. In unit tests, you can verify that return values are as expected. Functional design is intrinsically testable; we can consider it a kind of state-based testing, although the states you'd be verifying are immutable return values.
In this article series, you'll see three different styles of testing, from interaction-based testing with Mocks and Stubs in C#, over strictly functional state-based testing in Haskell, to pragmatic state-based testing in F#, finally looping back to C# to apply the lessons from functional programming.
- An example of interaction-based testing in C#
- An example of state-based testing in Haskell
- An example of state based-testing in F#
- An example of state-based testing in C#
- A pure Test Spy
Adopting a more functional design, even in a fundamentally object-oriented language like C# can, in my experience, lead to a more sustainable code base. Various maintenance tasks become easier, including unit tests. Functional programming, however, is no panacea. My intent with this article series is only to inspire; to show alternatives to the ways things are normally done. Adopting one of those alternatives could lead to better code, but you must still exercise context-specific judgement.