Unit testing when Value Objects get in the way
Join the DZone community and get the full member experience.
Join For FreeTests developed during TDD can be classified into several levels, depending on the size of the object graph they need to work with. End-to-end tests span the whole application graph, while unit tests usually target a single public class at a time.
In the middle we find functional tests, which exercise a group of objects. A recurring problem is that of nearby classes C creeping into unit tests of unrelated classes; this situation transform what would be a unit test of the original class O into a functional tests of O and C together (possibly with multiple C classes involved).
Functional tests are handy for specifying behavior at an higher level of abstraction than that of a single object, and sometimes for checking the wiring of a component of the application. However, if they are introduced involuntarily in place of unit tests they are prone to raise maintenance problems, since they will need to change every time the C class is updated. Moreover, they will fail along with the unit test of C, pointing to a problem into either O or C, which are not able to localize immediately.
Consider this test, where the original class is DocumentsDeclarationNodeCommand and the collaborating one is InMemoryDocumentCopy:
@Test public void shouldSendTheListOfDocumentsAndWaitForAcknowledgement() throws ConnectionClosedException { UpstreamConnection upstream = mock(UpstreamConnection.class); DownstreamConnection downstream = mock(DownstreamConnection.class); InMemoryDocumentCopy first = new InMemoryDocumentCopy("1.txt", "hello"); InMemoryDocumentCopy second = new InMemoryDocumentCopy("2.txt", "hello2"); DocumentsDeclarationNodeCommand command = DocumentsDeclarationNodeCommand.fromDocumentCopies( Arrays.<DocumentCopy>asList(first, second), 10001); command.execute(upstream, downstream); InOrder inOrder = inOrder(upstream, downstream); inOrder.verify(upstream).command("DOCUMENTS|PORT=10001"); inOrder.verify(upstream).command("1.txt|5"); inOrder.verify(upstream).command("2.txt|6"); inOrder.verify(upstream).endCommandSection(); inOrder.verify(downstream, times(1)).readResponse(); }
The two expectations on command() make this test a functional one: a change in the textual serialization format of InMemoryDocumentCopy (such as "1.txt|sha1_hash|5") will break this checks, even if DocumentsDeclarationNodeCommand still works. Yet we cannot avoid to verify that the documents are really sent to the server by this object.
Functional tests can be transformed again into unit tests by testing O with a Test Double instead of C (a Stub, or a Mock.) The only remaining dependency will be the one of the interface of C, which can be even extracted into an independent entity (a first-class interface in language that support them such as Java, C# and PHP.)
Pure functions
What happens when you can't easily inject a Test Double to maintain the tests at the unit level? This issues exists in functional languages where functions call a tree of other functions.
A analogue approach to dependency injection is to inject the function as a parameter, but doesn't probably scale to the level of injection we perform on objects: every function signature would have to receive all the collaborating ones as additional parameters. There are even mocking frameworks for functional languages like Marick's one which are able to isolate a function from its collaborators.
Uncle Bob uses the Derived Expectation pattern instead:
testing "update-all" (let [ o1 (make-object ...) o2 (make-object ...) o3 (make-object ...) os [o1 o2 o3] us (update-all os) ] (is (= (nth us 0) (reposition (accelerate (accumulate-forces os o1) (is (= (nth us 1) (reposition (accelerate (accumulate-forces os o2) (is (= (nth us 2) (reposition (accelerate (accumulate-forces os o3) ) )
The update-all function calls internally reposition, accelerate and accumulate-forces (or it calls other functions which in turn call these three). Instead of specifying unreadable literal expectations in the tests like (1.096, 4.128), this approach let the test specify update-all link to the other functions without introducing magic numbers. It is therefore a unit test for update-all, while the same test containing numbers would be a functional test.
Note that this approach is safe for functional languages because the collaborating functions have no state, being pure; you can call reposition and accelerate how many times you want, and their result won't change. This is not necessarily true for collaborators in object-oriented languages: in principle, a method can return a different value for each call.
Tests with derived expectations
As long as the composed methods do not change their result, this approach would build real unit tests, whose success does not depend on the correctness of classes other than the one under test. Apart from corner cases like the composed methods throwing exceptions, a change in the collaborator's behavior would change only the collaborator's test.
Value Objects are the ideal collaborator to stub out with derived expectations:
- they are immutable, so their methods always return the same result.
- Their code is usually self-contained and simple, so it's difficult for a method to throw an exception or to break internally once the Value Object has been correctly built.
- Being simple, final classes they do not implement an explicit interface; and they are not commonly substituted by Test Doubles. Their behavior is mixed in with the objects using them.
@Test public void shouldSendTheListOfDocumentsAndWaitForAcknowledgement() throws ConnectionClosedException { UpstreamConnection upstream = mock(UpstreamConnection.class); DownstreamConnection downstream = mock(DownstreamConnection.class); InMemoryDocumentCopy first = new InMemoryDocumentCopy("1.txt", "hello"); InMemoryDocumentCopy second = new InMemoryDocumentCopy("2.txt", "hello2"); DocumentsDeclarationNodeCommand command = DocumentsDeclarationNodeCommand.fromDocumentCopies( Arrays.<DocumentCopy>asList(first, second), 10001); command.execute(upstream, downstream); InOrder inOrder = inOrder(upstream, downstream); inOrder.verify(upstream).command("DOCUMENTS|PORT=10001"); inOrder.verify(upstream).command(first.toString()); inOrder.verify(upstream).command(second.toString()); inOrder.verify(upstream).endCommandSection(); inOrder.verify(downstream, times(1)).readResponse(); }
Conclusion
We saw that Test Doubles like Mocks and Stubs are not the only way to achieve isolated tests, which fail only where the class under test fail and not when a collaborator changes its implementation.
In the Example, DocumentsDeclarationNodeCommand is tested by involving the real collaborator, but setting up Derived Expectation from it instead of literal ones. The result is this test is only tied to the method signatures of the collaborator instead of to the real behavior (the output format of toString()).
This technique doesn't need to be used often: its purpose is to isolate from an immutable object, without introducing a Test Double.
Opinions expressed by DZone contributors are their own.
Comments