TDD : Breaks all the existing test cases while refactoring the code

♀尐吖头ヾ 提交于 2019-12-06 10:54:53

问题


I have started following TDD in my project. But ever since I started it after reading some articles, I am bit confused since the development has slowed down. Whenever I refactor my code, I need to change the existing test cases I have written before because they will start failing. Following is the example:

    public class SalaryManager
    {
        public string CalculateSalaryAndSendMessage(int daysWorked, int monthlySalary)
        {
            int salary = 0, tempSalary = 0;
            if (daysWorked < 15)
            {
                tempSalary = (monthlySalary / 30) * daysWorked;
                salary = tempSalary - 0.1 * tempSalary;
            }
            else
            {
                tempSalary = (monthlySalary / 30) * daysWorked;
                salary = tempSalary + 0.1 * tempSalary;
            }

            string message = string.Empty;
            if (salary < (monthlySalary / 30))
            {
                message = "Salary cannot be generated. It should be greater than 1 day salary.";
            }
            else
            {
                message = "Salary generated as per the policy.";
            }

            return message;
        }
    }

But I found now I am doing lot of things in one method so to follow SRP Principle I refactored it to something like below:

    public class SalaryManager
    {
        private readonly ISalaryCalculator _salaryCalculator;        
        private readonly SalaryMessageFormatter _messageFormatter;
        public SalaryManager(ISalaryCalculator salaryCalculator, ISalaryMessageFormatter _messageFormatter){
            _salaryCalculator = salaryCalculator;
            _messageFormatter = messageFormatter;
        }

        public string CalculateSalaryAndSendMessage(int daysWorked, int monthlySalary)
        {
            int salary = _salaryCalculator.CalculateSalary(daysWorked, monthlySalary);
            string message = _messageFormatter.FormatSalaryCalculationMessage(salary);

            return message;
        }
    }

    public class SalaryCalculator
    {
        public int CalculateSalary(int daysWorked, int monthlySalary)
        {
            int salary = 0, tempSalary = 0;
            if (daysWorked < 15)
            {
                tempSalary = (monthlySalary / 30) * daysWorked;
                salary = tempSalary - 0.1 * tempSalary;
            }
            else
            {
                tempSalary = (monthlySalary / 30) * daysWorked;
                salary = tempSalary + 0.1 * tempSalary;
            }
            return salary;
        }
    }

    public class SalaryMessageFormatter
    {
        public string FormatSalaryCalculationMessage(int salary)
        {
            string message = string.Empty;
            if (salary < (monthlySalary / 30))
            {
                message = "Salary cannot be generated. It should be greater than 1 day salary.";
            }
            else
            {
                message = "Salary generated as per the policy.";
            }
            return message;
        }
    }

This may not be the greatest of the example. But what I meant here is as soon as I did the refactoring my existing test cases which I wrote for the SalaryManager started failing and I had to fix them using mocking.

This happens all the time in read time scenarios and the time of development increases with it. I am not sure if I am writing the write way of TDD. Please help to understand.


回答1:


Whenever I refactor my code, I need to change the existing test cases I have written before because they will start failing.

That's certainly an indication that something is going wrong. The popular definition of refactoring goes something like this

REFACTORING is a disciplined technique for restructuring an existing body of code, altering its internal structure without changing its external behavior.

Part of the point of having the unit tests, is that the unit tests are evaluating the external behavior of your implementation. A unit test that fails indicates that an implementation change has changed the externally observable behavior in some way.

In this particular case, it looks like you changed your API - specifically, you removed the default constructor that had been part of the API for creating instances of SalaryManager; that's not a "refactoring", it's a backwards breaking change.

There's nothing wrong with introducing new collaborators while refactoring, but you should do so in a way that doesn't break the current API contract.

public class SalaryManager
{
    public SalaryManager(ISalaryCalculator salaryCalculator, ISalaryMessageFormatter _messageFormatter){
        _salaryCalculator = salaryCalculator;
        _messageFormatter = messageFormatter;
    }

    public SalaryManager() {
        this(new SalaryCalculator(), new SalaryMessageFormatter())
    }

where SalaryCalculator and SalaryMessageFormatter should be implementations that produce the same observable behavior that you had originally.

Of course, there are occasions where we need to introduce a backwards breaking change. However, "Refactoring" isn't the appropriate tool for that case. In many cases, you can achieve the result you want in several phases: first extending your API with new tests (refactoring to remove duplication with the existing implementation), then removing the tests that evaluate the old API, and finally removing the old API.




回答2:


This problem happens when refactoring changes responsibilities of existing units especially by introducing new units or removing existing units.

You can do this in TDD style but you need to:

  1. do small steps (this rules out changes that extracts both classes simultaneously)
  2. refactor (this includes refactoring test code as well!)

Starting point

In your case you have (I use more abstract python-like syntax to have less boilerplate, this problem is language independent):

class SalaryManager:
    def CalculateSalaryAndSendMessage(daysWorked, monthlySalary):
      // code that has two responsibilities calculation and formatting

You have test class for it. If you don't have tests you need to create these tests first (here you may find Working Effectively with Legacy Code really helpful) or in many cases together with some refactoring to be able to refactor you code even more (refactoring is changing code structure without changing its functionality so you need to have test to be sure you don't change the functionality).

class SalaryManagerTest:
    def test_calculation_1():
      // some test for calculation

    def test_calculation_2():
      // another test for calculation

    def test_formatting_1():
      // some test for formatting

    def test_formatting_2():
      // another test for calculation

    def test_that_checks_both_formatting_and_calculation():
      // some test for both

Extracting calculation to a class

Now let's you what to extract calculation responsibility to a class.

You can do it right away without changing API of the SalaryManager. In classical TDD you do it in small steps and run tests after each step, something like this:

  1. extract calculation to a function (say calculateSalary) of SalaryManager
  2. create empty SalaryCalculator class
  3. create instance of SalaryCalculator class in SalaryManager
  4. move calculateSalary to SalaryCalculator

Sometimes (if SalaryCalculator is simple and its interactions with SalaryManager are simple) you can stop here and do not change tests at all. So tests for calculation will still be part of SalaryManager. With the increasing of complexity of SalaryCalculator it will be hard/impractical to test it via SalaryManager so you will need to do the second step - refactor tests as well.

Refactor tests

I would do something like this:

  1. split SalaryManagerTest into SalaryManagerTest and SalaryCalculatorTest basically by copying the class
  2. remove test_calculation_1 and test_calculation_1 from SalaryManagerTest
  3. leave only test_calculation_1 and test_calculation_1 in SalaryCalculatorTest

Now tests in SalaryCalculatorTest test functionality for calculation but do it via SalaryManager. You need to do two things:

  1. make sure you have integration test that checks that calculation happens at all
  2. change SalaryCalculatorTest so that it does not use SalaryManager

Integration test

  1. If you don't have such test already (test_that_checks_both_formatting_and_calculation may be such a test) create a test that does some simple usecase when calculation is involved from SalaryManager
  2. You may want to move that test to SalaryManagerIntegrationTest if you wish

Make SalaryCalculatorTest use SalaryCalculator

Tests in SalaryCalculatorTest are all about calculation so even if they deal with manager their essence and important part is providing input to calculation and then check the result of it.

Now our goal is to refactor the tests in a way so that it is easy to switch manager for calculator.

The test for calculation may look like this:

class SalaryCalculatorTest:

    def test_short_period_calculation(self):
       manager = new SalaryManager()
       DAYS_WORKED = 1
       result = manager.CalculateSalaryAndSendMessage(DAYS_WORKED, SALARY)
       assertEquals(result.contains('Salary cannot be generated'), True)

There are three things here:

  1. preparation of the objects for tests
  2. invocation of the action
  3. check of the outcome

Note that such test will check outcome of the calculation in some way. It may be confusing and fragile but it will do it somehow. As there should be some externally visible way to distinguish how calculation ended. Otherwise (if it does not have any visible effect) such calculation does not make sense.

You can refactor like this:

  1. extract creation of the manager to a function createCalculator (it is ok to call it this way as the object that is created from the test perspective is the calculator)
  2. rename manager -> sut (system under test)
  3. extract manager.CalculateSalaryAndSendMessage invocation into a function `calculate(calculator, days, salary)
  4. extract the check into a function assertPeriodIsTooShort(result)

Now the test has no direct reference to manager, it reflects the essence of what is tested.

Such refactoring should be done with all tests and functions in this test class. Don't miss the opportunity to reuse some of them like createCalculator.

Now you can change what object is created in createCalculator and what object is expected (and how the check is done) in assertPeriodIsTooShort. The trick here is to still control the size of that change. If it is too big (that is you can't make test green after the change in couple minutes in classical TDD) you may need to create a copy of the createCalculator and assert... and use them in one test only first but then gradually replace old with one in other tests.



来源:https://stackoverflow.com/questions/49350993/tdd-breaks-all-the-existing-test-cases-while-refactoring-the-code

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!