如何构建我的类以更轻松地进行单元测试?

Posted

技术标签:

【中文标题】如何构建我的类以更轻松地进行单元测试?【英文标题】:How do I architect my classes for easier unit testing? 【发布时间】:2013-05-25 11:06:16 【问题描述】:

我承认,我没有进行太多单元测试……但我愿意。话虽如此,我有一个非常复杂的注册过程,我想对其进行优化以便于单元测试。 我正在寻找一种方法来构建我的类,以便将来可以更轻松地测试它们。所有这些逻辑都包含在 MVC 框架中,因此您可以假设控制器是根一切都是从那里实例化的。

为了简化,我实质上要问的是如何设置一个系统,您可以在其中管理任意数量的带有 CRUD 更新的第三方模块。这些第三方模块都是 RESTful API 驱动的,响应数据存储在本地副本中。删除用户帐户之类的操作需要触发删除所有关联模块(我将其称为提供程序)。这些提供者可能依赖于另一个提供者,因此删除/创建的顺序很重要。 我对应该专门使用哪些设计模式来支持我的应用程序感兴趣

注册跨越多个类并将数据存储在多个数据库表中。以下是不同提供者和方法的顺序(它们不是静态的,只是为了简洁而写成这样)

    Provider::create('external::create-user') 在特定提供者的特定步骤启动注册。第一个参数中的双冒号语法表示该类应在providerClass::providerMethod 上触发创建。我做了一个一般性的假设,Provider 将是与方法create()update()delete() 的接口,所有其他提供者都会实现它。 这可能是您需要帮助我解决的问题。 $user = Provider_External::createUser() 在外部 API 上创建用户,返回成功,然后用户存储在我的数据库中。 $customer = Provider_Gapps_Customer::create($user) 在第三方 API 上创建客户,返回成功,并在本地存储。 $subscription = Provider_Gapps_Subscription::create($customer) 在第三方 API 上创建与先前创建的客户关联的订阅,返回成功,并在本地存储。 Provider_Gapps_Verification::get($customer, $subscription) 从外部 API 检索一行。此信息存储在本地。另一个电话被打了,我跳过它以保持简洁。 Provider_Gapps_Verification::verify($customer, $subscription) 执行外部 API 验证过程。其结果存储在本地。

这是一个非常简单的示例,因为实际代码依赖于至少 6 个外部 API 调用和注册期间创建的 10 多个本地数据库行。在构造函数级别使用依赖注入是没有意义的,因为我可能需要在控制器中实例化 6 个类而不知道我是否需要它们。我想要完成的是类似于 Provider::create('external') 的内容,我只需指定开始注册的起始步骤。


问题的症结

如您所见,这只是注册过程的一个示例。我正在构建一个系统,我可以在其中拥有数百个服务提供者(外部 API 模块),我需要注册、更新、删除等。这些提供者中的每一个都与一个用户帐户相关联。

我想以一种在触发创建新提供者时可以指定操作顺序(步骤)的方式构建此系统。换句话说,允许我指定事件链中接下来触发哪个提供程序/方法组合,因为创建可以跨越很多步骤。目前,我通过主题/观察者模式发生了这一系列事件。我正在寻找可能将此代码移动到数据库表provider_steps,我在其中列出每个步骤以及它在success_stepfailure_step 之后(用于回滚和删除)。该表如下所示:

  # the id of the parent provider row
  provider_id int(11) unsigned primary key,
  # the short, slug name of the step for using in codebase
  step_name varchar(60),
  # the name of the method correlating to the step
  method_name varchar(120),
  # the steps that get triggered on success of this step
  # can be comma delimited; multiple steps could be triggered in parallel
  triggers_success varchar(255),
  # the steps that get triggered on failure of this step
  # can be comma delimited; multiple steps could be triggered in parallel
  triggers_failure varchar(255),
  created_at datetime,
  updated_at datetime,
  index ('provider_id', 'step_name')

这里有很多决定要做...我知道我应该更喜欢组合而不是继承并创建一些接口。我也知道我可能需要工厂。最后,我这里有很多领域模型问题......所以我可能需要业务领域类。我只是不知道如何在追求圣杯的过程中将它们全部融合在一起而不造成一团糟。

另外,数据库查询发生的最佳位置是哪里?

我已经为每个数据库表建立了一个模型,但我想知道在哪里以及如何实例化特定的模型方法。

我一直在阅读的东西......

Design Patterns The Strategy Pattern Composition over Inheritance The Factory method pattern The Abstract factory pattern The Builder pattern The Chain-of-responsibility pattern

【问题讨论】:

【参考方案1】:

你看过状态设计模式吗? http://en.wikipedia.org/wiki/State_pattern 您可以将所有步骤作为状态机中的不同状态,它看起来像图形。您可以将此图表存储在您的数据库 table/xml 中,每个提供者也可以有自己的图表,表示执行应该发生的顺序。

