Delphi风格:如何为可单元测试的代码构建数据模块?

12

我正在寻求有关如何构建易于维护的Delphi程序的建议。在大约二十年主要使用C/C++之后,我开始接触Delphi编程,尽管我最初是通过Turbo Pascal学习编程的,因此我对基本语言并不感到不适。在我以前使用C++和C#的经验中,通过使用cxxtest和NUnit,我成为了一个TDD信徒。

我继承了这个现在由我负责维护的程序。它主要由各种表单和一些数据模块组成。应用程序业务逻辑和数据访问主要分散在各个表单中,而数据模块主要只是全局ADO对象的存放位置。通常通过引用TADOQuery或TADOCommand的全局实例,将SQL文本格式化到对象的相关属性中,并调用其Open或Execute方法来进行数据库访问。

我试图将业务逻辑封装到一定程度以进行单元测试。我看到了这个答案,它非常合理,可以将逻辑从表单中抽象出来。我想知道数据访问的最佳实践是什么。我的想法是,数据模块应该公开一种应用程序特定的小API(可能具有所有虚拟方法),以便可以将其替换为用于测试的模拟对象。在另一个答案中的链接显示了一些示例,这使我相信我正在正确的方向上,但我仍然对看到某种数据模块最佳实践文档感兴趣。通过Google可以找到的大多数页面都介绍如何将数据绑定控件与查询进行挂钩等设计时酷炫的功能,而这并不是我目前非常感兴趣的。


现有的表单使用数据感知控件(TDBEdits)还是标准控件(TEdits)? - LachlanG
到目前为止还没有数据感知控件。我一直在考虑这个问题。 - wades
2
数据感知控件往往会使应用程序更难进行单元测试。虽然我自己是它们的粉丝,但我建议您在这个阶段避免使用它们。 - LachlanG
4个回答

8

个人而言,我不是TDataModule的粉丝。它很少鼓励良好的面向对象设计原则。如果它只是用作方便的容器来存储数据库组件,那还可以接受,但太多时候它变成了业务逻辑的“垃圾桶”,这些业务逻辑最好放在领域层中。当这种情况发生时,它就会变成一个上帝类和依赖磁铁。

此外,自至少Delphi 2以来一直存在的一个错误(或者可能是一个功能),会导致表单的数据感知控件失去其数据源,如果这些数据源位于在表单之前没有打开的单元中,则会出现这种情况。

我的建议

  • 在UI和数据库之间添加一个领域层
  • 尽可能将业务逻辑推入领域对象中。
  • 使用设计架构模式将决策委托给领域层,使UI和数据持久层尽可能浅。

如果您不熟悉它,这种技术被称为领域驱动设计。这当然不是唯一的解决方案,但是是一个好方法。基本前提是UI、业务逻辑和数据库以不同的速度和出于不同的原因发生变化。因此,将业务逻辑作为问题域的模型,并将其与UI和数据库分开。

这如何使我的代码更易于测试?

通过将业务逻辑移动到自己的层中,您可以在没有UI或数据库干扰的情况下测试它。这并不意味着仅仅因为您将其放入自己的层中,您的代码就会天然易于测试。使旧代码易于测试是一项艰巨的任务。大多数旧代码都紧密耦合,因此您需要花费大量时间将其拆分为具有明确定义职责的类。

这是“Delphi风格”吗?

这取决于你的视角。传统上,大多数Delphi应用程序是通过同时开发UI和数据库创建的。在窗体设计器中放置一些db aware控件。添加/更新一个带有字段的表来存储控件的数据。使用事件处理程序添加适量的业务逻辑。哇!您刚刚完成了一个应用程序。对于非常小的应用程序,这是一个很好的时间节省器。但我们不要自欺欺人,小型应用程序往往会变成大型应用程序,这种设计将成为无法维护的噩梦。

这实际上并不是语言的错。您可以从数百个VB、C#和Java商店找到相同的快速/肮脏/短视的设计。这些应用程序是新手开发人员(以及应该知道更好的经验丰富的开发人员)的结果,IDE使其如此容易,并且需要快速完成工作的压力。

在Delphi社区(以及其他社区)中有些人长期以来一直在倡导更好的设计技术。


