重构前如何编写单元测试?


55

我已经阅读了类似问题的一些答案,例如“重构时如何保持单元测试正常工作?”。就我而言,情况略有不同,因为我得到了一个项目进行审查并符合我们已有的一些标准,目前该项目根本没有测试!

我已经确定了许多我认为可以做得更好的事情,例如不要在服务层中混合DAO类型的代码。

在重构之前,为现有代码编写测试似乎是一个好主意。在我看来,问题是当我进行重构时,这些测试将随着我改变执行某些逻辑的位置而中断,并且这些测试将牢记先前的结构(模拟的依赖关系等)编写。

就我而言,最好的前进方法是什么?我很想围绕重构的代码编写测试,但是我知道我可能会错误地重构事物,从而可能改变期望的行为。

无论是重构还是重新设计,我都很高兴能理解要更正的术语,目前我正在为重构进行以下定义:“按照定义,重构不会改变软件的功能,您将更改其操作方式。”。因此,我不会更改软件的功能,而是会更改软件的方式/位置。

同样,我可以看到这样一种论点,即如果我更改了可以视为重新设计的方法的签名。

这是一个简单的例子

MyDocumentService.java (当前)

public class MyDocumentService {
   ...
   public List<Document> findAllDocuments() {
      DataResultSet rs = documentDAO.findAllDocuments();
      List<Document> documents = new ArrayList<>();
      for(DataObject do: rs.getRows()) {
         //get row data create new document add it to 
         //documents list
      }

      return documents;
   }
}

MyDocumentService.java (无论如何都经过重构/重新设计)

public class MyDocumentService {
   ...
   public List<Document> findAllDocuments() {
      //Code dealing with DataResultSet moved back up to DAO
      //DAO now returns a List<Document> instead of a DataResultSet
      return documentDAO.findAllDocuments();
   }
}

14
它真的是重构您打算做的事,还是重新设计了?因为在两种情况下答案可能不同。
Herby

4
我正在研究定义“按照定义,重构不会改变软件的工作,而会改变软件的工作方式。” 因此,我相信在这种情况下,它正在重构,可以随时纠正我对术语的理解
PDStat

21
不,编写集成测试。您正在计划的“重构”超出了单元测试的水平。只有单元测试新的类(或者您知道要保留的旧类)。
停止危害莫妮卡

2
关于重构的定义,您的软件是否明确定义了它的作用?换句话说,它是否已经“分解”为具有独立API的模块?如果没有,那么您就无法重构它,除非是在最高级别(面向用户)。在模块级别,您将不可避免地进行重新设计。在这种情况下,不要浪费时间在编写单元测试之前。
凯文·克鲁姆维德

4
如果没有测试的安全网,您很可能必须进行一些重构,才能将其纳入测试工具。我能给您的最好建议是,如果您的IDE或重构工具无法帮您做到,请不要手动进行。继续应用自动重构,直到您可以将CUT纳入工具中。您肯定要拿起迈克尔·费瑟(Michael Feather)的“有效使用旧版代码”的副本。
RubberDuck

Answers:


56

您正在寻找检查回归的测试。即破坏一些现有的行为。首先,我要确定行为将在什么级别上保持不变,并且驱动该行为的接口将保持不变,然后开始进行测试。

现在,您有一些测试可以断言,此级别以下进行任何操作,您的行为都保持不变。

您完全可以质疑测试和代码如何保持同步。如果与组件的接口保持不变,则可以围绕该接口编写测试,并为两个实现声明相同的条件(在创建新实现时)。如果不是,则您必须接受对冗余组件的测试是冗余测试。


1
就是说,您可能正在进行集成或系统测试,而不是单元测试。您可能仍将使用“单元测试”工具,但是每次测试将击中多个单元代码。
莫兹

是。确实是这样。您的回归测试很可能在做一些非常高级的操作,例如对服务器的REST请求以及随后的数据库测试(即绝对不是单元测试!)
Brian Agnew

40

推荐的做法是从编写“固定测试”开始,以测试代码的当前行为(可能包括错误),而无需您冒充辨别给定行为是否违反要求文档的疯狂行为,解决方案,解决您不了解的问题或代表未记录的需求变更。

最好将这些固定测试置于较高水平,即集成而不是单元测试,以便在您开始重构时它们可以继续工作。

但是,可能需要进行一些重构才能使代码可测试-请小心遵守“安全”重构。例如,几乎在所有情况下,私有方法都可以公开而不会破坏任何东西。


