Sunday, October 3, 2021

TDD vs Test-last

TDD

First of all TDD can’t be properly applied if you have no idea what the problem is and you don’t know how to solve it.

So an overview should be done before applying TDD.

  • Analyze the problem.
  • Go through the related existing code if present.
  • Identify where the new functionality should be called from and how.
  • Define a list of test cases.

TDD process can be depicted by the below diagram


Let's consider each stage separately.

  • Red

    • Write a test (with expectation, ie assertions).

    • Make sure to write necessary production code just to make it compiler friendly.

    • So the project should compile successfully and fail the newly introduced test.

  • Green

    • Now it’s time to modify the production code to make the test pass.

    • No new functionality other than the covered by the written test should be introduced.

  • Refactor

    • Here we can improve the relevant production code as long as we don’t introduce new functionality which are not covered by tests.

    • We can improve production code(removing code duplications, renaming) as well as test code (improving readability of tests) in this stage.

    • This is the opportunity to improve the code.


Above process happens repetitively until the requirement is satisfied by the production code.

Test Last

This is the traditional approach in most code bases. There is an existing code base, even a legacy code. Developers write tests to simply pass the existing code. As a result writing tests are more expensive as well as tests are relatively lower in quality.

Problem

There is a requirement to develop a broadband bill calculation component for a leading telecommunication company. There are 2 packages with respective monthly rentals and data limits. 250 LKR should be charged on each exceeding GB.

  • Lite downloader ( 500 LKR, 1 GB)

  • Mega downloader (750 LKR, 3 GB)

10% of telecommunication levy should be added to each package.


Now let's discuss the solution for this problem using TDD approach first. As we have to follow the above diagram iteratively I’ll number each iteration. Also I’ll use junit to write unit tests.

Solution

Write a test (stage 1).

Make sure that the test should cover just 1 path of the production code.


@Test
public void calculate_bill_for_lite_package_when_the_limit_is_not_exceeded() {
String pkg = "lite";
float excessAmount = 0;
BillCalc billCalc = new BillCalc();
float actualBill = billCalc.calc(pkg, excessAmount);
float expectedAmount = 550.0f ; // 500 + 500 * (10/100.0f) ;
float delta = 0;
assertEquals(expectedAmount , actualBill , delta);
}

Please note that I have instantiated a BillCalc object while the class is not defined yet. Now it's my responsibility to satisfy the compiler. So I have to define the class and write the calc() method with minimal implementation to avoid the compile error. As a result the production code should look like below.

public class BillCalc {
public float calc(String dataPackage, float excessAmount) {
return 0; //minimal code to satisfy the compiler
}
}

Now there are no compile errors. And we can execute our test.


Congratulations! We got a failing (Red) test. Now we are done with the stage 1 of the 1st iteration in the TDD process.

Write production code to support the written test (stage 2)


public float calc(String dataPackage, float excessAmount) {
    float finalBill = 0;
    if (dataPackage.toLowerCase().equals("lite")) {
        float rental = 500;
        float excess = excessAmount * 250.0f;
        float total = rental + excess;
        finalBill = total + total * (10 / 100.0f);
    }
    return finalBill;
}

We wrote the production code just to pass the written test according to the specification as well as we did not introduce new features which are not covered by written test (s). Let’s run the test again to check whether the test case is Green.


The test is passing now! This means that we are done with the stage 2 of the 1st iteration as well.

Refactor the code (stage 3)


I realize that the written code is not perfect here. As we are currently in the refactoring stage, We can improve the production code. Here I would like to replace the if statement with a switch and extract a few constants and use them in production code as well as test code to improve the readability.

Refactored production code.


  
public class BillCalc {
    public final static String LITE_PACKAGE = "lite";
    public final static float LITE_PACKAGE_RENTAL = 500.0f;
    public final static float EXCESS_GB_CHARGE = 250.0f;

    public float calc(String dataPackage, float excessAmount) {
        float finalBill = 0;
        switch (dataPackage) {
            case LITE_PACKAGE:
                float excess = excessAmount * EXCESS_GB_CHARGE;
                float total = LITE_PACKAGE_RENTAL + excess;
                finalBill = total + total * (10 / 100.0f);
                break;
        }
        return finalBill;
    }
}

Refactored test code.


 
@Test
public void calculate_bill_for_lite_package_when_the_limit_is_not_exceeded() {
    float excessAmount = 0;
    BillCalc billCalc = new BillCalc();
    float actualBill = billCalc.calc(BillCalc.LITE_PACKAGE, excessAmount);
    float expectedAmount = 550.0f; // 500 + 500 * (10/100.0f) ;
    float delta = 0;
    assertEquals(expectedAmount, actualBill, delta);
}

See how easy it is to refactor the code without any fear! Because we are modifying the code which is already covered by the written test(s) so the tests would alert us when we do something wrong with the production code. In the test-last approach this will not be such an easy task due to the lack of test coverage and there is a good possibility that we refactored an uncovered path!

Let’s execute the test again to check whether we have broken the tests by refactoring the code.


Great! We are done with the 1st iteration of the TDD process.

I am going to follow the same process and write more test with production code to fulfill the requirement.

Completed code can be found on below gists.

Tests

https://git.io/v6aR6


Production code

https://git.io/v6aET


Maintainability


Assume that after a long time the telecommunication company wants to give a promotion that Mega downloader package users get additional 1GB free. And the initial developer who wrote the billing component has left the company. As we have used TDD in our solution, the new developer can easily introduce the new feature without breaking the existing billing logic because the old code is covered by the existing test suite.

It will be much harder to do this change if the codebase is maintained using the traditional approach. Lots of regression tests should be conducted to ensure the old logic is not broken.

Discussion


Having a production code covered by a comprehensive test suite allows us to refactor code and introduce new features without a fear of breaking existing functionality. If we break something we will get failing tests as we have a good unit test coverage. We don’t have this advantage in the traditional test-last approach.

As we know enhancements become harder or much more costly when the lifetime of the codebase increases.

Initially TDD seems to be a burden because we have to write tests even for the tiniest thing we introduce to the code. But in the long term the codebase becomes more maintainable and testable.

By using TDD we have reduced this impact in Pagero code bases. Test suite acts as a live documentation. New features and refactorings to the codebase can be done with confidence. Also lots of bugs can be identified at the developer level.








0 comments:

Post a Comment

© kani.stack.notez 2012 | Blogger Template by Enny Law - Ngetik Dot Com - Nulis