因此,当您进入某种状态时,您可能会触发事件(保存用户、获取用户)。我不知道您的特定应用程序,但事件可以被其他提供者重新使用。

如果某些步骤失败,则执行不同的图形路径。

如果你能正确地抽象它,你可以拥有一个松散耦合的系统,它遵循图形给出的命令并根据状态执行事件。

然后,如果您需要添加一些其他提供者,您只需要创建图表和/或一些新事件。

这里有一些例子:https://github.com/Metabor/Statemachine

【讨论】:

【参考方案2】:

考虑将您的应用程序分层,并为每一层定义角色和职责。您可能想从Apache-Axis' message flow subsystem 获得灵感。核心思想是创建一个处理程序链,请求通过该处理程序流过,直到它被处理。这样的设计有利于可插入组件,这些组件可以捆绑在一起以创建更高阶的功能。

您可能还想进一步了解Functors/Function Objects,尤其是Closure、Predicate、Transformer 和Supplier,以创建您的参与组件。希望对您有所帮助。

【讨论】:

【参考方案3】:

我在您的代码中看到的最严重的问题 - 这阻碍了您实际测试它 - 是使用静态类方法调用:

Provider::create('external::create-user') $user = Provider_External::createUser() $customer = Provider_Gapps_Customer::create($user) $subscription = Provider_Gapps_Subscription::create($customer) ...

它在您的代码中很流行——即使您“仅” 将它们概述为静态以“简洁”。这种态度并不简洁,它对于可测试的代码会适得其反。 不惜一切代价避免这些,包括。在询问有关单元测试的问题时,这是一种众所周知的不良做法,而且众所周知,这样的代码难以测试

在您将 所有 静态调用转换为对象方法调用并使用依赖注入而不是静态全局状态来传递对象后,您可以使用 phpUnit 进行单元测试,包括。在您的(简单)测试中使用协作的存根和模拟对象。

所以这里有一个 TODO:

    将静态方法调用重构为对象方法调用。 使用依赖注入传递对象。

您的代码也大大改进了。如果你认为你不能这样做,不要把时间浪费在单元测试上,把时间浪费在维护你的应用程序上,快速发布,让它赚钱,如果不再有利可图就烧掉它。但是不要用单元测试静态全局状态来浪费你的编程生命——这样做很愚蠢。

【讨论】:

我实际上只是发布了静态数据以保持简短,而不必延长我的代码示例。没有什么是静态的;所有类都通过它们的构造函数实例化。【参考方案4】:

您已经在使用 pub/sub 模式,这似乎很合适。除了上面的 cmets 之外什么都没有,我会考虑将有序列表作为优先机制。

但是,每个订阅者都关心其依赖项的操作顺序以触发成功/失败,这仍然是不对的。依赖项通常看起来像是属于树,而不是列表。如果您将它们存储在树中(使用复合模式),那么内置递归将能够通过首先清理其依赖项来清理每个依赖项。这样您就不必再担心清理发生的优先顺序 - 树会自动处理。

您可以使用树来存储 pub/sub 订阅者,几乎就像使用列表一样容易。

使用测试驱动的开发方法可以满足您的需求,并确保您的整个应用程序不仅完全可测试,而且完全被测试所覆盖,以证明它可以满足您的需求。我将首先准确描述您需要做什么才能满足一个要求。

您知道自己想要做的一件事是添加提供程序,因此 TestAddProvider() 测试似乎合适。请注意,此时它应该非常简单,并且与复合模式无关。一旦它起作用,您就知道提供者有一个依赖项。创建一个 TestAddProviderWithDependent() 测试,看看结果如何。同样,它不应该很复杂。接下来,您可能想要 TestAddProviderWithTwoDependents(),这就是实现列表的地方。一旦它起作用了,您就知道您希望 Provider 也成为 Dependent,因此新的测试将证明继承模型有效。从那里,您将添加足够的测试来说服自己添加提供者和依赖项的各种组合有效,并测试异常条件等。仅从测试和需求中,您将很快达到满足您需求的复合模式.在这一点上,我实际上会打开我的 GoF 副本,以确保我了解选择复合模式的后果,并确保我没有添加不合适的疣。

另一个已知要求是删除提供程序,因此创建一个 TestDeleteProvider() 测试,并实现 DeleteProvider() 方法。您也不会远离提供者删除其依赖项,因此下一步可能是创建一个 TestDeleteProviderWithADependent() 测试。复合模式的递归在这一点上应该很明显了,你应该只需要更多的测试来让自己相信深度嵌套的提供者、空叶子、宽节点等,都会正确地清理自己。

我假设您的提供商需要实际提供他们的服务。是时候测试调用提供程序(使用模拟提供程序进行测试),并添加测试以确保它们可以找到它们的依赖项。同样,复合模式的递归应该有助于构建依赖关系列表或任何您需要正确调用正确提供程序的内容。