+1用于集成测试。根据应用程序的不同,您可能可以从实际向Web应用程序发送请求的级别开始。应用程序发回的内容不应仅因为重构而更改,尽管如果它发回HTML,则无疑测试性较差。
jpmc26

我喜欢短语“固定”测试。
布莱恩·阿格纽

12

我建议(如果您还没有阅读的话)-既阅读有效的旧代码还是重构-改善现有代码的设计

[..]在我看来,问题是当我进行重构时,这些测试将随着我更改执行某些逻辑的位置而中断,并且将考虑到先前的结构(模拟的依赖关系等)来编写测试[ ..]

我并不认为这是一个问题:编写测试,改变你的代码的结构,然后调整测试结构。如果您的新结构实际上比旧结构好,这将为您提供直接反馈,因为如果这样,调整后的测试将更容易编写(因此更改测试应该相对简单,从而降低了采用新结构的风险。错误通过测试)。

另外,就像其他人已经写过的一样:不要写详细的测试(至少不要一开始就写)。尝试保持较高的抽象水平(因此您的测试可能会更好地表征为回归测试或什至是集成测试)。


1
这个。这些测试看起来很糟糕,但它们将涵盖现有行为。然后,随着代码的重构,在锁定步骤中进行测试。重复直到您拥有引以为傲的东西。++
RubberDuck

1
我对这两个书中的建议都持赞成态度-当我不得不处理无测试的代码时,我总是会近在咫尺。
Toby Speight

5

不要在模拟所有依赖项的地方编写严格的单元测试。有人会告诉您这些不是真正的单元测试。别管他们。这些测试非常有用,这很重要。

让我们看看您的示例:

public class MyDocumentService {
   ...
   public List<Document> findAllDocuments() {
      DataResultSet rs = documentDAO.findAllDocuments();
      List<Document> documents = new ArrayList<>();
      for(DataObject do: rs.getRows()) {
         //get row data create new document add it to 
         //documents list
      }

      return documents;
   }
}

您的测试可能看起来像这样:

DocumentDao documentDao = Mock.create(DocumentDao.class);
Mock.when(documentDao.findAllDocuments())
    .thenReturn(DataResultSet.create(...))
assertEquals(..., new MyDocumentService(documentDao).findAllDocuments());

与其模拟DocumentDao,不如模拟其依赖项:

DocumentDao documentDao = new DocumentDao(db);
Mock.when(db...)
    .thenReturn(...)
assertEquals(..., new MyDocumentService(documentDao).findAllDocuments());

现在,您可以MyDocumentServiceDocumentDao不中断测试的情况下将逻辑从中移入。测试将显示功能是相同的(就您已经测试的而言)。


如果您正在测试DocumentService并且不模拟DAO,则根本不是单元测试。它介于整体测试和集成测试之间。是不是?
2016年

7
@Laiv,人们使用术语单元测试的方式实际上有很多不同。有人用它来表示仅严格隔离的测试。其他包括快速运行的任何测试。有些包含在测试框架中运行的任何内容。但是最终,如何定义术语“单元测试”并不重要。问题是什么测试有用,所以我们不应因定义单元测试的精确程度而分心。
温斯顿·埃韦特

最好的一点表明,有用才是最重要的。对于最琐碎的算法而言,奢侈的单元测试只是为了使单元测试弊大于利,即使这不仅浪费大量时间和宝贵资源。这可以应用于几乎所有事物,这是我希望在职业生涯的早期就知道的。

3

如您所说,如果更改行为,则它是一种转换而不是重构。在多大程度上改变行为才是与众不同的。

如果没有最高级别的正式测试,则尝试找出一组要求(客户代码或人工人员),这些要求在您重新设计代码以使其正常工作之后需要保持不变。这就是您需要实现的测试用例列表。

为了解决有关需要更改测试用例的更改实现的问题,建议您看一下底特律(古典)VS伦敦(模拟主义者)TDD。这个在他的大文章Martin Fowler的会谈嘲笑不是存根但很多人有意见。如果您从外部无法更改的最高级别开始,然后逐步降低要求,则要求应该保持相当稳定,直到达到真正需要更改的级别为止。

如果没有任何测试,这将很困难,并且您可能要考虑通过双重代码路径运行客户端(并记录差异),直到您可以确定新代码完全满足其要求为止。


3

这是我的方法。它需要花费时间,因为它是4个阶段的重构测试。

我要公开的内容可能比问题示例中提供的组件更适合于复杂性更高的组件。

