TDD and Tech Debt

Ask you this: how do you spot a beginner programmer?

One (clearly experienced) programmer said, “Give the guy a problem, if he starts coding he’s a beginner, if he grabs a pencil and a paper he’s a seasoned programmer.” Some stand up and start pacing around instead, but the principle is there: experienced programmers known that you first need to understand the problem properly, and only then implement the solution.

Well, test-driven development adds another step in between: write the tests to check that the solution works before you implement it. And besides ensuring that the code actually works as expected, this has an interesting side effect.

Improved Analysis

The exercise of writing a unit test is driven by functionality. The developer establishes a set of conditions and then it checks that the result matches the expectations. This exercise forces the developer to consider the problem and the expected solution very carefully, but from an unusual perspective: that of the user. By user here we mean someone or something that relies on this functionality, it could be real (human) user or another component of the application. And when the developer goes through this process, some questions will come to his or her mind:

  • What should the conditions be?
  • Could the conditions be any different?
  • What should happen if the conditions are not met?
  • Is there a better way to implement or structure the solution?

Start-coding-right-away programmers seldom consider any of these, and thus come up with sub-optimal, optimistic implementations. Sub-optimal because they never get a chance to question whether their first idea for a solution was the right one. Optimistic because they simply don’t consider the many possible paths to disaster. But that’s perfectly normal: no one can decide on the best solution and foresee all possible weaknesses from the start. Code needs to be strained, stretched, pushed to the limits to become better, and TDD provides the perfect tools to reach that stage earlier.

That’s not news, everyone knows that. But there’s another side effect that not so many people notice.

Less Technical Debt

Has your boss ever told you “just get it working, we’ll fix it properly later”? Of course he has. And then you thought “yeah, right, we’ll never get around to fixing it and then I’ll bite us in the back…”. And you were probably right. Well, that thing has a name:

Technical debt is a concept in programming that reflects the extra development work that arises when code that is easy to implement in the short run is used instead of applying the best overall solution. – Wikipedia

And the name is very accurate, because like any other kind of debt, it becomes more expensive the longer you wait to pay it. Many refuse to believe it even exists, but the truth is that it is very real. The question is: what if you don’t want to pay it? Oh, you’ll pay it, in the form of a slow but continuous drop in development velocity, and an equally slow but continuous increase in the number of new bugs. It will bankrupt your product, slowly but surely.

Unless you start paying now. And test-driven development can help you with that.

The Problem: Evolution

Let’s pretend that you are building an application, and you need to calculate and store some values using an external service. You decide to use a Celery task for that, because you’re smart. After a few test-implement-test iterations you come up with following task:

@app.task
def do_something():
    results = {}
    client = SomeServiceClient()
    for res in client.get_items(None):
        ... 
        # process data and add to results
        ...
    CalculatedValues.objects.create(**results)

And the following test:

@mock.patch('some_service.SomeServiceClient.get_items')
def test_do_something(self, get_items_mock):
    get_items_mock.return_value = [
        ...
        # Mocked data with some edge cases
        ...
    ]
    do_something.apply()
    cv = CalculatedValues.objects.last()
    self.assertEqual(cv.some_field, 56)
    ...
    # More checks here

Excellent, you can call it a day. But of course that won’t scale in time as the number of items grows, you need to limit that in some way. So you add an optional parameter to limit the dates, which will be used sometimes:

@app.task
def do_something(start_date=None):
    results = {}
    client = SomeServiceClient()

    querystring = start_date and 'date >= {}'.format(start_date.isoformat()) or None
    for res in client.get_items(querystring):
        ... 
        # Complicated logic to process data and add to results
        ...
    CalculatedValues.objects.create(**results)

Your test now checks with what values get_items was called, to make sure the query string was built properly, and executes the task (at least) twice:

@mock.patch('some_service.SomeServiceClient.get_items')
def test_do_something(self, get_items_mock):
    get_items_mock.return_value = [
        # Mocked data
    ]
    do_something.delay()
    cv = CalculatedValues.objects.last()
    get_items_mock.assert_called_once_with(None)
    get_items_mock.reset_mock()
    self.assertEqual(cv.some_field, 56)
    ...
    # More checks here
    do_something.delay(date(2015, 10, 10)
    cv = CalculatedValues.objects.last()
    get_items_mock.assert_called_once_with('date >= 2015-10-10')
    ...
    # More checks here

Not too troublesome. But now somebody decides that you need to also filter by status of the items:

@app.task
def do_something(start_date=None, status_choices=None):
    result = {}
    client = SomeServiceClient()

    qs_parts = []
    if start_data:
        qs_parts.append('date >= {}'.format(start_date.isoformat()))
    if status_choices:
        qs_parts.append('status in ({})'.format(','.join(status_choices)))
    querystring = qs_parts and ' and '.join(qs_parts) or None

    for res in client.get_items(querystring):
        ... 
        # Complicated logic to process data and add to results
        ...
    CalculatedValues.objects.create(**results)

OK. That means your test now also needs to check the query string construction with date and some status, date only, status only and nothing at all; besides checking the item(s) that was (were) stored. I’m not going to write that, but you get where I’m going with this, don’t you? Your test is growing a lot.

And you’re not done yet, because a few weeks later you discover that you need to expand the data, and in those cases you want to page the results to avoid eating up all the memory because the API is very generous with information:

@app.task
def do_something(start_date=None, status_choices=None, expand_data=False):
    # Result dict and client
    results = {}
    client = SomeServiceClient()

    # Build querystring
    qs_parts = []
    if start_data:
        qs_parts.append('date >= {}'.format(start_date.isoformat()))
    if status_choices:
        qs_parts.append('status in ({})'.format(','.join(status_choices)))
    querystring = qs_parts and ' and '.join(qs_parts) or None

    # Process items, expanded or not
    if expand_data:
        ix = 0
        # Paged, get and process until page is empty
        while True:
            items = client.get_items(querystring, expand='data', index=ix)
            if not items:
                break
            for item in items:
                ... 
                # Complicated logic to process data and add to results
                ...
            ix += 50

    else:
        # Not paged, get and process everything
        for res in client.get_items(querystring):
            ... 
            # Complicated logic to process data and add to results (again!)
            ...
    CalculatedValues.objects.create(**results)

Oh, you’re starting to get code duplication, not good. Not to mention that testing this is quite difficult by now, since you need to consider all the combinations in parameters to make sure that you’re testing all the edge cases (but you know that you probably aren’t). You know what you need to do, don’t you? Something you should have done two iterations earlier, when adding the query string construction…

The Solution: Refactor

Instead of using a function task, you can use a class-based task and move the main functionality to class methods:

class DoSomething(Task):

    @staticmethod
    def build_querystring(start_date, status_choices)
        ...

    @staticmethod
    def iter_items(querystring, paging):
        client = SomeServiceClient()
        ...
            # crazy logic to get items, paged or not
            yield item

    @staticmethod
    def process_item(item):
        data = {}
        ...
        # Complicated logic to process data
        ...
        return data

    @staticmethod
    def append_results(item_data, results):
        ...
        # Complicated logic to add data to results
        ...
        return results

    def run(start_date=None, status_choices=None, page_size=None):
        result = {}
        querystring = self.build_querystring(start_date, status_choices)
        for item in self.iter_items(querystring, page_size):
            data = self.process_item(item)
            self.append_results(data, results)
        CalculatedValues.objects.create(**results)

Now you can test the construction of the query string in one test case, the iteration of results in another one, the processing of data in another ones, the addition of results in another one, and the execution of the task in another one (mocking the other methods) to check the integration. Each test will be simple, with few assertions, and likely to be more thorough, since you’ll probably (read definitely) test more edge cases. Win-win.

TDD Raises the Alarm

That’s how test-driven development helps with reducing technical debt: by highlighting things that are just not testable, you’ll be persuaded to refactor the implementation to make testing easier. And luckily for you, code that is easy to test is also better in almost any measure.

A Note on Debugging

Debugging is difficult. And necessary, because no matter how fantastic your tests are, you will miss something. Someone or something will manage to break your functionality somehow. But that’s OK, because you’ll fix it and your product will become more robust, only not just yet:

Rule 1 of Debugging: Once you’ve identified a bug, reproduce it in an automated test before fixing it.

Always, without exceptions. Yes, I know that you’re desperate to fix it and ship right away, but if you don’t want to see this bug again, you have to automate the conditions that lead to it, and only then fix it. Otherwise it will manage to creep back to the code base undetected. Bugs are very sneaky, you know that. They’re little ninja poltergeists of mischief.

Final Words

In short, test-driven development will save your life. Or at least, make it a lot easier. So start doing it. Now.

Leave a comment