Why do estimates keep growing?
Let’s develop software the pre-agile way! Our application will be an Enterprise Food Store Shopping Cart Total Price Calculator, which obviously will be used to figure out how much we are going to pay for some bread and butter for our sandwiches at the supermarket. We are going to develop our software real fast, which means we will not involve big up-front planning and we will not waste our time on unit testing, since for such a trivial application, bothering with the tests instead of the features just slows us down and clutters our code with unnecessary indirections, right?
The language is going to be Python, just to keep things simple. (Sorry PHP. :-P)
We are going to buy different kinds of food. Every kind has its own price which is specified as Hungarian Forint (HUF) per unit which can be kilograms for some kinds, pieces for others, etc. Of course the actual price for a given item in our shopping cart will be the amount (ie. number of units) bought multiplied by the unit price, and the total price will be the sum of all those prices. That’s just a
for loop and elementary math, no surprises expected.
Fast software development
We will need a simple data structure for the kinds of food to store unit prices. We are going to use integers since we have no smaller units of money than HUF in Hungary since decades. Some say value objects are cool, so we are going to use a simple immutable data structure like this:
class Food(object): def __init__(self, unit_price): self.unit_price = int(unit_price) def getUnitPrice(self): return self.unit_price
We need to represent individual items in the shopping cart in order to simplify the overall price calculation. Smells like another data structure:
class ShoppingCartItem(object): def __init__(self, kind, amount): self.amount = float(amount) self.kind = kind def getPrice(self): return self.kind.getUnitPrice() * self.amount
Note that the amount can be an arbitrary real number, for example we might want to buy 0.5 kg of onions. On the contrary, 0.5 piece of bun should not be allowed, but let’s just ignore that for now. We might develop validation of such constraints when users start complaining about them.
Finally, we need to calculate the total price:
class TotalCalculator(object): def total(self, shopping_cart_items): total = 0 for item in shopping_cart_items: total += item.getPrice() return total
We never said we wouldn’t test at all:
def main(): onion = Food(100) cucumber = Food(200) tomatoe = Food(300) items = [ ShoppingCartItem(onion, 1), ShoppingCartItem(cucumber, 2), ShoppingCartItem(tomatoe, 3) ] total = TotalCalculator().total(items) print "Total: ", total, " HUF" if __name__ == "__main__": main()
We expect it to output 100 * 1 + 200 * 2 + 300 * 3 = 1400 HUF, and it works just fine.
Overall development time: a few minutes, maybe less. That’s what I call fast!
Turns out our Enterprise Food Store Shopping Cart Total Price Calculator becomes so successful we start to get feature requests from a growing number of users after a couple of weeks.
Base 5 rounding
In Hungary, we don’t use some small units of our money when paying in cash because of some economic or political crap I don’t happen to give a duck about. Actually, we don’t have 1 HUF nor 2 HUF coins at all, the smallest value expressable in cash is 5 HUF. So we round everything in base 5. For example:
- 11 HUF and 12 HUF are rounded downwards to 10 HUF,
- 13 HUF and 14 HUF are rounded upwards to 15 HUF,
- 16 HUF and 17 HUF are rounded downwards to 15 HUF,
- 18 HUF and 19 HUF are rounded upwards to 20 HUF.
Now our users want us to implement these rules, and we are happy to do so, it’s just a few more lines of code, pretty harmless:
class TotalCalculator(object): def round_in_base_five(self, price): remainder = price % 5 if remainder <= 2: return price - remainder return price + 5 - remainder def total(self, shopping_cart_items): total = 0 for item in shopping_cart_items: total += self.round_in_base_five(item.getPrice()) return total
Impossible to screw up, but just to be sure, let’s modify our little test to see the new function working. Just create a food kind with a price of 1 HUF/kg, and see how it’s price is rounded when we buy 10 kg, 11 kg, 12 kg of it and so on:
def main(): onion = Food(1) for i in range(10, 21): total = TotalCalculator().total([ ShoppingCartItem(onion, i) ]) print i, " ~ ", total
This code outputs the rules exactly as specified, so we’re ready. Development time: one more minute. What a professionalism here!
Ooops, a bug there!
Oh, well, a couple of days or maybe weeks later a user will complain that if onions cost 121 HUF/kg and we buy just 0.1 kg of them then the application will calculate 15 HUF instead of 10 HUF as the price. The total price should be 10 because 121 * 0.1 = 12.1 which when rounded to integers (remember, there are no cents for HUF) yields 12 which should be rounded downwards in base five. After some time of debugging (maybe comparable to the total cost of the initial development of the rounding feature, though) we find out that we forgot to round the price to integers before doing the base 5 rounding. A simple fix for that:
def round_in_base_five(self, price): price_int = round(price) remainder = price_int % 5 if remainder <= 2: return price_int - remainder return price_int + 5 - remainder
Does it work?
def main(): onion = Food(121) total = TotalCalculator().total([ ShoppingCartItem(onion, 0.1) ]) print "Total: ", total, " HUF"
That outputs 10 HUF, we’re done.
A month later we promise to some of our users that we develop support for taxing rules, since different kinds of food can have different rates of taxes. It seems to be just another property for our
Food data structure and another multiplication in
getPrice(). We bet we can write that code in seconds!
But during testing we find some interesting behavior: when onion costs 102 HUF/kg and cucumber costs 202 HUF/kg and we buy 1 kg of each, the total price is calculated to be 300 HUF (without taxes), but 102 + 202 = 304 which should be rounded upwards to 305. We revert our taxing related changes but the behavior remains reproducable, so it’s not a regression introduced during the development of taxing. After a short debugging session (just for curiosity) we find that
total() applies the base five rounding inside the for loop instead of rounding the total price only:
def total(self, shopping_cart_items): total = 0 for item in shopping_cart_items: total += self.round_in_base_five(item.getPrice()) return total
Commit messages in the version control system don’t tell anything special about that line, the method seems to be unmodified since the introduction of the rounding feature, so we scan through our months old emails to find that PDF containing the feature request and the exact requirements about rounding, in the hope that it will help us remember why it’s inside the loop. Sadly our documentation seems to be lacking that information. The game is two-fold now: either it’s a bug, maybe a misunderstanding of some ambiguity in the specs, or an intended behavior our users requested back then. In short, we have no evidence, we need to talk to a domain expert.
Development time of the taxing feature: oh, so we were developing a feature before we started digging through aeons old docs to find out if a strange behavior is a bug or a feature! Yeah, those took about 20 minutes altogether instead of our original estimations of a few seconds. And by the way: how exactly are we supposed to calculate taxes? Should they be based on the integer rounded price or the real number (real as in Math)? How do they relate to base 5 rounding? (Answers for these questions may also require some rounding related code to be moved to
Retrospective: uhm, well, if we gave up on that former issue right when we found out it’s not a regression, then we might have been able to meet our original estimations. We promise ourselves next time we will just file a new bug in our bug tracker, which we can fix later. Except when such a bug makes the new feature untestable. Remember, we need to move fast, so we have no time to investigate bugs in such detail or even fix them. And to make things worse, such investigations may also question how existing features do and how they should work, and answering those questions will surely require even more time!
Our code consists of 3 very simple classes (2 of which are stupid data structures), and all it does is evaluating a simple formula using a for loop and a conditional statement. Yet we had 2 bugs in it, one of which seems to produce an endless stream of questions about almost everything we’ve done so far, leading us to distrust our own code we were so proud of once.
On the other hand, if we found a testcase somewhere in our code named
test_base_5_rounding_is_applied_to_individual_items() or similar, we would be able to make some assumptions that we could use to make our life easier. For example we’d know that at some point of development it was considered expected behavior somebody thought of while developing the feature. It may have been reviewed by customers as well when we presented them the list of requirements we implemented and are happy to retest anytime a new build is created (e.g. by our CI system through automated, quickly running tests) to make sure we never loose them. We may alter that behavior of course if people’s minds changed since then (starting with renaming the testcase, then changing the expected values in it), but this would also be a well-thought decision. Besides, reading through the names of testcases in the unit tests of
TotalCalculator is surely easier than finding that email, PDF, wiki page or whatever documentation which provides an exact, formal and detailed specification. You may even think of your tests as a so exact, so formal and so detailed specification that they can be run on a computer to verify the correctness of the system. Beat that with emails and wiki pages!
We turned a very simple, few eLoC source code into a rotting maintenance hell in minutes of effective work through a short series of seemingly rational, closely reasoned decisions and nice and mostly clear specifications. Now imagine how many times similar mistakes we made throughout this little experiment can be done during the development of huge and complex systems and how the effects can add up! For me, that question about growing estimates (and still missed deadlines) in legacy code base (by definition: code lacking low level automated tests) is answered.
The Why do estimates keep growing? by athoshun, unless otherwise expressly stated, is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.