A full Test Driven Project to learn from.
Our simple Home Security system is almost complete. We've written most of the units it is made of but to be fully complete and deployable, we still need to connect the dots. In all the previous posts, we've written unit tests as we were writing units, i.e. the core parts of the application. Since we're now going to finish it, we need to write code to cross the boundaries between the application and the environment it interacts with. We'll need to implement a concrete Database, a concrete Logger, a concrete Siren and the UI. This will give us the opportunity to understand the so called Testing Pyramid. It is a concept by which application's tests can be classified into three different categories, each one having an increasing number of tests (hence the pyramid shape):
- Unit tests. The base of the pyramid, where the greatest number of tests lie. They exercise the core part of the application: its units. A unit is definable as any piece of code we write (i.e. not 3rd party libraries we use). A unit tests is then a test which exercise only that single unit; every other unit it eventually interacts with will be replaced by a test double.
- Integration tests. Fewer than unit tests, they exercise the interaction between two or more concrete units or the interaction between a concrete unit and the operating system or, similarly, the application's execution environment.
- Component tests. The top of the pyramid, being the category with the smallest number of tests. They exercise the application as a whole. Every unit the application is made with will be real and the tests can only drive the application's inputs and read its outputs.
Being a simple project, Domus will have the simplest database ever; it will read/write the PIN code from/to a file and we'll call it FileDatabase. Our first requirement is: when FileDatabase is created it has to create the file and write the factory PIN code into it.
public class FileDatabaseTest extends TestCase {
protected void tearDown() {
File file = new File("pin.dat");
assertTrue("File couldn't be deleted", file.delete());
}
public void testCreatePinFileIfNotExists() throws Exception {
// setup
FileDatabase db = new FileDatabase("pin.dat");
// verify
Scanner dbScanner = new Scanner(new File("pin.dat"));
assertEquals("0000", dbScanner.next());
}
}
This is an Integration Test and the reason is that FileDatabase interacts directly with the execution environment (i.e. the JVM and a real file). Because of this interaction, integration tests are slower than unit tests. It might not sound like a big issue but as soon as the integration tests get more and more, running all the tests will take longer and longer, until they will eventually take so much time that we'll be annoyed by running them or, anyway, we'll spend lot of time waiting rather than implementing features. This should not make us think that integration tests are bad. We need them. We just want run them less frequently than unit tests, to not slow us down.
The following is the implementation of FileDatabase constructor:
The following is the implementation of FileDatabase constructor:
public FileDatabase(String pinFileName) throws IOException {
FileWriter pinFile = new FileWriter(pinFileName);
pinFile.write("0000");
pinFile.close();
}
To keep this short, I'll skip over the full implementation of FileDatabase (you can find all the code on git, also for FileLogger and FileSiren). To launch Domus, we'll also need a Main file which will wire all the parts together. It will also implement a classic command line interface printing out a menu with all the choices, which for our simple project will be:
- 0, to quit the program
- 1, to add a sensor
- 2, to engage the alarm
- 3, to disengage the alarm
- 4, to trigger a sensor
- 5, change pin code
The implementation of Main can be found on git. What's more important to look at is one component test for Domus. This test exercises the whole Domus, by adding a sensor, engaging the alarm and then triggering the sensor; the expected outcome is the activation of the siren, which is just a matter of writing 1 into the file siren.out (as often happens for /dev files on Unix platforms).
public class DomusTest extends TestCase {
protected void tearDown() {
assertTrue(new File("pin.dat").delete());
assertTrue(new File("log.txt").delete());
assertTrue(new File("siren.out").delete());
}
public void testActivateSiren() throws Exception {
// setup
StringBuffer commands = new StringBuffer();
commands.append("1\n"); /* Add a sensor */
commands.append("2\n"); /* Engage the alarm */
commands.append("0000\n");
commands.append("4\n"); /* Trigger a sensor */
commands.append("0\n");
commands.append("0\n"); /* Quit the program */
System.setIn(new ByteArrayInputStream(commands.toString().getBytes()));
// exercise
Main.main(new String[] {"pin.dat", "log.txt", "siren.out"});
// verify
Scanner sirenScanner = new Scanner(new File("siren.out"));
assertEquals(1, sirenScanner.nextInt());
}
}
Two other component tests can be written to verify that the user can:
- change the PIN code
- disengage the alarm.
You can find these tests on git. What is important to highlight is that these three tests are the only component tests needed. Looking at how many tests we've written so far, we can finally understand what the Testing Pyramid is:
- 3 component tests
- 8 integration tests
- 16 unit tests
As expected, in a well tested codebase, the majority of the tests are unit tests, because they exercise all the workers of a system, proving that each one is doing its job; a lower number of integration tests prove that the peripheral parts of the system interacts properly with the execution environment; finally, a small set of component tests prove that everything has been wired correctly.