|
Over the last few years, my company has been called in to help various development projects in trouble. Sometime these troubles are management-related, sometimes process, sometimes the troubles can be traced back to individual team members. But at the beginning of 2000, we were called in to help a Delphi project with an out of control bug-count.
This development team (who were kind enough to let me write about them, provided I didn't tell anyone who they were<g>) seemed to be doing all the right things. They had a manager who did his best to "run interference" on the developers behalf, letting them focus on writing software. They had source code control and a bug-tracking database. They had a good range of experience across the team, and they had regular code-reviews. Yet they still had a pile of bug reports that would kill a brown dog* and seemed to have been 2 weeks away from delivering their software for the last 4 months.
* A translation is probably required for any non-Aussie readers. The Australian mongrel brown dog is notoriously hard to kill. All sorts of stories abound regarding brown dogs that have survived snake bites, crocodile attacks and Taco Bell burritos (personally I'm sceptical about that last one). At the very least, understand that having a bug list that would kill a brown dog is a very bad situation to be in.
What follows are the techniques we used to reduce their current bug count, and more importantly, reduce the number of bugs they were introducing into the new code they were writing. With one exception, none of the techniques we'll discuss were that difficult to implement, nor did they require a big investment of time.
Aims
In spite of the name of this paper, it would be too much to hope that we can prevent a bug from ever appearing in our code. While some of the techniques presented do prevent you from creating bugs, some much more achievable goals that we're aiming for are to:
- Find the bugs we do introduce as soon as possible after we introduce them,
- Get supplied with enough information about the bug to help resolve it,
- Automatically detect if a bug has been introduced/reintroduced
and perhaps most importantly,
- learn from every bug we do introduce.
This last point is important because a lot of developers I've seen treat bugs as an inevitable chore at the end of each burst of creativity. All that's needed is a small adjustment in attitude to view each bug as an opportunity for more creativity. Most of the techniques presented in this paper came about through this exact attitude. When a bug was found, we spent some time trying to find a way to prevent us from ever putting the same bug in again. If we couldn't do that, we tried to think of a way that this bug could have been found either automatically, or at least very easily. What you'll find below are some of these techniques.
Disclaimers
"Rules are intended for the guidance of wise men, and to be followed blindly by fools."
I don't really mind if you use any of the techniques outlined below. We found that they worked for the particular group of developers mentioned earlier, but that's not to say that they'll work for you. They are intended more as demonstrations of the attitude outlined above. Take the ones that work for you and use them, discard the rest, I really don't mind.
I'm sure there will be some of you who disagree strongly with some of what I outline. Again, that's great. After presenting this session at various developer conferences, I've had some very lively discussions over a drink or two with attendees who disagreed with me. No problem, you buy the beer, I'm happy to listen to you.
However, if you do only one thing, start treating bugs as an opportunity. As trite as that may sound, it's the only consistently successful way I've seen to improve the stability of the code you produce.
Lies, Damn Lies and Statistics
One last disclaimer. Before we started looking at the source code at the aforementioned company, we spent a lot of time looking at their bug tracking database. The fact that they even had one was a huge benefit to us, as we could look at the bugs they'd been finding over the life of the project, and start to group them to get an idea of the areas where they were hurting most. Each of the techniques below will be accompanied by a percentage value, This was the percentage of the bugs they had which were solved by this technique. While I'm not trying to suggest that your project will have similar values, as some sort of anecdotal evidence, the percentages give us somewhere to start. Again, your mileage may vary.
So, on to the techniques we used. The first couple are a little more abstract than the rest, but are probably those that once fixed, gave us the most bang for our buck.
"It compiles...let's ship it!"
As flippant as the above comment sounds, we found a lot of evidence to suggest this attitude in the developers we were dealing with.
Problem: Bad Attitude
Changing attitude can often be the most important part of fixing bugs. An amazing 4% of the bugs we found were in code that had never been run by the development team. Let me say that again...code that had never been run by the development team. They'd written some routine that the developer decided was so simple he didn't need to run it to see that it would work. Alternatively, the changes that had been made to some existing code were so trivial it couldn't possibly have broken anything.
Also, a number of the bugs that were currently in the bug database came as no surprise to the team. On quite a few occasions when shown a bug report we heard developers say "Oh yeah, I saw that one awhile ago, but it went away". Sorry, bugs don't go away by themselves. As tempting as it sometimes is to assume that we have inadvertently fixed a bug while doing something else, my experience has shown that mostly it's only been covered up, waiting to rear it's ugly head again.
Solution:
So what do we do? Well, this first one comes down to old fashioned discipline. The team in question came up with some rules for the developers. For example, we made a rule that developers must step through in the debugger every single line of code they write, as soon as possible after writing it, to ensure that it is behaving as they expected. Also, every bug they spotted had to be logged, and the only way it was to be closed was if their was an explanation of how the bug was removed.
Now this isn't easy to do, and you run the risk of ending up with a very pissed off group of developers. One of the worst things you can do, as a consultant or as a manager, is suggest the rules yourself. In the company in question, we didn't introduce these rules from above, but allowed the developers to come up with them themselves. In general, developers are eager to improve the quality of their work, and if you approach it in the right manner, are usually very willing to discipline themselves, once they are convinced of the value. Another effective technique is to rely on their professional egos. We changed their code reviews from private affairs conducted by the team leader to a process that involved the entire team. Their determination not to have the worst code at the next team review was often all the incentive they needed to discipline themselves.
No Excuses
Problem: Doing work you don't have to.
2% of all their bugs could have been, and in fact were, detected for them by Delphi. While 2% doesn't sound like much, it's a lot when you consider you can get this reduction for no outlay, no extra effort beyond turning on some options in the tool you're already using.
Hints, Warnings, Range Checking, I/O Checking, Overflow Checking. These are all things that Delphi could have been doing for them simply by turning them on. The developers in question even went so far as to turn Hints and Warning off. Why? "We started to get so many of them". In my opinion, there's no excuse for this. If you've got hundreds of hints and warnings, then maybe Delphi's trying to tell you something.
Solution: Use the tool, Tool! *
This particular problem was solved pretty easily. Hints and Warning were turned back on, and an afternoon was invested in getting the number down to zero. Where applicable, they were removed. Where we decided the code was actually correct, the message was either (very carefully) suppressed using {$HINTS OFF}{$HINTS ON} and {$WARNINGS OFF}{$WARNINGS ON} or more cautiously by commenting the offending line of code explaining why we were ignoring the message. Once this was done, we only had to rely on the developers own competitiveness to stop new hints and warnings creeping in.
* I'm not sure how well this translates, but calling someone a "Tool" is an Australian insult, roughly equivalent, I guess, to "moron"
"I didn't know I had to destroy it"
Problem: Memory Leaks
Memory Leaks are an insidious bug, silently leaking away until your application chokes to death. While the Object Pascal language provides us with plenty of ammunition in our fight against memory leaks, a surprising number of even experienced Delphi developers either aren't using them correctly or simply aren't aware of them.
Owners, Try..Finally blocks and Free/FreeAndNil are all useful tools, however that was not the problem with this particular project. All of the developers on the team were either already aware of these things or were quickly made aware. But this didn't seem to reduce our leak problems. What we needed was a no-brainer solution that would require our developers never to have to worry about cleaning up after themselves. Now, a wholesale move to Java with it's built in Garbage Collection was out of the question, so we decided to build some of our own.
Solution: Interfaces
What I'm about to present is not a Garbage Collection system for Delphi, but the use of an existing feature (ie. Interfaces) to perform a similar function.
Cleaners
One situation where the developers were getting themselves in trouble was in simply not always remembering to free an object. While at first glance this may seem like a trivial problem to solve (can anyone say try..finally), for whatever reason, these guys just couldn't get it. So rather than continue to bash our head against a wall, we decided to make it so they didn't have to.
First off, we declared a marker interface (an interface without methods) like this:
ImtCleaner = interface ['{34E82AD7-0DC1-42AE-B484-0E233F25E858}'] end;
Having done this, we declared an object that implements this interface like this:
TmtCleaner = class(TInterfacedObject, ImtCleaner) private FObject : TObject; public constructor Create(AObject : TObject); destructor Destroy; override; end;
The implementation of the constructor and the destructor look like this:
constructor TmtCleaner.Create(AObject: TObject); begin FObject := AObject; end;
destructor TmtCleaner.Destroy; begin FreeAndNil(FObject); inherited; end;
In a nutshell, this object is passed a reference to another object as a parameter to it's constructor, and frees that object in it's destructor. Why does this buy as anything? Haven't we now got to worry about freeing the TmtCleaner instance? No. That's part of the magic of interfaces. Instead of remembering to wrap our creation and destruction of our object in a try..finally, we can simply do this:
procedure TForm1.Button3Click(Sender: TObject); var MyObject : TMyObject; Cleaner : imtCleaner; begin MyObject := TMyObject.Create; Cleaner := TmtCleaner.Create(MyObject); // do stuff here end;
As soon as our Cleaner variable falls out of scope, the reference count will decrement to zero, at which point the TmtCleaner object it points to will be destroyed, freeing MyObject in it's destructor.
Now, I have to confess I don't usually use this technique. However, it came up when we were brainstorming with the developers, and they loved it. This is all just personal preference, so if you like it, use it, if not, come up with your own solution. This next one, however, I love and use all the time.
TRestore
There's another problem which can be solved with the judicious use of Interfaces. How often have you seen code that changes the screen cursor at the beginning of a method, then sets it back at the end. There are a few problems that can occur. If an exception fires halfway through a method, then the code that sets the cursor back may never run, leaving your cursor as an hourglass. Even if you fix this with a try..finally block, the other common problem is where the cursor is set to crHourglass at the beginning of the method and is set back to crNormal at the end. This assumes that the cursor was crNormal before we changed it to an hourglass. If it was already an hourglass, you've just prematurely set it back. So you usually see code like this to get around it:
procedure TForm1.Button6Click(Sender: TObject); var OldScreenCursor : TCursor; begin OldScreenCursor := Screen.Cursor; Screen.Cursor := crHourglass; try // do stuff finally Screen.Cursor := OldScreenCursor; end; end;
While this is only mildly annoying when dealing with Cursors, it becomes a right pain when, for example you're dealing with Fonts. Not only might you change the color, but also the size, the Font name, etc. The same is true with Brushes, Pens, indeed, any other object. You end up saving references to a bunch of properties and restoring them in the finally section. If you forget one, good luck tracking it down in a complex Paint operation.
What we want is another no-brainer. We want the developer to be able to change his TBrush to his heart's content. Change any property, or even all of them, and not have to keep track of anything. Then have it all restored to exactly how it was when he's done. So, how do we solve this one?
Again, a marker interface. Let's look at a simple example first, TCursor. We've declared a marker interface called ImtRestore, and implemented another TInterfaceObject descendant like so:
TmtCursorRestore = class(TInterfacedObject, ImtRestore) private FOriginal : TCursor; public constructor Create; destructor Destroy; override; end;
...
constructor TmtCursorRestore.Create; begin FOriginal := Screen.Cursor; end;
destructor TmtCursorRestore.Destroy; begin Screen.Cursor := FOriginal; inherited; end;
In the constructor, we're saving the current value of the Screen Cursor, and then in the destructor, we restore it. Again, because of the magic of interfaces, we don't even have to remember to free our TmtCursorRestore object. We're left with the following code instead of the earlier Cursor example:
procedure TForm1.Button7Click(Sender: TObject); var OldScreenCursor : ImtRestore; begin OldScreenCursor := TmtCursorRestore.Create; Screen.Cursor := crHourglass; // do stuff end;
In the sourcecode which accompanies this article, you'll find another, more complex, implementation of the ImtRestore interface, this time for a generic implementation for any TPersistent descendant (eg. TPen, TFont, TBrush, etc). The implementation is a bit more involved, but you use it in exactly the same way. The technique, of course, will apply to just about any object you like. I know of one company who're using it to restore the filter and range settings on TTables after they've finished playing with them.
Even if you don't like these examples, hopefully you're starting to understand what I mean about treating a bug as an opportunity. Asking a simple question like "How do I avoid ever having to fix this bug again?" has freed this development team from worrying about a whole range of bugs. As an added bonus, figuring out the solutions can be a lot of fun as well.
"Does this thing start at 0 or 1"
Problem: Not fully understanding the myriad of data structures used
In the application they were building, they were using a wide range of containers. Standard Delphi ones like TList, TStringList and TCollection, a bunch of other, non-standard ones like Binary Trees, and even TTreeViews, TStringGrids, etc. A really common problem they were striking was when people unfamiliar with the use of these structures were attempting to access the data. Over 3% of their bugs were coming from people starting a traversal at position 1 instead of 0, ending at Count instead of Count - 1, or in the case of the Trees, missing entire branches in their traversals.
One solution might be "Read the Manual", but again, that only seems to work with some developers. But a little thought brought up a solution which again allowed it to be a no-brainer.
Solution: Iterators
Iterators are a fairly common pattern in langauges like C++ and Java, but don't appear in the standard Delphi class library. Iterators allow you to traverse the contents of some data structure, while remaining ignorant of how the contents are stored. Using an Iterator to traverse a Binary Tree is exactly the same as using an Iterator to traverse a TList. Whoever implemented the Iterator had to know the difference, but the user certainly doesn't.
The sourcecode that accompanies this paper contains Iterator implementations for a bunch of the standard Delphi containers, as well as some more complex Iterator interfaces, but let's stick to a simple eaxmple. Our simple Iterator interface looks like this:
ImtIterator = interface ['{C82E9C52-7FAF-49E5-9DD9-949F6F73DA16}'] function HasNext : Boolean; function Next : TObject; function Current : TObject; end;
We've then got an implementation of this interface which knows how to traverse TLists:
TmtListIterator = class(TInterfacedObject, ImtIterator) private FList : TList; FCursor : Integer; public constructor Create(Target : TList); // ImtIterator function HasNext : Boolean; function Next : TObject; function Current : TObject; end;
You can look in the accompanying sourcecode for the implementation details. What we're left with, however, is a method of getting an Iterator for a data structure while remaining ignorant of it's type. Now, in other languages (like Java) which have support for Iterators built in, we'd simply ask the data structure for an Iterator, and let it worry about what type of object to create. TList, however, knows nothing about Iterators, so we have a couple of solutions.
We can cheat a little and find out what sort of data structure we're dealing with and create the relevant type, but that takes away the fun. Another alternative is to declare descendants of each of our data structures that know how to give out the right type of Iterator. An example of a TList descendant is given below:
TmtEnhList = class(TList) public function Iterator : ImtIterator; end;
Notice the Iterator returns an instance of an implementation of our ImtIterator interface. We don't care which implementation we're getting. This way we can treat every data structure exactly the same way, and get an ImtIterator from it, then use it, ignorant of how it's storing it's data:
procedure TForm1.Button1Click(Sender: TObject); var myIterator : ImtIterator; begin myIterator := MyObjects.Iterator;
while myIterator.HasNext do Memo1.Lines.Add(TComponent(MyIterator.Next).Name); end;
A few things to note. This example assumes MyObjects is an instance of the TmtEnhList shown earlier. If it was a simple TList, we still aren't in trouble. We can pass the TList variable as a parameter to the constructor of TmtListIterator and get back our imtIterator implementation. Also, HasNext is an Iterator method which returns a boolean indicating if their are more objects in the structure. Next is a method which returns a reference to the next object. in the sourcecode, you'll find more complex Iterators that let you reference the current object, that let you reference the object associated with a string in a TStringList, etc, but the basic technique for their use is identical. Lastly, as we're using Interfaces, we don't even have to worry about freeing our Iterator. Man, this is easy.
Hidden Bugs
Problem: Inadvertently suppressing bug details
I'm assuming nobody here needs an explanation of exceptions or exception handling. However, exception handling is often very badly abused. Our intrepid team of developers were very conscientiously using exception handling, but unwisely. As a result errors were occurring that were suppressed completely, causing other problems further down the track which were difficult to trace back to their cause.
Solution: Unfortunately, Education
We didn't come up with a nice solution to this one, other than to educate everyone about good exception handling, and to police it during the code reviews. So, for the record, here is "Malcolm's Guide to Good Exception Handling". Well, not true actually. I didn't dream up any of the following points, they're are all items I've picked up along the way from more experienced developers. However, I'm writing this paper, not them, so give me a little license.
As a general rule, always keep in mind that your exception handler is just one of potentially many. Unless you are prepared to completely deal with an exception, you should let it continue on up the line.
Cardinal Exception Sin #1 : Too General
Catching an exception, and not re-raising it, means you except responsibility to handle that exception, and all it's descendants. Be aware, that means all current descendants and all future descendents that may be created. With this in mind, the following piece of code, which is seen with alarming regularity, is pretty brave indeed:
except on E: Exception do begin // handle the exception end; end;
Always be as specific as possible when specifying the type of the exception you are going to catch. The above code is basically saying that the code contained between the begin..end pair will handle any exception that can occur, including Exception descendants not yet invented. Wow, that's some pretty impressive code!
Cardinal Exception Sin #2 : Empty
Following on from the last example, the only time you should use an unqualified exception handler is when you are re-raising the exception. Otherwise, you are again accepting responsibility to deal with any exception which might possibly occur.
An even scarier example is the following:
try // do some stuff except end;
I see this all the time. Sure, your users won't see any exceptions in this block of code, but that doesn't mean that nothing will go wrong.
All of the above applies to else clauses on exception blocks.
Cardinal Exception Sin #3 : Redundant
Exception handling blocks that do the following:
except on E: EMathError do ShowMessage('An error occurred: ' + E.Message); end;
are completely redundant. If you didn't handle this exception, exactly the same end result would ensue. Again, if you're going to respond to an error constructively, then go ahead, otherwise, why not let the default behaviour happen?
"But that can't happen, it's impossible"
Problem: Situations you thought were impossible have a tendency to happen all the same.
Users are very resourceful and the systems we are writing are very complex. The things you thought could not happen, are almost bound to occur. A number of times during the life of this project, I heard the developers responding to descriptions of bugs with lines like "You must be mistaken, that can't happen". But they did, and more than once.
So we've got a couple of choices. We can wait for these "impossible" situations to occur and try to examine the remains afterwards to see what happened, or we can make sure the compiler knows that these conditions shouldn't occur. Then when they do, our app can respond with a nice, textual description of what occurred, along with the .pas file and the line number where it occurred.
Solution: Assertions
An Assertion is a feature of the Object Pascal language which basically let you assert, or proclaim, that a particular boolean condition should be true at this point in your code. If in fact it is not true, then an exception is raised. However, this is a rather special exception, as it carries extra information with it, such as the source location where the condition failed. Let's have a look at how we use it:
procedure PrintList(nofItems : Integer; list : TList); begin while (nofItems > 0) and (list <> nil) do begin WriteLn(list.name); list := list.next; Dec(nofItems); end; Assert(list = nil, 'Supplied length does not match actual length'); end;
This example, from the Delphi Help, walks through a linked-list to what should, under normal circumstances, be the end of the list. However, instead of relying on normal circumstances, we have asserted that at the end of the procedure, the pointer to the next item in the list should be nil (in other words, we should be at the end of the list). In the case where it isn't we'll find out straight away, and with a bunch of information to help us track it down. One extra line of code has given us a big help in the instance that an abnormal situation occurs, and if it never occurs, we haven't wasted that much effort.
One last thing about Assertions. You have the ability to turn off assertions. What this means is that if you go into the Project Options dialog and turn them off, or use the {ASSERTIONS ON}{ASSERTIONS OFF} compiler defines, then the compiler will generate no code for the calls to Assert. If you're concerned about the performance hit in all these extra calls, then you can turn them off before shipping. Just be aware that you should make sure that your calls to Assert have no side effects that the rest of your code relies on. The boolean expression can contain calls to functions, just be careful what you do in those calls. If you turn off Assertions, those calls will no longer be made.
All that said, I tend to leave Assertions turned on, even in my shipping code. The minor performance hit that I incur is easily forgotten each time I get a bug report that tells me the unit, the line number and the problem that occurred. If only all bug reports where this complete.
"Hard to see, the Dark Side is"
Remember back at the beginning of this paper, I said that I was sure that people would disagree with some of what I say. Well, I had this section in mind when I said that. At other conferences when I've given this presentation, it's this section which generates the most discussion. I've had people tell me that the couldn't agree more and that after them, I must be one of the smartest people around, only to have the next person tell me that I'm a complete idiot and would probably benefit from a good beating. So, at the risk of more beatings, here goes...
An amazing 4% of their bugs were caused by a single, unnecessary feature of Object Pascal. Any guesses? Yep, the with statement.
Before I start criticizing the with statement, let me list it's advantages as I see them:
- Reduces typing
- Can clarify (maybe) some code that uses long variable names or object de-references several levels deep.
- If the way to get a reference to your object is time consuming, then a with statement can mean you only have to do that evaluation once, giving a performance boost.
Before we talk about the disadvantages, let look at this list. There is nothing here that can't be achieved another way, with little extra effort. If you want to reduce typing, the good folk in Scotts Valley gave us CodeInsight...use it. Long variable names and several levels of de-referencing is nothing that can't be solved with sensible use of local variable references. Ditto for the performance saving.
So if we can get all the benefits another way, all we have left is to look at the disadvantages. The first problem, and in my view, the only one you should need to hear, is that the with statement reduces the clarity of the scope of your code. Most things in Object Pascal are wonderfully explicit. Anything that causes you to pause, even momentarily, when reading your code to wonder what scope some operation is going to be executed in, is a bad thing. This feature reduces the readability of your code, increases the number of cycles your brain needs to understand what's going on, and all for benefits that could be achieved other ways! Have a look at the following piece of code:
with Listbox1.Items, Table1, Form1 do while not EOF do begin Insert(0, FieldByName('COUNTRY').AsString); Next; end;
There are a couple of differences between what the programmer intended and what he actually wrote. an important point to note, and one that a lot of developers find out the hard way, is that the operands of the with statement are evaluated right to left. So here are the problems:
- while the developer intended the Insert statement to relate to the Listbox.Items, the Table1 object also has an Insert method, and as it appears further to the right in the list of operands, it will be evaluated first. Thankfully the compiler will pick this one up for you, as the method signatures are different.
- Next was actually intended to be related to the Table1 object, but again, TForms have a Next method with the same signature, so this one will be a little more difficult to track down.
So let's pull the Table1 reference out of the with statement, and we'll end up with this:
with Listbox1.Items, Form1 do while not EOF do begin Insert(0, Table1.FieldByName('COUNTRY').AsString); Table1.Next; end;
Are we okay now? Ummm, unfortunately not. We've introduced a new bug by forgetting to put Table1 in front of the call to EOF. This one wasn't a bug before, but EOF will now resolve down to the EOF function in the System unit.
Granted, this was a contrived example, but these types of issues, and plenty of hairier ones, occur all the time to otherwise smart developers using the with statement. Also, consider the poor programmers who have to look at your code after you. You may be up to the challenge of deciphering your with statements, but spare a thought for the rest of us mere mortals.
Less importantly, but still a consideration, is that the with statement causes some very nice IDE features, like Tooltip Evaluation while debugging, to get confused. If Borland elected to remove the with statement from the Object Pascal language tomorrow, they'd get nothing but support from me. There, I've said it. Let the beatings commence.
Checkpoint
So, how are we going? So far, the techniques above reduced the bug rate by 16%. This may not sound like much, but consider that this 16% came pretty cheaply. Nothing we've talked about so far is really that hard or time-consuming. We've basically adjusted our attitude a little, and changed a few behaviours. But I'm sure you haven't sat through all my crappy jokes for 16%. How did they achieve over 60% bug rate reduction? Welcome to the last section.
"It worked last build!"
Problems:
- Code changes introduce/expose bugs in previously tested code.
- There is usually a long duration between introducing bugs and getting bug reports
- The majority of the time spent bug-fixing is spent finding the bug, not fixing it
You should know that the first point accounted for a staggering 40+% of the bugs in their current bug database. That is, these bugs occurred in code that had previously worked, and failed through changes to it or to other parts of the system. So, what are we going to do about this?
Solution # 1: A smarter compiler
What I really want is a compiler that not only checks that my code is syntactically correct, but that my logic is correct as well. Not asking for much, I know. As impressed as I am by the boffins in Scotts Valley, I'm pretty sure this one isn't going to pop up in Delphi 6.
But, pretend for a moment that you had this right now. That your compiler would give you errors when what you wrote wasn't what you meant to write. That it would raise a flag when your business logic was incorrect, or when you changed something that broke some other dependant piece of code. OK, everyone pretending?
How would this change the way you wrote code? Well for starters, as soon as you wrote some incorrect logic, you'd be alerted to the fact. No more having to remember what you were thinking last month when you coded this particular method. You'd know about it straight away, while all of your convoluted logic was still fresh in your mind. Also, the scope of the areas you'd need to look at would be reduced. If your code passed the logic-compiler 5 minutes ago, but now it's raising errors, you can be pretty sure it's something you did in the last five minutes.
In addition, you'd probably be much braver in the way you attack your code. No more would you hear the phrase "It's not broken so why fix it". We'd all suddenly become Indiana Jones, wading through our code, refactoring with a machete, cutting out great swathes of code and reimplementing more elegantly and concisely, confident that if we've broken anything that something else depends on, we'll know about it as soon as we compile.
I think I'd better calm down. I can hear you all muttering "Indiana Jones? What the hell is this guy on about?", but this compiler is what I want. No, I haven't got it. But I think I have the next best thing.
Solution #2 : Automated Unit Testing
Once we accept the fact that we don't have the compiler outlined above, we need to look at how we can get as many of the same benefits as possible. One method which has been gaining in popularity in the Java and Smalltalk worlds for a few years is Automated Unit Testing.
Before we get to far into Unit Testing, it's worth looking at what it isn't. Unit Testing is not Black Box testing. The tests should be written by the same developer who writes the class being tested. Typically, tests will include some that are designed to workout the class being tested based on knowledge of the internal implementation. This is by no means a requirement, but some people find it beneficial.
Also, Unit Testing is not Functional Testing. We are not testing that the software conforms with the Functional Specification, although it's certainly possible to do some functional testing this way.
What Unit Testing is, however, is a way to define a set of tests for a class in your system. You test the programmatic interface of your object. You put values into properties, call methods, and test that the results you receive are those you expect to receive. So, just as you may have in the past written a simple Delphi application that lets you plug values into a class you've written and display the results, your test case is simply an object that performs those same tasks. But instead of displaying the results for a human to confirm, we write code to automatically confirm that the result is what was expected. Typically you write
A few years ago, Kent Beck and Erich Gamma released a framework for Automated Unit Testing in Java called JUnit. JUnit has gone on to inspire other Unit Testing frameworks for various languages, including a number for Delphi. The Automated Unit Testing framework I'll use in these examples was very closely based on the JUnit framework, however the client for whom we originally developed this framework had some extra requirements in terms of the GUI presented, outputting test results to XML etc that made us develop our own. However, I encourage you to have a look at some of the other frameworks for Delphi, and find one that fits your style best. They all follow a fairly similar pattern, as they all basically started out as JUnit clones (including ours), so you should be able to pick any of them up fairly easily.
So how does it all work? Well, each class you wish to have tested has a corresponding test class, derived from TmtTestCase. If you install the framework source code provided with this paper, there are wizards to simplify the creation of Test Projects and Test Cases, but we'll come to that shortly. Once you have you TmtTestCase descendant, you'll want to override the Setup and TearDown methods to, as the names suggest, initialize and clean up your test environment respectively. In the simpel example provided where we are testing a TList class, this involves creating a couple of TList instances preloaded with some objects, and then in the TearDown method destroying them.
Once this is done, you can add as many Test procedures as you like to your TestCase, to test whatever features of the TList you wish. In this example, we're testing things like:
- Adding an item and seeing if the Item Count increases,
- Inserting an item into a specific position and seeing if it is indeed still there.
- Testing that when we walk past the end of the list an error occurs, etc
Your test procedures should take no parameters, their names should start with the word Test, and they should be declared as published . This way the testing framework can automatically run all your test procedures for you, using a combination of RTTI and some other assembler magic I borrowed from the VCL sourcecode.
The declaration of our test case is shown below:
TSampleListCase = class(TmtTestCase) private FEmpty: TList; FFull: TList; public procedure SetUp; override; procedure TearDown; override; published procedure TestAdd; procedure TestIndexTooHigh; procedure TestObjectType1; procedure TestWillGoWrong; procedure TestOopsAnError; end;
Let's have a look at one of the implementations of our test procedures:
procedure TSampleListCase.TestAdd; {This test checks if an added item is actually in the list.} var AddObject: TObject; begin AddObject := TObject.Create;
FEmpty.Add(AddObject); // FEmpty is an empty TList created in the SetUp method
// CHECKS {The assert statements are the checks to see if everything went OK. When an assert fails, it will end up in the TestResult as a failure.} Assert(FEmpty.Count = 1, 'List is not empty. It has ' + IntToStr(FEmpty.Count) + ' items in it.'); Assert(FEmpty.Items[0] = AddObject, 'Added Object is no longer there.'); end;
First, it performs some operations against the class we're testing. However, it then Asserts some things that should be true at this point, if the previous operations against the class we're testing behaved as expected. In this case, we're testing properties of the class itself, but really you can test whatever you need to to figure out if the operations we're successful. Testing a class that used TIniFile might also involve checking to see if the file was created on disk. Either way, if your test fails, an exception will be raised with details of the failure, and will be caught and logged by the framework. The rest of the tests will continue.
So the developer who is implementing the TList class in this example, can run his text case(s) simply by pressing F9, and know almost immediately whether anything he has added since the last run has broken any of the Test procedures. Also, this developer's test case can be added to the library of test cases for the entire project. This could then be run after every build to ensure that the developers test have passed, before the build goes to QA, or even as part of an automated build system that does all of this automatically.
have a look through the sample tests supplied with the framework to get a better idea of how it all works, but it's probably worth spending a bit more time on the How and Why of Automated Unit Testing, rather than just the What.
A Testing Process
In clients where we have introduced Automated Unit testing, we typically start them out working a certain way. They inevitably adapt the process once they are comfortable, but here's how we get them started.
Whether they have started their project or not, we first make it a "rule" of the Code Reviews that all new code written must have a test case. So, any new classes get a test case written for them, and any existing code that is touched must have a test case developed for it. Now, the first reaction tot his is "Wow, this is going to slow down development", and at first it does. But once developers are comfortable writing them, they usually get to a point where writing the test case and the class takes about the same time as writing the class used to. How can this work? Well, wherever possible we get developers to write their tests before they write the class they are testing. As backward as this sounds, it produces some interesting results.
Firstly, it forces developers to think about what the class needs to do, not how to do it. Put another way, it makes them focus on the interface to the class, not the implementation. They develop the test case based on whatever requirements documentation or specs they have. Once the test case is developed, the developer implements the class until the test case runs without error. The critical point here is that it's very easy to know when you are finished. You're finished when your test cases run without failure. No more gold-plating of classes and adding features that you might need one day. You implement what you need to meet the requirements you have and once your test case runs, you move on to the next task. This is where most of the time savings come from.
The other scenario is bug reports. When a bug is reported, the first thing we do is check-out the corresponding test case and write a test procedure that exposes the bug. Then we can work on the class until our test passes. Not only does it simplify bug reproduction, but your library of test cases becomes more rigorous over time.
The criticisms I often hear about Unit testing is that you can't possibly hope to test all scenario's, and if you could, the time taken to write all those tests would not be feasible. Well, until we have our magic Logic Compiler, we can't expect to cover every test scenario, but that doesn't mean we shouldn't test at all. You have to take a risk-based approach to writing your test cases. Cover the tests that you can reasonably expect will pop up, or that will be completely disastorous if they do pop up, then add to your tests over time as bugs you've missed show up. Cover boundary conditions and other obvious cases, then get on with your job. We've found we can dramatically improve our productivity with this approach, without getting bogged down writing page after page of test cases.
I don't expect to convince you of the benefits of Automated Unit Testing in this paper. I didn't think much of the idea when I first heard it. It wasn't until I was reading "Refactoring" by Martin Fowler, someone who I'd already decided was a pretty smart bloke after "UML Distilled" and "Analysis Patterns", and who basically says that Unit Testing is an essential part of Refactoring, and building reliable code in general, that I was finally convinced to give it a shot. After a couple of days of gradually getting comfortable, I was a convert. It becomes a very natural and intuitive way to work, so at least give it a try and decide for yourselves.
You can find more info at:
Conclusion
As I said back at the beginning of this paper, I don't expect you to suddenly adopt everything I've talked about in this paper. If you adopt one of these things I'll be a happy man. What I was mostly hoping to impress on you is that by a simple change in attitude, you can radically reduce the number of bugs that end up on your customers machines. Some of the techniques you'll come up with are simple, others, like the Automated Unit Testing require a fair commitment of effort before you see some results, but all of them have the potential to benefit your projects if you let them. Remember, bugs aren't a chore, they're an opportunity for more creativity on your part.
Download Source Code
|