问题
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:
- do small steps (this rules out changes that extracts both classes simultaneously)
- 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:
- extract calculation to a function (say
calculateSalary
) ofSalaryManager
- create empty
SalaryCalculator
class - create instance of
SalaryCalculator
class inSalaryManager
- move
calculateSalary
toSalaryCalculator
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:
- split
SalaryManagerTest
intoSalaryManagerTest
andSalaryCalculatorTest
basically by copying the class - remove
test_calculation_1
andtest_calculation_1
fromSalaryManagerTest
- leave only
test_calculation_1
andtest_calculation_1
inSalaryCalculatorTest
Now tests in SalaryCalculatorTest
test functionality for calculation but do it via SalaryManager
. You need to do two things:
- make sure you have integration test that checks that calculation happens at all
- change
SalaryCalculatorTest
so that it does not useSalaryManager
Integration test
- 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 fromSalaryManager
- 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:
- preparation of the objects for tests
- invocation of the action
- 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:
- extract creation of the
manager
to a functioncreateCalculator
(it is ok to call it this way as the object that is created from the test perspective is the calculator) - rename
manager
->sut
(system under test) - extract
manager.CalculateSalaryAndSendMessage
invocation into a function `calculate(calculator, days, salary) - 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