A full Test Driven Project to learn from.
In the last post, we've seen what the Dependency Injection is and, particularly, how it makes unit testing easier: by injecting fake collaborators, we fully control inputs and outputs of the class being tested. In this third part we're going to discover five different ways of faking collaborators, commonly known as Test Doubles:
- Dummy Object
- Stub Object
- Fake Object
- Spy Object
- Mock Object
As usual, we'll extend Domus to fulfill new requirements. In particular, we want to persist the PIN code into a database to survive system reboots and we want to produce logs reporting relevant events such alarm engagement and disengagement. To satisfy this two requirements, we'll introduce a Database and a Logger.
Stub Object
The first Test Double to talk about is the Stub Object: its only responsibility is to return known values (fixed or configurable) to its callers; therefore stubs suite very well when an object serves as an input to the class being tested.We need a stub object to redefine the requirement of all the tests dealing with the PIN code. The new requirement, in fact, is that SecurityAlarm shall validate the PIN entered by the user against the one persisted into the database (for size reasons here there is only the code of testEngageAlarm()):
public void testEngageAlarm() {
// setup
StubDatabase db = new StubDatabase();
SecurityAlarm alarm = new SecurityAlarm(db);
// exercise
alarm.engage("0000");
// verify
assertTrue("Alarm not engaged", alarm.isEngaged());
}
All these tests now don't compile anymore, as SecurityAlarm now expects a Database to be injected, so we define the new constructor:
public SecurityAlarm(Database db) {
}
All the previous tests now compiles, however all the tests we didn't change (not dealing with the PIN code) aren't compiling anymore, because there is no empty constructor for SecurityAlarm. This gives us the opportunity to talk about dummies.
Dummy Object
Dummies are objects whose only responsibility is to shut the compiler up. They concretely implement their interfaces to pass the type checking at compilation time, but they shouldn't be invoked at all by the class being tested. Bear in mind that the null keyword is considered a dummy object and it is more than welcomed in unit tests as it highlights that a particular collaborator is not involved in that particular unit test (hence, the cause of failure should be elsewhere). However, the class being tested might not allow null to be passed; in these cases, a Null Object needs to be used. As said, it concretely implements its interface but either does nothing (i.e. empty methods) or makes the test failing, depending on the context and the taste.
To make our unit tests compiling again, we'll use the null keyword wherever required, for example in testAlarmNotEngagedAtStartup():
public void testAlarmNotEngagedAtStartup() {
// setup
SecurityAlarm alarm = new SecurityAlarm(null);
// verify
assertFalse("Alarm engaged", alarm.isEngaged());
}
With these changes the tests are now compiling again. They still pass, but this shouldn't confuse us; SecurityAlarm is still satisfying its requirements:
- Engaging/Disengaging the alarm
- Activating the sirens
- etc
The fact is: we're now doing a refactoring. When we refactor some code we move from a software that satisfies its requirements to a different version which still satisfies its requirements. So, let's keep refactoring SecurityAlarm and remove all references to the private variable _pin and the DEFAULT_PIN constant:
public class SecurityAlarm implements Sensor.TriggerListener {
...
private Database _db;
...
public SecurityAlarm(Database db) {
_db = db;
}
private boolean isPinValid(String pin) {
return pin.equals(_db.getPin());
}
...
public void changePinCode(String oldPin, String newPin) {
if (isPinValid(oldPin)) {
}
}
...
}
Most of the test are still passing but others, like testChangePinCode(), are now failing which means that something is going wrong. This brings us to a new requirement and a new Test Double.
Fake Object
Fakes are the next step toward the real implementation; they pretend very well to be what they say to be. They're more clever than stubs because they start having some degree of logic driving their return values, possibly function of other properties that collaborators might even set. A FakeDatabase is then what we need to make testChangePinCode() passing again (please note that the Database interface has also been changed to offer the method setPin()):
public class SecurityAlarmTest extends TestCase {
class StubDatabase implements Database {
@Override
public String getPin() { return "0000"; }
@Override
public void setPin(String pin) {
/* Do nothing */
}
}
class FakeDatabase implements Database {
String pin = "0000";
@Override
public String getPin() { return pin; }
@Override
public void setPin(String pin) { this.pin = pin; }
}
...
public void testChangePinCode() {
// setup
FakeDatabase db = new FakeDatabase();
SecurityAlarm alarm = new SecurityAlarm(db);
// exercise
alarm.changePinCode("0000", "1234");
alarm.engage("1234");
// verify
assertTrue("Alarm not engaged", alarm.isEngaged());
}
...
public void testCanChangePinCodeMoreThanOnce() {
// setup
FakeDatabase db = new FakeDatabase();
SecurityAlarm alarm = new SecurityAlarm(db);
// exercise
alarm.changePinCode("0000", "1234");
alarm.changePinCode("1234", "5678");
alarm.engage("5678");
// verify
assertTrue("Alarm not engaged", alarm.isEngaged());
}
}
Spy Object
We've already seen spies in the previous post: they're useful when we're only interested in the outputs of the class being tested. They're not usually as clever as Fakes are, because they have a different responsibility: they only keep track of which methods have been invoked (and eventually their arguments); the unit test will then queries the Spy Object to assert that the expected methods have been invoked. Finally, as a courtesy, they might also return known values (fixed or configurable) to their callers but there shouldn't be any logic behind these return values. In Domus, a Spy is exactly what we need for the Logger. Reflecting the events relevant in the Home Security domain, Logger is an interface exposing the following methods:
- alarmEngaged()
- alarmDisengaged()
To keep this post short, I'm going to show only the code for the engagement case (you can find the complete code on git). As Database, Logger is a collaborator and we'll inject it into SecurityAlarm constructor. The requirement is very simple: when the alarm is engaged a log should be produced. The following is the unit test:
public class SecurityAlarmTest extends TestCase {
...
class SpyLogger implements Logger {
private boolean _alarmEngagedLogged;
@Override
public void alarmEngaged() {
_alarmEngagedLogged = true;
}
public void assertAlarmEngagedLogged() {
Assert.assertTrue("Alarm engaged not logged", _alarmEngagedLogged);
}
}
public void testLogWhenAlarmIsEngaged() {
// setup
StubDatabase db = new StubDatabase();
SpyLogger logger = new SpyLogger();
SecurityAlarm alarm = new SecurityAlarm(db, logger);
// exercise
alarm.engage("0000");
// verify
logger.assertAlarmEngagedLogged();
}
}
Making this test passing should be quite easy as it's just about invoking the logger when the alarm is engaged:
public SecurityAlarm(Database db, Logger logger) {
_db = db;
_logger = logger;
}
...
public void engage(String pin) {
if (isPinValid(pin)) {
_engaged = true;
_logger.alarmEngaged();
}
}
However, all the old tests fails if we pass null as a dummy logger. This is, in fact, a case when a Null Object is needed as a dummy.
Mock Object
We're not going to see this in practice because we should avoid Mock Objects as much as possible. A Mock Object is kind of a superset of a Spy. It keeps track of every single method and all its parameters that the class under test calls (even indirectly) and makes the test fail if:
- a method has been invoked unexpectedly
- a method has not been invoked when it should have been
- a method has been invoked too many or too few times
- a method has been invoked with the wrong arguments
- a method has been invoked in the wrong order with respect to another one
All these checks might sound good but they're not in most of the cases. The reason to avoid using Mocks is because, by their nature, they impose how the class being tested should be implemented rather then what it should achieve. As soon as the class being tested is extended to implement new features (which is a very good thing), all the tests relying on Mock Objects will very probably fail (which is a very bad thing). So, what is a Mock Object good for? It's good only when we need to test that a piece of code invokes an external API properly, which is something rare and for which component tests are probably better.
No comments:
Post a Comment