无论如何,该策略对于要通过接口(DAO,服务,控制器等)进行标准化的任何候选组件都是有效的。

1. 界面

让我们从MyDocumentService收集所有公共方法,并将它们放到一个接口中。例如。如果确实存在,请使用该选项,而不要设置任何新选项

public interface DocumentService {

   List<Document> getAllDocuments();

   //more methods here...
}

然后,我们强制MyDocumentService实现此新接口。

到现在为止还挺好。没有重大变化,我们遵守了当前合同,behaivos保持不变。

public class MyDocumentService implements DocumentService {

 @Override
 public List<Document> getAllDocuments(){
         //legacy code here as it is.
        // with no changes ...
  }
}

2. 遗留代码的单元测试

在这里,我们努力工作。设置测试套件。我们应该设置尽可能多的案例:成功案例和错误案例。最后这是为了保证结果的质量。

现在,我们将使用该接口作为要测试的合同,而不是测试MyDocumentService

我不会详细介绍,如果我的代码看起来过于简单或不可知,请原谅我

public class DocumentServiceTestSuite {

   @Mock
   MyDependencyA mockDepA;

   @Mock
   MyDependencyB mockDepB;

    //... More mocks

   DocumentService service;

  @Before
   public void initService(){
       service = MyDocumentService(mockDepA, mockDepB);
      //this is purposed way to inject 
      //dependencies. Replace it with one you like more.  
   }

   @Test
   public void getAllDocumentsOK(){
         // here I mock depA and depB
         // wanted behaivors...

         List<Document> result = service.getAllDocuments();

          Assert.assertX(result);
          Assert.assertY(result);
           //... As many you think appropiate
    } 
 }

此阶段花费的时间比任何其他方法都要长。这是最重要的,因为它将为将来的比较设定参考点。

注意:由于未进行重大更改,behaivor保持不变。我建议在这里在SCM中做一个标签。标签或分支无关紧要。只是做一个版本。

我们希望它用于回滚,版本比较,并且可能用于旧代码和新代码的并行执行。

3. 重构

重构将被实现到一个新组件中。我们不会对现有代码进行任何更改。第一步就像复制和粘贴MyDocumentService并将其重命名为CustomDocumentService一样容易。

新类继续实现DocumentService。然后去重构getAllDocuments()(让我们开始一个。Pin重构)

可能需要对DAO的界面/方法进行一些更改。如果是这样,请勿更改现有代码。在DAO界面中实现您自己的方法。将旧代码注释为“ 已弃用”,稍后您将知道应删除的内容。

不要中断/更改现有的实现,这一点很重要。我们要并行执行两个服务,然后比较结果。

public class CustomDocumentService implements DocumentService {

 @Override
 public List<Document> getAllDocuments(){
         //new code here ...
         //due to im refactoring service 
         //I do the less changes possible on its dependencies (DAO).
         //these changes will come later 
         //and they will have their own tests
  }
 }

4.更新DocumentServiceTestSuite

好的,现在比较简单。添加新组件的测试。

public class DocumentServiceTestSuite {

   @Mock
   MyDependencyA mockDepA;

   @Mock
   MyDependencyB mockDepB;

   DocumentService service;
   DocumentService customService;

  @Before
   public void initService(){
       service = MyDocumentService(mockDepA, mockDepB);
        customService = CustomDocumentService(mockDepA, mockDepB);
       // this is purposed way to inject 
       //dependencies. Replace it with the one you like more
   }

   @Test
   public void getAllDocumentsOK(){
         // here I mock depA and depB
         // wanted behaivors...

         List<Document> oldResult = service.getAllDocuments();

          Assert.assertX(oldResult);
          Assert.assertY(oldResult);
           //... As many you think appropiate

          List<Document> newResult = customService.getAllDocuments();

          Assert.assertX(newResult);
          Assert.assertY(newResult);
           //... The very same made to oldResult

          //this is optional
Assert.assertEquals(oldResult,newResult);
    } 
 }

现在,我们分别对oldResult和newResult进行了独立验证,但我们也可以相互比较。最后的验证是可选的,并且取决于结果。可能不是可比的。

用这种方式比较两个集合可能不会引起太多的注意,但是对于任何其他类型的对象(pojo,数据模型实体,DTO,包装器,本机类型...)都有效。

笔记

