A full Test Driven Project to learn from.
Since the beginning of Software Engineering we have always seen a rapid growth in technologies. Beyond all these technologies, several methodologies and practices have been developed and presented. Among all of these, only one really had revolutionized the Software Engineering: the Object Oriented Paradigm. The Functional paradigm is now coming back again, but it's not hitting the ground running, neither this time.
Today, another revolution is in progress and the world is increasingly joining it: Test Driven Development. The concept is really simple: write the test first, then write the code to make the test passing. Even if so simple, its effects are not that simple and immediate to recognize and appreciate. Its because of those effects that the TDD is a revolution in Software Engineering.
Rather than talking about TDD all in once as many other guides do, we'll apply it to a brand new educational project, detailing all its aspects on the road, while we face them. For this project, we'll develop a Security and Domotics System in Java. All the project's code is available here on bitbucket and to show the TDD cycle, I've always committed first the test and then the production code. To identify commit which adds new tests I've always prefixed their commit log with TEST:. Additionally, I've created a tag per each post. So all this post's code can be checked out from tag v0.1.
So, we want a Security System and we want it to be great! This time, though, rather than starting modeling all the components this system might be made with, we will start up our IDE and create a brand new project: Domus.
First of all Domus is a Security System, so we'll start implementing that functional area. As a Security System, we do expect to be able to engage and disengage the alarm and we want to do it by means of a PIN code. However, before engaging the alarm, we start the system up and we want it to not be engaged at this initial stage. Let's define this requirement by writing a test:
public class SecurityAlarmTest extends TestCase {
public void testAlarmNotEngagedAtStartup() {
// setup
SecurityAlarm alarm = new SecurityAlarm();
// verify
assertFalse(alarm.isEngaged());
}
}
This code doesn't even compile as we've not defined SecurityAlarm anywhere. However, we've defined how our API has to look like. This is the first important aspect of TDD: By writing the test first, we'll always write the cleanest and most honest API ever. Someone might argue "of course you have, it's just a stupid isEngaged() method being called". However, all of us have coped at least once with complicated API for doing silly things like this. Honesty, as well, is really important for an API. We have all faced at least once an API pretending to have less dependencies than what it indeed had, often because they were silently invoking singletons or equivalent static methods we weren't aware of.
The following is the code that satisfies the requirement and make the test passing.
public class SecurityAlarm {
public boolean isEngaged() {
return false;
}
}
The next bit is engaging the alarm. To do this, we want to specify our PIN code. Again, we're now defining a new method and this code won't neither compile.
public void testEngageAlarm() {
// setup
SecurityAlarm alarm = new SecurityAlarm();
// exercise
alarm.engage("0000");
// verify
assertTrue("Alarm not engaged", alarm.isEngaged());
}
This new requirement allows us to appreciate the Agile aspect of TDD. Someone would start implementing the engage() method checking that the PIN code is correct, eventually if it is well formed perhaps by means of regular expressions specified on a config file. Never do such things. Instead, if you reckon those are important features, take just note of them and stick writing the minimum amount of code that makes the test passing. After having made the test passing, review the notes and one-by-one write the requirements for those features, that is write the tests which will enforce you to implement them. Unfortunately, this requires you to be disciplined but it pays back. So, in our case, the minimum amount of code which makes the test passing is introducing a private variable tracking the engagement status and set it to true in the engaged() method.
public class SecurityAlarm {
private boolean _engaged = false;
public boolean isEngaged() {
return _engaged;
}
public void engage(String pin) {
_engaged = true;
}
}
Having been now able to engage our alarm, we definitely want to disengage it. So, let's formalize the requirement:
public void testDisengageAlarm() {
// setup
SecurityAlarm alarm = new SecurityAlarm();
alarm.engage("0000");
// exercise
alarm.disengage("0000");
// verify
assertFalse("Alarm engaged", alarm.isEngaged());
}
This case is just the opposite of the engage() method, so we just need to set our new private variable back to false in the disengage() method. Don't be worried about doing copy-and-paste, the test is now supervising us.
public class SecurityAlarm {
...
public void disengage(String pin) {
_engaged = false;
}
}
Obviously, the PIN code's goal is to deny engagement and disengagement of the alarm to unauthorized people. So let's write the requirements for these new features (For convenience I've put engagement and disengagement tests together here but they have actually been committed separately).
public void testDoNotEngageAlarmIfPinIsWrong() {
// setup
SecurityAlarm alarm = new SecurityAlarm();
// exercise
alarm.engage("1234");
// verify
assertFalse("Alarm engaged", alarm.isEngaged());
}
public void testDoNotDisengageAlarmIfPinIsWrong() {
// setup
SecurityAlarm alarm = new SecurityAlarm();
alarm.engage("0000");
// exercise
alarm.disengage("1234");
// verify
assertTrue("Alarm not engaged", alarm.isEngaged());
}
The minimum changes required to make these tests passing are the following:
public class SecurityAlarm {
Here comes the second important aspect of TDD: After a test have been satisfied once, it will supervise all our refactorings, ensuring we've not broken anything. This means that now we can refactor SecurityAlarm in any way we want. If the unit tests pass, then we've got it right. In our case we can remove the string literals and introduce a helper private method isPinValid() to verify whether the PIN code is valid or not, so to increase code expressiveness.
public class SecurityAlarm {
...
public void engage(String pin) {
if (pin.equals("0000")) {
_engaged = true;
}
}
public void disengage(String pin) {
if (pin.equals("0000")) {
_engaged = false;
}
}
}
Here comes the second important aspect of TDD: After a test have been satisfied once, it will supervise all our refactorings, ensuring we've not broken anything. This means that now we can refactor SecurityAlarm in any way we want. If the unit tests pass, then we've got it right. In our case we can remove the string literals and introduce a helper private method isPinValid() to verify whether the PIN code is valid or not, so to increase code expressiveness.
This is a silly refactoring right? SecurityAlarm is the only class in this tiny code base… It has only three methods… However, the first time I've done this refactoring it was almost 2am and I've got it wrong and the unit tests caught my mistake. Immediately! I've also committed my error on git so that you can check what I've missed. Basically, with that bug anyone would have been able to engage/disengage the alarm even with the wrong PIN code! Try imaging how boring and disappointing it would have been to boot the system up, engage the alarm (with the right PIN), type then the wrong PIN and discover that the alarm is disengaged!
Here is how SystemAlarm looks like after the refactoring:
public class SecurityAlarm {
static private final String DEFAULT_PIN = "0000";
private boolean _engaged = false;
private boolean isPinValid(String pin) {
return pin.equals(DEFAULT_PIN);
}
public boolean isEngaged() {
return _engaged;
}
public void engage(String pin) {
if (isPinValid(pin)) {
_engaged = true;
}
}
public void disengage(String pin) {
if (isPinValid(pin)) {
_engaged = false;
}
}
}
As customers, we would be very disappointed if we couldn't change the PIN code. Let's put down the requirement. We want to change the PIN code but, to be sure that we're the only one who can change it, we want to input also the old PIN code:
public void testChangePinCode() {
// setup
SecurityAlarm alarm = new SecurityAlarm();
// exercise
alarm.changePinCode("0000", "1234");
alarm.engage("1234");
// verify
assertTrue("Alarm not engaged", alarm.isEngaged());
}
The minimum amount of changes that make all the unit test passing consist in introducing a new private variable to store the PIN and defaulting it to the factory PIN:
public class SecurityAlarm {
...
private String _pin = DEFAULT_PIN;
private boolean isPinValid(String pin) {
return pin.equals(_pin);
}
...
public void changePinCode(String oldPin, String newPin) {
_pin = newPin;
}
}
However, the feature is not complete yet. We want to make sure that only who knows the PIN can change the PIN:
public void testDoNotChangePinCodeIfOldOneIsWrong() {
// setup
SecurityAlarm alarm = new SecurityAlarm();
// exercise
alarm.changePinCode("1234", "5678");
alarm.engage("5678");
// verify
assertFalse("Alarm engaged", alarm.isEngaged());
}
This is the working code:
public void changePinCode(String oldPin, String newPin) {
if (isPinValid(oldPin)) {
_pin = newPin;
}
}
Here comes another important aspect of TDD: Since unit tests are tiny, it does cost nothing to copy-paste-tweak an existing test to test some edge or paranoid condition. In our case. We might want to test if the Security System allows us to change PIN code more than once and it does really validate against the old PIN code rather than the factory one. Sounds paranoid? In this case it's probably not, but even if it was copy-paste-tweak of 5 lines would cost less than 1 minute of "typing".
public void testCanChangePinCodeMoreThanOnce() {
// setup
SecurityAlarm alarm = new SecurityAlarm();
// exercise
alarm.changePinCode("0000", "1234");
alarm.changePinCode("1234", "5678");
alarm.engage("5678");
// verify
assertTrue("Alarm not engaged", alarm.isEngaged());
}
This test passes, proving that we got it right the first time, but we now have another test supervising us.
This concludes the first part of this educational project and we've already implemented important features in the overall system. It's worth to highlight that to implement this central features we didn't spend any time designing any component. This is because TDD is the most Agile methodology, as it promotes the design to emerge on its own, in a bottom-up fashion, rather then in the classical top-down one of modeling several components and their collaborators up-front and narrowing down their details. However, TDD is not purely bottom-up, but it balances itself with top-down as we first identify the component to assign a given responsibility to (top-down) and then we implement it by increasingly extending its requirements and introducing its collaborators (bottom-up).
Before leaving, it's worth to briefly recall the important aspects (or "features") of the Test Driven Development we've seen so far:
- It leads to the cleanest and most honest API ever.
- It leads to the right and most effective balance of bottom-up and top-down designing.
- Unit tests make refactoring enjoyable rather than risky and scary.
- Unit tests are quick to copy-paste-tweak letting us able to test paranoid cases.
No comments:
Post a Comment