1
数据模块并不是“大泥球”设计的罪魁祸首。数据模块只是一个支持非可视控件的类。你使用它来做什么取决于你自己。如果你挖进了一个坑,不要责怪制造铲子的人。 - Warren P
1
一个不恰当的比喻。填平一个坑所需的时间比挖掉它要少。但是对于一个设计不良的应用程序来说,情况并非如此。我的观点是缺乏封装实际上鼓励了糟糕的设计。同样地,通过双击表单控件以创建事件处理程序的简便性和方便性会鼓励将业务逻辑直接耦合到UI中。存在大量垃圾代码的原因是因为工具使得编写这种代码变得容易,并且在某些情况下实际上阻碍了你进行良好的设计。 - Kenneth Cochran

7
我认为您需要(实际上,大多数Delphi数据库开发人员都需要)一个模拟数据集(查询、表等等)组件,您可以在模块初始化时使用它们,并将它们替换为当前的ADO数据集对象以进行测试。不要强制将接口引入设计中,这是提供替代功能的一种方式,而应该考虑Liskov替换原则,即您应该能够(在测试装置设置时间),将您想要使用的模拟数据集注入到您的数据模块中,并在测试执行时间,用其他具有相同功能的实体(模拟数据集或文件支持的表格数据集)替换您正在使用的ADO数据集。

也许您甚至可以完全从数据模块中删除数据集,并在运行时(在主应用程序中)将它们连接到正确的ADO数据集对象上,在单元测试中附加您的模拟数据集。

由于您没有编写ADO数据集,因此不需要对其进行单元测试。然而,模拟这样的数据集可能会很困难。

我建议您考虑使用JvCsvDataSet或ClientDataSet作为您的fixture(模拟)数据集的基础。然后,您将能够使用这些来确保所有数据库平台依赖项(编写远程过程或数据库SQL的内容)被抽象成其他类,再次您将不得不进行模拟。这样的努力可能不仅需要使您的业务逻辑可单元测试,而且还可能是成为多个数据库平台友好型业务逻辑的一步。

想象一下,您有一个名为CustomerQuery的ADOQuery,将您放置在数据模块上的对象重命名为CustomerQueryImpl,并将其添加到您的数据模块类声明中:

  private
        FCustomerQuery:TADOQuery;

  published
        property CustomerQuery:TADOQuery read FCustomerQuery write FCustomerQuery;

然后在你的数据模块的创建事件中,将属性与对象连接起来:

   FCustomerQuery := CustomerQueryImpl

现在,您可以编写单元测试,这些测试将在运行时“挂钩”并使用自己的测试夹具(模拟对象)替换CustomerQuery。

4
首先,在更改任何内容之前,您需要进行一些单元测试,以确保不会破坏任何东西。我建议在不更改任何内容的情况下,尝试编写针对当前GUI的单元测试。DUnit支持GUI测试(以及传统的单元测试),虽然它有点笨重,无法处理模态对话框,但是它是功能性的。
接下来,由于您的表单不使用数据感知控件,因此我会通过在表单和现有全局数据模块之间引入另一层数据模块(如果您愿意,可以称之为服务层)来解决这个问题。
对于应用程序中的每个表单,我都会创建一个相应的新服务层数据模块。这听起来可能像很多数据模块,但它们非常轻巧,如果需要,您可以稍后合并它们。
您可以使用普通的TObjects而不是TDataModules作为服务层,但是使用数据模块使您具有灵活性,可以稍后在其上放置非可视组件,例如TClientDataSet和TDataSource,如果您在以后采用数据感知控件路线。

最初,每个服务层数据模块仅充当访问全局数据模块的代理。此时您的目标仅是消除表单对全局数据模块的直接依赖。

一旦表单仅通过服务层数据模块间接访问全局数据模块,那么我将开始将功能从表单移动到服务层。有了这些功能在服务层数据模块中,您会发现更容易为新代码和现有代码编写单元测试。

此时,您还可以开始合并每个表单的服务层数据模块。在从表单中提取逻辑完成后,现在合并它们要比在该过程中尝试合并它们容易得多。


0
请阅读本文,它涉及到单元测试和模拟对象,包括模拟对象的理论、本地化UT和接口发现。
希望您喜欢它。

在Delphi中,您不需要依赖反转和接口发现来模拟事物。单元名称别名通常就足够了。Delphi已经有了一个接口部分。您不必使用ISomethingEverything来使模拟对象(固定装置)工作。 - Warren P

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接