我不敢告诉如何进行单元测试或如何使用模拟库。我也不敢说您必须如何进行重构。我想做的是建议一项全球战略。如何进行取决于您。您确切地知道代码的方式,代码的复杂性以及这种策略是否值得一试。时间和资源等事实在这里至关重要。您将来对这些测试的期望也很重要。

我已经以服务部门为例开始了我的工作,接下来我将介绍DAO等。深入到依赖级别。或多或少可以将其描述为自下而上的策略。但是,对于较小的更改/重构(例如在巡回示例中公开的更改/重构),自下而上会使任务更容易。因为更改的范围很小。

最后,由您决定删除不推荐使用的代码并将旧的依赖项重定向到新的依赖项。

删除也不推荐使用的测试,然后完成工作。如果使用测试对旧解决方案进行了版本控制,则可以随时进行检查和比较。

由于进行了如此多的工作,您已经对旧代码进行了测试,验证和版本控制。以及经过测试,验证和准备进行版本控制的新代码。


3

tl; dr不要编写单元测试。在更合适的水平上编写测试。


给定您的重构工作定义:

您不会更改软件的功能,而是会更改软件的功能

广的范围。一方面是对特定方法的独立更改,也许使用更有效的算法。另一方面是移植到另一种语言。

无论执行什么级别的重构/重新设计,重要的是要运行处于该级别或更高级别的测试。

自动化测试通常按级别分类为:

  • 单元测试 -各个组件(类,方法)

  • 集成测试 -组件之间的交互

  • 系统测试 -完整的应用程序

编写可以承受重构的测试级别,该级别基本上没有被修改过。

认为:

将应用程序都有什么必要,公开可见的行为之前之后的重构?我如何测试该东西仍然可以正常工作?


2

不要浪费时间编写可以挂接在可以预期界面将以不平凡的方式进行更改的点上的测试的方法。这通常表明您正在尝试对本质上“协作”的类进行单元测试-这些类的价值不在于它们自己做什么,而是在于它们如何与许多密切相关的类交互以产生有价值的行为。您要测试的就是这种行为,这意味着您想进行更高级别的测试。低于此级别的测试通常需要进行很多丑陋的模拟,结果测试可能会更多地拖延开发而不是保护行为。

无论您要进行重构,重新设计还是其他事情,都不要太着迷。您可以进行更改,这些更改在较低级别上构成了许多组件的重新设计,而在较高集成级别上仅构成重构。关键是要弄清楚什么行为对您有价值,并在进行过程中捍卫该行为。

在编写测试时考虑一下可能会很有用-我可以轻松地向质量检查人员,产品负责人或用户描述此测试实际上在测试什么吗?如果描述测试似乎太深奥且过于技术化,则可能是您在错误的级别进行测试。在“有意义”的点/级别进行测试,不要在每个级别的测试中都使代码混乱。


始终对投票理由感兴趣!
topo morto

1

您的首要任务是尝试为您的测试提出“理想方法签名”。力求使其成为纯函数。这应该独立于实际测试的代码;它是一个小的适配器层。将您的代码写入此适配器层。现在,当您重构代码时,只需更改适配器层。这是一个简单的示例:

[TestMethod]
public void simple_addition()
{
    Assert.AreEqual(7, Eval("3 + 4"));
}

[TestMethod]
public void order_of_operations()
{
    Assert.AreEqual(52, Eval("2 + 5 * 10"));
}

[TestMethod]
public void absolute_value()
{
    Assert.AreEqual(9, Eval("abs(-9)"));
    Assert.AreEqual(5, Eval("abs(5)"));
    Assert.AreEqual(0, Eval("abs(0)"));
}

static object Eval(string expression)
{
    // This is the code under test.
    // I can refactor this as much as I want without changing the tests.
    var settings = new EvaluatorSettings();
    Evaluator.Settings = settings;
    Evaluator.Evaluate(expression);
    return Evaluator.LastResult;
}

测试是好的,但是被测代码的API不好。我可以通过更新适配器层来重构它,而无需更改测试:

static object Eval(string expression)
{
    // After refactoring...
    var settings = new EvaluatorSettings();
    var evaluator = new Evaluator(settings);
    return evaluator.Evaluate(expression);
}

根据“不要重复自己”原则,此示例似乎很明显,但是在其他情况下,它可能并不那么明显。优点超越了DRY-真正的优点是将测试与被测代码分离。

当然,并非在所有情况下都建议使用此技术。例如,没有理由为POCO / POJO编写适配器,因为它们确实没有可以独立于测试代码而更改的API。同样,如果编写少量测试,则可能会浪费相对较大的适配器层。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.