Test Driven Development
Testing--sometimes tedious, but certainly necessary. Maybe it should it be the
basis of your approach to coding.
What is Test Driven Development?
The fundamental idea of test driven development is to write tests before
writing the code to be tested. As the code is written, and you'll have
immediate confirmation of whether or not a new chunk of code is completely
functional, close to working, a complete disaster. In the most rigorous
application, you write a single test, watch it fail, then write the
implementation code that makes that single test pass, and then repeat.
Why Test Driven Development?
Writing tests before writing code tests your design--first of all, is it
actually testable in the first place? But more importantly, do the interfaces
you've created at actually make sense? Once you start to use a your work, you
often notice subtle issues that are hard to catch during design. Writing or
designing tests gives you a chance to see how someone will interact with your
work, and this added perspective can be very helpful. For instance, if you
were designing a string class and it takes four lines of code to convert a
string into an integer, that might be an indication that your design isn't
making common use cases easy. While this might come up during a discussion of
the design, the consequences will be far more obvious when writing fifteen
tests with the same bad interface.
Tests also give you a way to measure progress of implementation. If you write
your tests such that unimplemented code will fail, then you can check out
implementation successes as your tests start to succeed.
Test driven development usually includes a component of unit testing. Unit
testing simply means testing the individual components of your program rather
than just the part the user sees, which is just the operation of the program.
For instance, if you have a large program composed of many different classes,
including a specialized implementation of a heap
algorithm with the normal heap interface:
// look at the top element
// remove the top element
add an element
void add(obj* ele);
// implementation helpers ...
Moreover, you know that during the expected normal operation of your program,
the user will never interact directly with the heap; its effects will be
indirect, and, potentially, it may be used only rarely. For instance, perhaps
your heap implementation only comes into play when the system has low-memory.
As a result, testing this heap code by testing the program itself would be
extremely hard to do. You'd have to come up with a use case where you can be
certain that your heap will be used. If you want to test special cases in the
heap implementation, you'd have to be extremely lucky or extremely clever.
Why bother? This is a perfect place to write unit tests. Instead of testing
your heap through the complete program, you could write functions that use the
SpecializedHeap class directly:
obj my_obj( 3 );
heap_tester.add( my_obj );
return heap_tester.peek()->getVal() == 3;
Notice that this test relies on both the add and peek functions. In general,
you'd like to avoid relying on a function you haven't tested to test another
function. On the other hand, sometimes you simply must test pairs of
functions--or rely on very simple getters or setters.
If you have a particularly complicated set of functions that must all work
correctly for you test to work, you may wish to make your unit tests friends
the class being tested so that they can check the internal data structures
themselves for evidence of correctness.
This example test case was quite simple, and it's best to start off by testing
the smallest pieces of functionality first, and then build tests for more
complex functionality on top of them. But you may also wish to make more complex tests for simple functions. Adding one element to a heap may work correctly, but adding three elements and removing them in order may be a mini-Waterloo.
Coupling of Code
If your program is too "tightly coupled" (i.e., interdependent), you may run
into issues where you need large portions of your program working before you
can start unit testing. For instance, in the heap example, if your obj class
requires a lot of sophisticated initialization it may be very hard to test the
heap without getting obj up and running. You might, nevertheless, like to be
able to implement the heap without having a full obj class. In this case, you
might decide to create a subclass of obj that has a simpler implementation,
but that can provide a simplified version of the functionality needed by the
Unit tests also make it easier to reuse code. How's that? Let's say you
didn't unit test your heap code, and you later decided that you needed exactly
the same heap implementation in another program. Much to your chagrin, that
program breaks once you start using your heap. You've already gotten a bit
lucky in that you've isolated the problem to using the heap code, but you're
still stuck figuring out exactly what is wrong with it. Now you're doing
maintenance work when you'd really like to be writing a new program.
By more thoroughly testing your program's pieces when you write them, you can
fix problems when the code is fresh on your mind. This also means you can feel
more comfortable sharing the code with others who might use parts of the code
in ways that your original program never did during testing.
One way to think about it is that unit tests give you a much better shot at
exercising every code path
, a path of execution through your code, than
simple end-to-end testing.
What Doesn't Unit Testing Do?
Unit testing can't catch every problem. Issues sometimes arise in the
integration of two components. Here, the program is not that the components
don't work, but that they may have a fundamentally different understanding of
the world. For instance, if you're using someone else's code, and you assume
that their functions never frees pointers passed into them, but they do, then
all of their unit tests might pass, but your code might not.
Unit tests can only test that functions do what they say they do--you still
have to write programs that use them in the proper way. Testing those
programs is a whole new ball of wax because even if the program "works", that
doesn't make it nice to look at, easy to learn, responsive or well-documented.
Unit tests are great reminders of your assumptions. Every time you change the
code, if an old unit test fails, that's a sign that your change has
reintroduced a bug. If your change reintroduced a bug, then it's quite
possible that you've forgotten some assumption about the code being tested.
Perhaps you rewrote a helper function and forgot that it could take negative
numbers as arguments.
Unit tests should be a living part of your code. If you find a bug in a
unit-testable part of your program, that's a good sign that you left out an
important unit test. Add a unit test that would have caught that bug the first
time, and you provide yourself more assurance that later modifications won't reintroduce it.
Keeping Tests Up To Date
Once you've finally got all your tests passing, it's a good idea to keep them
that way. If you get too complacent about a few failing tests, you might not
notice when new tests start to fail. If you really want to keep around these
test cases, you might be better off creating a filter that hides them from your
normal view so that new failures show up unobscured by old ones.
Starting your program by thinking about how to test it might seem unnatural at
first, but it has a certain satisfying quality to it. As soon as you
implement a feature, you get to check off a test as a new success. It's good
for your psyche, and, almost as good, it reminds you when you forget to
implement a feature.