Bug fixing

Bugs are all but inevitable despite our best efforts.

And at the end of the last section we found our first one. So how do we go about fixing bugs? And more importantly how do we ensure that once we fix bugs, they can't come back to bite us again? As you might have guessed, the answer starts and ends with tests!

When it comes to fixing bugs, ideally we should be writing tests at the edge of our system. Taking our endpoint for example, the test which targets the bug should be written against the domain logic if possible i.e. the calculate_income_tax_owed() as opposed to the API endpoint itself. Remember the API endpoint might necessitate passing through various unrelated components like authentication, validation and serialization. This of course assumes that the bug is not located in 1 of those areas.

Our test suite should double up as a specification for how we want our systems to operate. This should include how we expect it to function under normal conditions, under failure conditions and also to cement into place adjustments to behaviour. These adjustments being bug fixes.


Writing the test to catch the bug

So lets write another test to capture the behaviour we want to add to our function:

class TestCalculateIncomeTaxOwed:
    ...
     
    @pytest.mark.parametrize(
        "salary",
        (0, 10, 100, 200, 1_000, 5_000, 12_568, 12_569),
    )
    def test_calculates_no_tax_when_salary_is_lower_than_personal_allowance(
        self, salary: int
    ):
        """
        Given a salary which lower than the personal allowance
        When `calculate_income_tax_owed()` is called
        Then the correct calculated tax owed of 0 is returned
        """
        # Given
        input_salary = salary

        # When
        calculated_tax_owed: int = calculate_income_tax_owed(salary=input_salary)

        # Then
        assert calculated_tax_owed == 0

Once again, we've parametrized a number of values within the contextual boundary that makes sense here i.e between £0 and £12,569. In any of these cases we expect the calculated tax owed to be £0.

If we run this test as is, it will fail due to the incorrect assumptions we'd already made in our implementation.


Implement the fix

Now that we have specified the behavior we want. We can now safely go and implement the solution. Having the test in place sets up our finish line, we don't have to worry about when we feel like the functional part of the solution is ready because the test will tell us.

def calculate_income_tax_owed(salary: float) -> float:
    tax_free_allowance = 12_570
    basic_rate_threshold = 50_270
    
    if salary <= tax_free_allowance:
        return 0

    if salary <= basic_rate_threshold:
        return (salary - tax_free_allowance) * 0.20

    return (basic_rate_threshold - tax_free_allowance) * 0.20 + (
        salary - basic_rate_threshold
    ) * 0.40

For now lets be really crude and just add another if statement before any of the calculations can be performed one line 5.

This function feels like it is starting to get out of hand. But that's okay for now.

The beauty of layering in all these tests into place means that if we want to go back and refactor things later on then we can do so easily and safely in the knowledge that our refactoring has not inadvertently broken functionality that we wanted.

Our tests are starting to feel like a safety net, they are now there to catch us from making any silly mistakes and in this case also to make sure that bugs are not repeated.

If we run the test now, we'll see it passes:

============================= test session starts ==============================
collecting ... collected 8 items

test_main.py::TestCalculateIncomeTaxOwed::test_calculates_no_tax_when_salary_is_lower_than_personal_allowance[0] PASSED [ 12%]
test_main.py::TestCalculateIncomeTaxOwed::test_calculates_no_tax_when_salary_is_lower_than_personal_allowance[10] PASSED [ 25%]
test_main.py::TestCalculateIncomeTaxOwed::test_calculates_no_tax_when_salary_is_lower_than_personal_allowance[100] PASSED [ 37%]
test_main.py::TestCalculateIncomeTaxOwed::test_calculates_no_tax_when_salary_is_lower_than_personal_allowance[200] PASSED [ 50%]
test_main.py::TestCalculateIncomeTaxOwed::test_calculates_no_tax_when_salary_is_lower_than_personal_allowance[1000] PASSED [ 62%]
test_main.py::TestCalculateIncomeTaxOwed::test_calculates_no_tax_when_salary_is_lower_than_personal_allowance[5000] PASSED [ 75%]
test_main.py::TestCalculateIncomeTaxOwed::test_calculates_no_tax_when_salary_is_lower_than_personal_allowance[12568] PASSED [ 87%]
test_main.py::TestCalculateIncomeTaxOwed::test_calculates_no_tax_when_salary_is_lower_than_personal_allowance[12569] PASSED [100%]

============================== 8 passed in 0.24s ===============================

Process finished with exit code 0

Moving forward

Our logic for calculating income taxes is still missing some key features. In particular, the core functionality of calculating for additional rate tax payers has not been added. This is for salaries over £125,140.

This would be a great chance for you to dig in and build out the implementation for this by yourself.

You can find the code for this chapter at the Github repo.

Last updated