Loosely Coupled Testing
Test Driven Development (TDD) has a plethora of benefits, but the one that stands out for me is how it helps to unify a team. This is because the outcome of TDD is a code base with high code coverage which acts as a safety net for future changes.
However, there is a risk that this safety net can become too tightly entangled around our code. For example, overusing mocking libraries or unit tests that are too knowledgeable about the code they are checking can in fact make the code more cumbersome to work with. Because software is in a perpetual state of flux, either due to new features being added or issues with the current code being addressed, our tests need to allow for change.
The vocabulary that I’ve used for the various test doubles (mocks/stubs, etc) is described in The Little Mocker, therefore I would encourage you to give that a read if you haven’t already done so.
Mocking Collaborators
One of the fundamental axioms of object oriented programming is that a set of objects work together in order to achieve a particular goal. This has a profound effect on how we do our testing. When we are writing a test for a class X
that depends on class Y
, then the unit test for class X
also needs to be aware of this dependency.
Let us imagine we are working on an ATM
class that models a cash machine which you can use to extract money from, view bank balances, and so on. Our ATM
class is going to be interacting with the Bank
.
Unfortunately the Bank
communicates directly to various external systems, so we can’t easily use the real Bank
class in the unit test for our ATM
. Instead, we will be utilizing polymorphism and dependency injecting test doubles of Bank
into ATM
.
There’s a few ways to create test doubles, but the more common way that I have observed is to use a mocking library, e.g. JMock/Mockito for Java, rspec-mocks for Ruby. The reason why this approach is so common is because it is relatively quick and easy to do. For example, to stub out the bank into returning bank account details for a particular bank account, we can do something as follows;
This test knows a fair amount of information. It knows which methods get invoked when the atm is finding out the bank balance, as well the format of the response that bank.get_account_details
generates.
It turns out that get_account_details
method on the Bank
is widely used across the system, and the mock setup we have used in the above test is duplicated in many places. What happens when get_account_details
undergoes some kind of change? What happens if the return value changes from a Hash to some other data type? It is true that a lot of the production code will need changing if this were to happen, but now each test must also be painstakingly updated to incorporate the change.
Another disadvantage to using mocking libraries is the amount of test setup that they can require. It is not uncommon to see test code which heavily use mocking libraries to have a substantial amount of setup to ensure that all the methods on the test double have some default behaviour defined. For example, if we want to create our bank test double, we may have something as follows;
Without this default setup, we may run into errors where methods are invoked on Bank
which have not yet been set up in the unit test. You may imagine this before
block becoming unwieldy as bank is utilized more by ATM
, as well as seeing this setup duplicated across other tests.
So, what is the alternative?
We can instead hand roll a FakeBank
class that can be used across all classes.
What is the advantage of doing this? Well firstly, all mock behaviour in now defined in one place. If we we need to change the return value of get_account_details
, we can do that change here instead of multiple test files. Secondly, as we have complete control over this test double, we can add functionality in order to make it easier to work with in our tests. For example, we can query the FakeBank
for the previous transactions that have been made in order to check the ATM has debited money from a particular bank account for instance.
Loosely coupled data
When we are writing unit tests, we typically want to work with some kind of data. Data manifests itself in various forms, for example domain models/value objects/hash-maps and so on. Data can sometimes be non-trivial to initialize, for instance where there are many data fields required to initialize said data. To solve this problem, it is not uncommon to use test doubles that are far easier to instantiate, and then only defining pertinent fields that are required for the test to pass.
Lets say we were to create a Transaction
data object that is to be utilized by our ATM. This can be achieved in ruby as follows;
The problem that I have encountered with this approach is that whenever more fields from the data object are invoked by the code under test, we have to keep adding setup behaviour to transaction_double
. The obvious alternative is to use real objects;
This is still not always ideal. Every time we want to create an Transaction
in our tests now must have this substantial amount of initialization. Our test code is also highly dependent on the constructor not changing. Any new mandatory constructor arguments added to Transaction
will break this test and any other tests creating Transaction
s, despite whether or not those new fields being used by code under test.
What I find the best option is to extract the object initialization into a separate spec helper;
Some advantages that I see with this approach are as follows;
-
All initialization is in one place. If a new mandatory data field is added, we only have one place to make a change.
-
In our test code, we can create an
Transaction
whereby we only need to specify the pertinent fields (if any) that we care about in the unit test. The undefined data fields take on a default value. -
We are allowing our code under test to interact with our real data objects just as they would do in production. Test doubles can mask issues as the code is not being tested as thoroughly as it could be.
In summary
Tests need to possess some knowledge about the code they are asserting in order to thoroughly test for correct behaviour/allow for change. On the other hand, too much knowledge may result in tests becoming too tightly coupled to the code which can significantly stifle change.
Mocking libraries can be useful for quickly building a fake collaborator when duplication isn’t going to be an issue and the collaborator’s behaviour is simple. However if you find that you are duplicating lots of setup in many unit tests, or your test doubles are non-trivial, then hand-rolling your test-doubles may be a better option.
When it comes to data, my advice is to always use real data objects instead of doubles created by mocking libraries if you can. Real data objects in your tests tend to react to change better as there is no mock setup to maintain, and its easier to put initialization in one place.
In my experience, I’ve found the techniques described in this article help prevent changes in the production code from rippling too heavily into the test code.