TDD和重构遇到的困难(或者-为什么这比应该的要痛苦得多?)


20

我想教自己使用TDD方法,而我有一个项目想要工作一段时间。这不是一个大项目,所以我认为这将是TDD的不错的选择。但是,我感觉有些不对劲。让我举个例子:

在较高级别上,我的项目是Microsoft OneNote的加载项,它使我可以更轻松地跟踪和管理项目。现在,如果我决定建立自己的自定义存储和后端的一天,我还希望保持与OneNote分离的业务逻辑。

首先,我从一个基本的普通单词接受测试开始,概述了我希望我的第一个功能要做的事情。看起来像这样(为简洁起见,将其复制):

  1. 用户点击创建项目
  2. 用户输入项目标题
  3. 验证项目创建正确

跳过UI内容和一些中介计划,我来进行第一次单元测试:

[TestMethod]
public void CreateProject_BasicParameters_ProjectIsValid()
{
    var testController = new Controller();
    Project newProject = testController(A.Dummy<String>());
    Assert.IsNotNull(newProject);
}

到目前为止,一切都很好。红色,绿色,重构等。现在它实际上需要保存内容。在这里减少一些步骤,我对此很满意。

[TestMethod]
public void CreateProject_BasicParameters_ProjectMatchesExpected()
{
    var fakeDataStore = A.Fake<IDataStore>();
    var testController = new Controller(fakeDataStore);
    String expectedTitle = fixture.Create<String>("Title");
    Project newProject = testController(expectedTitle);

    Assert.AreEqual(expectedTitle, newProject.Title);
}

在这一点上,我仍然感觉很好。我还没有具体的数据存储,但是我按预期的方式创建了界面。

我将在此处跳过一些步骤,因为这篇文章已经足够长了,但是我遵循了类似的过程,最终我对数据存储进行了此测试:

[TestMethod]
public void SaveNewProject_BasicParameters_RequestsNewPage()
{
    /* snip init code */
    testDataStore.SaveNewProject(A.Dummy<IProject>());
    A.CallTo(() => oneNoteInterop.SavePage()).MustHaveHappened();
}

直到我尝试实现它为止,这是很好的:

public String SaveNewProject(IProject project)
{
    Page projectPage = oneNoteInterop.CreatePage(...);
}

问题就在“ ...”所在的位置。现在,我意识到CreatePage需要一个部分ID。当我在控制器级别思考时,我并没有意识到这一点,因为我只关心测试与控制器相关的位。但是,直到现在,我一直意识到我必须要问用户一个存储项目的位置。现在,我必须将位置ID添加到数据存储中,然后将其添加到项目中,然后将其添加到控制器中,然后将其添加到已针对所有这些内容编写的所有测试中。它变得非常繁琐,我不禁感到,如果我提前草拟设计而不是在TDD过程中进行设计,我会更快地抓住这一点。

如果我在此过程中做错了什么,可以给我解释一下吗?无论如何,可以避免这种重构吗?还是这很常见?如果很常见,有什么方法可以使它更轻松吗?

谢谢大家!


如果您在以下讨论论坛上发布此主题,将会得到一些非常有见地的评论:groups.google.com/forum/#! forum/… ,专门针对TDD主题。
Chuck Krutsinger

1
如果您需要在所有测试中添加一些内容,听起来您的测试写得不好。您应该重构测试并考虑使用合理的夹具。
Dave Hillier

Answers:


19

尽管TDD被(正确地)吹捧为设计和开发软件的一种方式,但事先考虑一下设计和体系结构仍然是一个好主意。国际海事组织,“提前设计出草图”是公平的游戏。但是,通常这将比通过TDD导致的设计决策更高。

的确,当事情发生变化时,通常必须更新测试。没有办法完全消除这种情况,但是您可以做一些事情来使您的测试不那么脆弱,并最大程度地减少痛苦。

  1. 尽可能将实施细节保留在测试范围之外。这意味着仅通过公共方法进行测试,并且在可能的情况下更倾向于基于状态的验证而不是基于交互的验证。换句话说,如果您测试某物的结果而不是步骤,那么您的测试应该不会那么脆弱。

  2. 就像在生产代码中一样,最大程度地减少测试代码中的重复。这个帖子是一个很好的参考。在您的示例中,将ID属性添加到构造函数听起来很痛苦,因为您在多个不同的测试中直接调用了构造函数。相反,请尝试将对象的创建提取到方法中,或针对测试初始化​​方法中的每个测试将其初始化一次。


我已经阅读了基于状态与基于交互的优点,并且大多数时候都了解它。但是,我不知道在没有明确公开测试属性的情况下,在每种情况下怎么可能。以我上面的例子为例。我不确定如何在不使用“ MustHaveBeenCalled”断言的情况下检查数据存储是否已实际调用。至于第二点,你是绝对正确的。在完成所有编辑后,我确实做完了,但是我只是想确保我的方法与公认的TDD惯例基本一致。谢谢!
兰登

@Landon在某些情况下,交互测试更合适。例如,验证是否已对数据库或Web服务进行了调用。基本上,每当您需要隔离测试时,尤其是与外部服务隔离时。
jhewlett

@Landon我是一个“令人信服的古典主义者”,所以我对基于交互的测试不是很有经验...但是您不需要对“ MustHaveBeenCalled”进行断言。如果要测试插入,则可以使用查询查看是否已插入。PS:出于测试性能的考虑,我在测试除数据库层以外的所有内容时都使用存根。
哈斯

@jhewlett这也是我也得出的结论。谢谢!
兰登

@Hbas没有要查询的数据库。我同意,如果有的话,这将是最直接的方法,但是我将其添加到OneNote笔记本中。我能做的最好的办法是向我的互操作助手类添加Get方法,以尝试拉出页面。我可以编写测试来做到这一点,但是我觉得我要同时测试两件事:我保存了吗?和我的助手类是否正确检索页面?虽然,我想您的测试有时可能不得不依赖在其他地方测试过的其他代码。谢谢!
兰登

10

...我不禁感到,如果我提前勾勒出设计方案,而不是让它在TDD流程中进行设计,我会更快地抓住这一点...

也许吧,也许不是

一方面,TDD可以很好地工作,在您建立功能时为您提供自动化测试,并在必须更改界面时立即中断。

另一方面,也许如果您是从高级功能(SaveProject)而不是较低级功能(CreateProject)开始的,则您可能会很快注意到丢失的参数。

再说一遍,也许你不会。这是一个无法重复的实验。

但是,如果您下次要上一课,请从顶部开始。并尽可能多地考虑设计。


0

https://frontendmasters.com/courses/angularjs-and-code-testability/ 从大约2:22:00到结束(大约1小时)。抱歉,该视频不是免费的,但我还没有找到能很好解释它的免费视频。

本课是编写可测试代码的最佳演示之一。这是一个AngularJS类,但是测试部分围绕Java代码进行,主要是因为他所谈论的与语言无关,而与编写良好的可测试代码有关。

魔术在于编写可测试的代码,而不是编写代码测试。这与编写冒充用户的代码无关。

他还花费一些时间以测试断言的形式编写规范。

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.