TDD:在重构代码

时间:2018-03-18 17:40:16

标签: c# tdd

我已经开始在我的项目中使用TDD了。但是自从我在阅读了一些文章后开始阅读,我感到有点困惑,因为开发速度已经放缓。每当我重构代码时,我都需要更改之前编写的现有测试用例,因为它们会开始失败。     以下是示例:

    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;
        }
    }

但我现在发现我在一种方法中做了很多事情,所以遵循SRP原则我将它重构为如下所示:

    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;
        }
    }

这可能不是最好的例子。但我在这里的意思是,一旦我完成了重构,我为SalaryManager编写的现有测试用例开始失败,我不得不使用模拟来修复它们。

这在读取时间场景中始终发生,并且开发时间随之增加。我不确定我是否正在编写TDD的写入方式。请帮助理解。

3 个答案:

答案 0 :(得分:0)

  

每当我重构代码时,我都需要更改之前编写的现有测试用例,因为它们会开始失败。

这肯定表明出现了问题。重构的流行定义类似于this

  

REFACTORING是一种严格的技术,用于重构现有的代码体系,改变其内部结构而不改变其外部行为。

进行单元测试的部分原因是单元测试正在评估实现的外部行为。失败的单元测试表明实现更改以某种方式改变了外部可观察行为。

在这种特殊情况下,您似乎更改了API - 具体而言,您删除了默认构造函数,该构造函数已成为创建SalaryManager实例的API的一部分;这不是“重构”,而是一个向后突破的变化。

在重构时引入新的协作者没有任何问题,但是你应该以不违反当前API合同的方式这样做。

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

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

其中SalaryCalculatorSalaryMessageFormatter应该是产生与您原来相同的可观察行为的实现。

当然,在某些情况下,我们需要引入向后突破性的变化。但是,“重构”不适合该案例。在许多情况下,您可以在几个阶段中实现所需的结果:首先使用新测试扩展API(重构以删除与现有实现的重复),然后删除评估旧API的测试,最后删除旧API。

答案 1 :(得分:0)

当重构更改现有单位的职责时,特别是通过引入新单位或删除现有单位,就会出现此问题。

您可以使用TDD样式执行此操作,但需要:

  1. 执行小步骤(这排除了同时提取两个类的更改)
  2. 重构(这包括重构测试代码!)
  3. 起点

    在你的情况下,你有(我使用更抽象的python类语法来减少样板,这个问题与语言无关):

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

    你有测试类。如果你没有测试,你需要先创建这些测试(这里你可能会发现Working Effectively with Legacy Code非常有帮助),或者在很多情况下还有一些重构,以便能够重构代码甚至更多(重构正在改变)代码结构而不改变其功能,因此您需要进行测试以确保您不会更改功能)。

    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
    

    将计算提取到类

    现在让您了解如何将计算责任提取到班级。

    您可以在不更改SalaryManager的API的情况下立即执行此操作。在经典TDD中,您可以在很小的步骤中完成并在每个步骤之后运行测试,如下所示:

    1. 将计算提取到calculateSalary
    2. 的函数(比如SalaryManager
    3. 创建空SalaryCalculator
    4. SalaryCalculator
    5. 中创建SalaryManager类的实例
    6. calculateSalary移至SalaryCalculator
    7. 有时(如果SalaryCalculator很简单并且与SalaryManager的交互很简单),您可以在此处停止并且根本不更改测试。因此,计算测试仍将是SalaryManager的一部分。随着SalaryCalculator复杂性的增加,通过SalaryManager测试它将是困难/不切实际的,所以你需要做第二步 - 重构测试。

      重构测试

      我会做这样的事情:

      1. 基本上通过复制班级将SalaryManagerTest拆分为SalaryManagerTestSalaryCalculatorTest
      2. test_calculation_1
      3. 删除test_calculation_1SalaryManagerTest
      4. 仅在test_calculation_1
      5. 中留下test_calculation_1SalaryCalculatorTest

        现在测试SalaryCalculatorTest测试功能以进行计算,但是通过SalaryManager进行测试。你需要做两件事:

        1. 确保您进行了集成测试,以检查所有计算是否发生
        2. 更改SalaryCalculatorTest,以便它不会使用SalaryManager
        3. 集成测试

          1. 如果您还没有进行此类测试(test_that_checks_both_formatting_and_calculation可能是这样的测试),请在SalaryManager
          2. 进行计算时创建一个简单用例的测试
          3. 如果您愿意,可能需要将该测试移至SalaryManagerIntegrationTest
          4. 使SalaryCalculatorTest使用SalaryCalculator

            SalaryCalculatorTest中的测试都是关于计算的,所以即使他们处理经理他们的本质,重要的部分是为计算提供输入,然后检查结果。

            现在我们的目标是以某种方式重构测试,以便很容易将管理员换成计算器。

            计算测试可能如下所示:

            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)
            

            这里有三件事:

            1. 准备测试对象
            2. 调用行动
            3. 检查结果
            4. 请注意,此类测试将以某种方式检查计算结果。它可能会令人困惑和脆弱,但它会以某种方式做到这一点。因为应该有一些外部可见的方式来区分计算结束的方式。否则(如果它没有任何可见效果)这样的计算没有意义。

              你可以像这样重构:

              1. manager的创建提取到函数createCalculator(可以这样调用,因为从测试角度创建的对象是计算器)
              2. 重命名manager - &gt; sut(被测系统)
              3. manager.CalculateSalaryAndSendMessage调用提取到函数`calculate(calculator,days,salary)
              4. 将检查提取为函数assertPeriodIsTooShort(result)
              5. 现在测试没有直接引用管理器,它反映了测试内容的本质。

                这种重构应该使用此测试类中的所有测试和函数来完成。不要错过重用其中一些内容的机会,例如createCalculator

                现在,您可以在createCalculator中更改assertPeriodIsTooShort中创建的对象以及预期的对象(以及检查的完成方式)。这里的诀窍是仍然控制变化的大小。如果它太大(即在经典TDD中几分钟后更改后无法进行绿色测试),您可能需要创建createCalculatorassert...的副本并使用它们在一次测试中只先进行一次测试,然后在其他测试中逐渐替换旧测试。

答案 2 :(得分:0)

您的困惑是由于对单元测试的普遍误解。在某些时候,人们开始散布这样的神话,即一个单元就像一个单一的类甚至一个单一的方法。这是由相关文献、文章和博客文章推动的,这些文章和博客文章展示了如何使用非常小的孤立类来完成。人们忽略了这样做的原因是,您无法在具有现实生活应用程序的书籍或文章中展示开发过程,因为它对于格式来说太大了。

确实,需要隔离单元测试。这并不意味着“单元”需要完全隔离。测试本身需要与其他测试隔离。这意味着测试应该相互依赖,并且应该可以单独甚至并行运行它们。

单元测试的相关单元应该是一个用例。用例是您的应用程序的一个功能。在博客引擎中,“创建博客文章”是一个用例。在酒店预订系统中,“查找空闲房间”是一个用例。通常,您会在所谓的应用程序服务中找到这些入口点。这就是您应该瞄准的粒度。嘲笑丑陋的外部依赖项,如数据库、文件系统和外部服务。如果您重构应用程序的内部结构,则测试不会中断,因为您不会更改用例的行为。想要拆分或合并您域中的类?用例将是稳定的。更改域对象之间的交互方式。测试保持绿色。