您可能会发现必须以特定顺序调用提供程序。此时,您可能需要为复合树中每个节点的列表添加优先级。或者,也许您必须构建一个完全不同的结构(例如链表)才能以正确的顺序调用它们。使用测试并慢慢接近它。您可能仍然有人担心您按照特定的外部规定顺序删除受抚养人。此时,您可以使用您的测试向怀疑者证明您将始终安全地删除它们,即使不是按照他们的想法。

如果你做对了,你之前的所有测试都应该继续通过。

然后是棘手的问题。如果您有两个共享共同依赖项的提供程序怎么办?如果您删除一个提供者,它是否应该删除其所有依赖项,即使另一个提供者需要其中一个?添加测试,并实施您的规则。我想我会通过引用计数来处理它,但也许你想要第二个实例的提供者的副本,所以你永远不必担心共享孩子,你让事情变得更简单。或者,也许这在您的域中从来都不是问题。另一个棘手的问题是您的提供程序是否可以具有循环依赖关系。你如何确保你不会陷入自我参照循环?编写测试并弄清楚。

在你弄清楚整个结构之后,你才会开始考虑用来描述这个层次结构的数据。

这是我会考虑的方法。它可能不适合您,但由您决定。

【讨论】:

【参考方案5】:

类层次结构中的每一个依赖关系都必须可以从外部世界访问(不应高度耦合)。例如,如果您在 B 类中实例化 A 类,则 B 类必须为 B 类中的 A 类实例持有者实现 setter/getter 方法。

http://en.wikipedia.org/wiki/Dependency_injection

【讨论】:

【参考方案6】:

单元测试 使用单元测试,我们只想测试构成单个源代码单元的代码,通常是 PHP 中的类方法或函数 (Unit Testing Overview)。这表明我们不想在单元测试中实际测试外部 API,我们只想在本地测试我们正在编写的代码。如果您确实想要测试整个工作流程,您可能想要执行集成测试 (Integration Testing Overview),这是一个不同的野兽。

正如您特别询问的有关单元测试设计的问题,假设您实际上指的是单元测试而不是集成测试,并提出有两种合理的方式来设计您的 Provider 类。

存根 用(可选)返回配置的返回值的测试替身替换对象的做法称为存根。您可以使用存根“替换 SUT 所依赖的真实组件,以便测试具有用于 SUT 的间接输入的控制点。这允许测试强制 SUT 沿着它可能不会执行的路径”。 Reference & Examples

模拟对象 将对象替换为验证预期的测试替身(例如断言已调用方法)的做法称为模拟。

您可以使用模拟对象“作为观察点,用于验证 SUT 在执行时的间接输出。通常,模拟对象还包括测试存根的功能,因为它必须返回值到如果 SUT 尚未通过测试,但重点是验证间接输出。因此,模拟对象不仅仅是一个测试存根和断言;它的使用方式完全不同。 Reference & Examples

我们的建议 将您的课程设计为同时使用 Stubbing 和 Mocking。 PHP 单元手册有一个很好的example of Stubbing and Mocking Web Service。虽然这并不能帮助您开箱即用,但它展示了您将如何为您正在使用的 Restful API 实现相同的功能。

数据库查询的最佳位置在哪里? 我们建议您使用 ORM 而不是自己解决这个问题。您可以轻松地 Google PHP ORM 并根据自己的需要做出自己的决定; our advice is to use Doctrine 因为我们使用 Doctrine,它非常适合我们的需求,在过去的几年里,我们已经开始欣赏 Doctrine 开发人员对领域的了解程度,简单地说,他们做得比我们自己做的更好,所以我们很高兴让他们为我们做这件事。

如果您不太了解为什么应该使用 ORM,请参阅 Why should you use an ORM?,然后在 Google 上搜索相同的问题。如果您仍然觉得自己可以推出自己的 ORM 或以其他方式比专门从事数据库访问的人更好地处理数据库访问,我们希望您已经知道问题的答案。如果您觉得自己迫切需要自己处理它,我们建议您查看一些 ORM (See Doctrine on Github) 的源代码,并找到最适合您的场景的解决方案。

感谢您提出一个有趣的问题,非常感谢。

【讨论】:

关于单元测试的好答案,但它不涵盖我的提供程序/服务类的整体架构以支持单元测试。我对我应该专门使用哪些设计模式来支持我的特定场景感兴趣。

以上是关于如何构建我的类以更轻松地进行单元测试?的主要内容,如果未能解决你的问题,请参考以下文章

如何对 Excel VBA 代码进行单元测试

如何以更少的配置加速单元测试

如何更好地构建 Jasmine 单元测试,使它们不会出现在一个巨大的文件中?

MyBatis - 如何对结果图进行单元测试?

iOS 单元/应用程序测试目标如何链接到其宿主应用程序中的类?

如何对一个扩展了抽象类的类进行单元测试,读取环境变量。