1 Introduction
In this lab you will be given the opportunity to test a simple application, reflect on its testability, and then try to improve it.
You are given a sequence of problems to solve:
1.1 Preparation
In this lab you will be working with the lab6-testability
project. It
contains a set of Java classes under src/main/java
, and a very
simple TestNG test class under src/test/java
. You can open the
project and run the test in your favorite IDE, or you can build and test
it in your shell using Maven: mvn test
. Note that you should be able
to compile the code, but the test is intended to fail!
For the IDA labs and thinlinc, run the following command to make mvn
work correctly (if you use the command line instead of Eclipse):
JAVA_HOME=`readlink -f /usr/bin/java`; JAVA_HOME=`dirname $JAVA_HOME`; JAVA_HOME=`dirname $JAVA_HOME`
If you don’t have Maven set up, download and install it. If you use Maven from the command-line, you can refer to its documentation
Tip
In Eclipse, use Import -> Maven -> Existing Maven Projects
.
Running Maven Test
will then download TestNG
and other dependencies used in the project.
1.2 Note on Mocking
If you are not familiar with mocking, you can skip this section and move on to the exercises.
If you are familiar with mocking, perhaps you will take issue with some of the exercises: where improving the testability of the item under test is suggested by the instructions, the problem could instead be solved by using one of several available mocking frameworks. This is true, but consider the following:
Mocking frameworks are highly useful, not least in that they can help overcome poor testability. This lab, on the other hand, is about exploring and improving testability, rather than techniques for testing despite lack of testability.
Testability is tightly related to flexibility: testing software can be thought of as using it in a context and for a purpose other than what it was originally designed for. Therefore, highly testable code tends to also be highly flexible for other purposes. In other words, testability has positive knock-on effects.
The techniques for improving testability explored in this lab will also make mocking easier. By giving control over the collaborators to the test, you also provide the ability to replace these collaborators with stubs, mocks or variants.
2 Exercises
The lab exercises are designed to be solved in sequence.
Note
You are writing code for testing the class. As such, you probably do not want to add new methods to classes (in particular not public methods) in too many places. If you feel like an elegant solution would be to add a method, consider what the consequences would be.
Tip
If you git commit
after each completed step, you can see what you did in each step in order to write a better lab report.
2.1 Asking for Dependencies
Begin by exploring the se.liu.ida.tdde45.Resident
class. It has
methods for making breakfast and lunch, and it has one collaborator to
help with this: a private instance of se.liu.ida.tdde45.House
. Now
take a look at se.liu.ida.tdde45.ResidentTest
. This has a single
method to test the breakfast-making. Note that it doesn’t verify any
breakfast: as long as no exceptions are raised, this test will pass. Run
it and see what happens!
Clearly there’s a problem. It would seem that the fridge needs to be
stocked. Note that there’s a
se.liu.ida.tdde45.singletons.FridgeStocker
with a
stock(Fridge fridge)
. This should be able to help, but there’s
another catch: how do we get a hold of the fridge to stock? Resident
uses the fridge indirectly, but it doesn’t expose its collaborators.
Think about how Resident
might be updated to allow the test to stock
the fridge. When you consider solutions, think about whether the class
has any reason to expose its collaborators other than to support the
test.
In other words, rather than exposing its House
to the outside world (or adding a getter for the Fridge
), is there anything else that can be done to improve the testability?
Tip
The name of the exercise is a hint!
Tip
The constructor is a good place to ask for the things you need.
Note
You do not need the test to pass to finish this exercise.
Tip
You do not want to stock the fridge in its constructor as you may want to test an empty fridge.
2.2 Object Construction Versus Collaboration Graphs
One way to help us reason about this application is to separate object construction dependencies from collaborator dependencies. Both of these form graphs. There are tools that can help us render such graphs, but this example is simple enough that we can do it with pen and paper.
Start from your test method and sketch an object construction graph:
which classes construct which other classes? To help you get started,
ResidentTest
creates a House
and a Resident
. Which objects
to these classes create in turn?
Once you have exhausted the tree and thereby mapped out the construction
of the application, repeat the exercise, but this time map
collaborations. Which classes collaborate with which other classes? To
help you get started, ResidentTest
invokes
Resident.makeBreakfast()
. Does it invoke methods in other classes?
Who does Resident
collaborate with? Don’t include simple get()
methods reaching through objects to get at the actual collaborator.
2.4 Global State
The testMakeBreakfast()
method is actually rather poorly named. A
good practice is to name test methods such that it’s clear which
behavior they’re testing. Now we’ll test that the application is well
behaved even when it doesn’t work. Create a new test method below the
first one called
testMakeBreakfastFailsIfFridgeStockerIsNotInitialized()
. Now we want
to verify that we actually get the UninitializedSingletonException
as expected. This should be very similar to the first test method:
initialize all the singletons except FridgeStocker
. This time we
expect an exception. There are different ways you can do this; TestNG
lets you conveniently express expected exceptions using the @Test
annotation:
@Test(expectedExceptions = UninitializedSingletonException.class)
Now run the test. If you’ve followed the instructions, the new test will
fail. Why? Here’s a clue: try commenting out the testMakeBreakfast()
test case and run only the new test method. (If your new test case
doesn’t fail, make sure you actually executed both tests, and that
TestNG executed testMakeBreakfast()
first.)
The problem here is that these singletons represent global state, and anything that interacts with them becomes interdependent through them. In this case, this has created a dependency between the test cases: we can only test what happens when using an uninitialized singleton if another test case hasn’t already initialized it, unless we want to restart the JVM in between test cases. This is bad: you always want your test cases to be independent so you can execute them in any order, any number of times, or only some of them.
The solution here is similar to the previous exercises: turn
FridgeStocker
into a regular object that you can inject into the
item under test. Which object should get the FridgeStocker
collaborator is an interesting question: is the Resident
responsible
for stocking the Fridge
before fetching ingredients, or is the
Fridge
intelligent enough to call the FridgeStocker
? To make an
informed decision on this we would need to know more about the purpose
of the application - for the sake of the exercise, let’s decide to
inject FridgeStocker
into Fridge
. To allow the test to control
the Fridge
you have to refactor several classes: the Fridge
must
be injected into the Kitchen
and the Kitchen
into the House
.
Once this has been refactored each individual test case has control over
whether and when the FridgeStocker
is initialized. You should now
have no trouble making
testMakeBreakfastFailsIfFridgeStockerIsNotInitialized()
work.
Note
You may want to rename UninitializedSingletonException
if the class is no longer a Singleton
.
You are allowed to keep the class name even if there is no Singleton
functionality remaining in the program.
2.5 Law of Demeter
We’re making progress! We now have two working test cases, one verifying
the happy path through Resident.makeBreakfast()
and one a sad path.
Both test methods have quite lengthy setup procedures, with big chunks
of boilerplate code. Why do we need to create all these objects? Does
Resident
need the House
and the Kitchen
to prepare its
meals? And what does this do for the maintainability of the code? What
will happen to our breakfast-making tests if we put in a bedstand in the
bedroom, or add a new room to the house?
The current setup is a clear violation of the Law of Demeter (read up on it if you are unfamiliar with it).
Resident
reaches through House
and Kitchen
to get to its actual collaborators.
In this simplified example this is only a minor inconvenience, but let’s
assume that both House
and Kitchen
in a real world scenario are
heavy-weight and non-trivial to construct: a house might contain any
number of rooms, with any amount of furniture and appliances. We already
refactored Resident
to ask for its House
rather than build it;
now refactor it further to ask for its actual collaborators. We still
end up with some boilerplate, but much less, and more importantly we
don’t run the risk of side-effects in parts of the code we’re not really
interested in, and isolate ourselves from changes in irrelevant objects.
Tip: If you want to, you can extract the object creation boilerplate
into a separate method annotated @BeforeMethod
. This will be
executed prior to each test method execution, greatly helping you clean
up your code, but that’s not the focus of this lab.
2.6 Object Substitution
An important benefit of working with objects, rather than with global
state singletons such as OrderDispatcher
and CreditCardCharger
is that they can easily be substituted, thereby controlling the behavior
of the item under test (or for other purposes - remember, everything we
do here has ramifications for flexibility in general). For the purposes
of these tests, whether making breakfast ends up changing the balance of
some bank account is irrelevant: we’re only interested in Resident
and its nearest collaborators.
Since we already refactored FridgeStocker
into an injectable object,
we can actually cut off its hidden dependency on OrderDispatcher
and
everything else. In your test code, create a class that extends
FridgeStocker
and overrides its stock()
method, so that it no
longer calls OrderDispatcher
. While you’re at it, remove the
requirement to initialize it as well - the only thing we’re interested
in is that it stocks the fridge!
Now we can test Resident
without any round-trips through the entire
application: we can focus on ensuring its correct internal behavior and
correct interactions with Fridge
, Stove
and Workbench
.
Remove all the singleton initializations as well as the
BankConnector
stub in your test code: they’re not needed anymore.
Depending on how you implemented your FridgeStocker
stub your
testMakeBreakfastFailsIfFridgeStockerIsNotInitialized()
method might
fail. If you don’t throw an exception from it anymore, you won’t be able
to verify that you get exceptions from it. If you preserved the
exception throwing in the stub, ask yourself what the purpose of the
test method is now: why are you running a test that verifies the
functionality of a stub that is also nothing but test code? Through our
refactoring of the code it has become much clearer that what might have
seemed like a perfectly valid thing to test in the first place actually
had nothing to do with the Resident
class to begin with, and
therefore shouldn’t be part of any ResidentTest
method. In other
words, we can safely remove this test method as well as the
BankConnector
stub.
Tip: Creating these substitutions by hand is rather awkward. This is where mocking frameworks shine: they make this type of substitution extremely easy, and then let you interrogate the mock objects to determine whether your object under test has behaved as expected.
In case you extracted the object creation part into a separate
@BeforeMethod
method, your testMakeBreakfast()
method should now
look something like this:
@Test
public void testMakeBreakfast() throws UnknownIngredientException,
UnpoweredException, UninitializedSingletonException {
stove.turnOn();
r.makeBreakfast();
}
2.7 A Few Quick Ones
Now that the code is more amenable to testing, let’s see if adding new
tests has become any easier. Create two new test methods: one verifying
that cooking on a filthy stove will make the Resident
sick, and one
verifying that the Resident
doesn’t get sick even if the stove is
filthy, if the stove isn’t turned on (preventing the meal from being
prepared in the first place).
What was your experience writing these tests? Would writing them have been easier or harder before all the refactoring of the code?
2.8 Graphs Revisited
We have now refactored the application quite a bit. Let us repeat the exercise from Section 2.2. Which differences do you spot? Which conclusions can you draw from this?
Clue: To effectively test the application logic, you need to do that in well defined isolated parts of the application. This requires control over object construction. How might this be expressed in terms of overlap versus separation of construction and collaboration graphs?
3 Final Note
There are many more things that can be improved in this example
application, and the end result of all the exercises is far from perfect
(for instance, we didn’t clean up the fact that non-singletons are now
throwing UninitializedSingletonException
). While this is the end of
the required lab exercises, feel free to keep experimenting with ways to
further increase the testability and flexibility of the code by
controlling object creation and dependency graphs in the application.
Try writing tests for other parts of the software, such as
Resident.makeLunch()
. Remember to not only test happy paths, but
also sad paths!