如何为更轻松的单元测试设计我的类结构?

28

我承认,我没有进行单元测试...但我想要。话虽如此,我有一个非常复杂的注册过程,希望能够优化以便更容易进行单元测试。我正在寻找一种结构化类的方法,以便将来可以更轻松地对其进行测试。所有这些逻辑都包含在MVC框架中,因此您可以假设控制器是根,从中实例化所有内容。

简单来说,我实际上是在问如何设置系统,以便您可以使用CRUD更新管理任意数量的第三方模块。这些第三方模块都是RESTful API驱动的,并且响应数据存储在本地副本中。例如删除用户帐户需要触发所有相关模块(我称之为提供程序)的删除。这些提供程序可能依赖于另一个提供程序,因此删除/创建的顺序很重要。我想知道应该使用哪些设计模式来支持我的应用程序

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

  1. Provider::create('external::create-user') 在特定提供商的特定步骤上启动注册。第一个参数中的双冒号语法表示该类应触发providerClass::providerMethod上的创建。我做出了一个一般性假设,即Provider将是一个接口,其方法create()update()delete()会被所有其他提供程序实现。如何实例化它可能是您需要帮助我的事情。
  2. $user = Provider_External::createUser() 在外部API上创建用户,返回成功,并将用户存储在我的数据库中。
  3. $customer = Provider_Gapps_Customer::create($user) 在第三方API上创建客户端,返回成功,并在本地存储。
  4. $subscription = Provider_Gapps_Subscription::create($customer) 创建与之前创建的客户相关联的订阅的第三方API,返回成功,并在本地存储。
  5. Provider_Gapps_Verification::get($customer, $subscription) 从外部API检索行。这些信息被存储在本地。我跳过了另一个调用,以保持简洁。
  6. Provider_Gapps_Verification::verify($customer, $subscription) 执行外部API验证过程。其结果被本地存储。

这只是一个简化的示例,实际代码至少涉及6个外部API调用和在注册过程中创建的10多个本地数据库行。由于可能需要在控制器中实例化6个类而不知道是否需要它们全部,因此在构造函数级别上使用依赖项注入没有意义。我想要实现的内容类似于Provider::create('external'),其中我只需指定启动注册的起始步骤。


问题的关键

因此,您可以看到,这只是一个注册过程的样本。我正在构建一个系统,可以让我注册、更新、删除等数百个服务提供商(外部API模块)。每个提供程序都与用户帐户相关联。

我希望以这样一种方式构建此系统

  # 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')

这里有太多决策需要做出……我知道我应该优先考虑组合而非继承,并创建一些接口。我也知道我可能需要工厂模式。最后,我在这里有很多领域模型的事情……所以我可能需要业务领域类。但我不确定如何将它们全部融合在一起,而不是在追求圣杯的过程中制造一个彻底的混乱。

此外,最好的地方去进行数据库查询是哪里?

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

我已经阅读过的内容...

6个回答

6
你已经在使用发布/订阅模式,这似乎是合适的。根据你上面的评论,我会考虑有序列表作为一种优先级机制。但每个订阅者都关心其依赖项的操作顺序来触发成功或失败,这仍然不太正确。依赖关系通常似乎应该属于树而不是列表。如果您将它们存储在树中(使用组合模式),那么内置递归将能够通过首先清除其依赖项来清除每个依赖关系。这样,您就不再担心按照清理发生的顺序进行优先排序 - 树会自动处理。

您还可以像使用列表一样轻松地使用树来存储发布/订阅订户。

使用测试驱动开发方法可以帮助您获得所需内容,并确保您的整个应用程序不仅完全可测试,而且完全由证明其执行您想要的操作的测试覆盖。您可以从描述满足单个要求所需的确切步骤开始。您知道自己想要添加提供程序,因此TestAddProvider()测试似乎很合适。请注意,此时它应该相当简单,并且与组合模式无关。一旦可以运行,您就会知道提供程序有一个依赖项。创建TestAddProviderWithDependent()测试,看看它是否正常工作。同样,这不应该是复杂的。接下来,您可能想要TestAddProviderWithTwoDependents(),那里将实现列表。一旦可以运行,您就会知道要使提供者也成为受托人,因此新测试将证明继承模型正常工作。从那里开始,您将添加足够的测试来说服自己各种添加提供程序和受托人的组合都有效,并测试异常条件等等。仅凭测试和要求,您很快就能得出满足您需求的组合模式。此时,我将打开我的GoF副本,以确保我了解选择组合模式的后果,并确保我没有添加不适当的瑕疵。

另一个已知的要求是删除提供程序,因此创建TestDeleteProvider()测试并实现DeleteProvider()方法。您离提供程序删除其受托人也不远了,因此下一步可能是创建TestDeleteProviderWithADependent()测试。此时,组合模式的递归应该是显而易见的,您只需要添加更多的测试即可使自己相信深度嵌套的提供程序、空叶子、宽节点等等都会正确地清除自己。

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

您可能会发现,必须按特定顺序调用提供程序。此时,您可能需要在组合树中的每个节点列表中添加优先级。或者,您可能需要构建一个完全不同的结构(例如链接列表)以按正确顺序调用它们。使用测试并缓慢逼近。您可能仍然会有人担心您按特定外部规定的顺序删除从属项。此时,您可以使用测试向怀疑者证明,即使不按照他们所想的顺序删除它们,您也将始终安全地删除它们。
如果您一直做得正确,那么之前的所有测试都应该继续通过。
然后是棘手的问题。如果您有两个提供程序共享一个公共依赖关系怎么办?如果删除一个提供程序,是否应该删除其所有依赖项,即使不同的提供程序需要其中一个依赖项?添加测试并实施您的规则。我认为我会通过引用计数来处理它,但也许您想要第二个实例的提供程序副本,这样您就永远不必担心共享子项,并且保持简单。或者,在您的领域中从未出现过这种问题。另一个棘手的问题是,如果您的提供程序可能存在循环依赖关系。如何确保您不会陷入自我引用的循环中?编写测试并找出答案。
在您完全弄清楚了这整个结构之后,才会开始考虑用于描述此层次结构的数据。
这是我考虑的方法。可能不适合您,但这取决于您自己的决定。

4
单元测试 通过单元测试,我们只需测试组成源代码的单个单元,通常是PHP中的类方法或函数(单元测试概述)。这表明我们不想在单元测试中测试外部API,我们只想测试本地编写的代码。如果你确实想要测试整个工作流程,你可能想要执行集成测试(集成测试概述),那是一种不同的技术。
因为您特别询问如何为单元测试设计,所以假设您实际上是指单元测试,并提交有两种合理的方法来设计您的提供者类。 存根 将对象替换为返回配置返回值的测试双倍被称为stubbing,您可以使用stub "替换SUT依赖的真实组件,以使测试具有SUT的间接输入的控制点。这允许测试强制SUT进入它可能不会执行的路径"。参考和示例 模拟对象 用验证期望值的测试双倍替换对象,例如断言已调用某个方法,被称为mocking。
您可以使用mock对象"作为用于验证SUT被执行时的间接输出的观察点。通常,mock对象也包括测试stub的功能,因为它必须向SUT返回值(如果尚未失败测试),但重点在于验证间接输出。因此,mock对象不仅是测试stub加断言;它以根本不同的方式使用"。参考和示例 我们的建议 设计您的类以同时兼容Stubbing和Mocking。PHP Unit手册提供了一个出色的Web服务Stubbing和Mocking示例。虽然这不能直接帮助您,但它演示了如何实现相同的Restful API。

最佳的数据库查询位置在哪里?

我们建议您使用ORM而不是自己解决这个问题。您可以轻松地在谷歌上搜索PHP ORM并根据自己的需求做出决定;我们的建议是使用Doctrine,因为我们使用Doctrine并且它很好地满足了我们的需求,在过去的几年中,我们开始欣赏Doctrine开发人员对领域的深入了解,简单来说,他们比我们自己做得更好,因此我们很高兴让他们替我们处理。

如果您真的不理解为什么应该使用ORM,请参阅为什么应该使用ORM?,然后再谷歌同样的问题。如果您仍然觉得自己可以编写自己的ORM或以其他方式处理数据库访问比那些专门从事此事的人更好,那么我们希望您已经知道了答案。如果您感到有迫切需要自行处理,请查看一些ORM的源代码(请参阅Github上的Doctrine),找到最适合您情况的解决方案。

感谢您提出这个有趣的问题,我很感激。


关于单元测试的回答很好,但它并没有涵盖我的提供者/服务类的整体架构以支持单元测试。我想知道应该使用哪些设计模式来支持我的特定场景。 - Corey Ballou

2
我能看到你代码中最大的问题,也是导致你无法进行测试的障碍,就是使用静态类方法调用:
  • Provider::create('external::create-user')
  • $user = Provider_External::createUser()
  • $customer = Provider_Gapps_Customer::create($user)
  • $subscription = Provider_Gapps_Subscription::create($customer)
  • ...
这在你的代码中已经普遍存在,即使你仅仅出于"简洁性"而将它们定义为静态的。这种做法并不是简洁,它反而会妨碍可测试性。请千万不要使用这种方法,甚至在提问单元测试方面时,都要避免这种写法,因为这是一个众所周知的坏习惯,这样的代码很难测试。
当你把所有静态调用转换成对象方法调用,并使用依赖注入来传递对象后,你就可以使用PHPUnit进行单元测试,包括使用存根和模拟对象在你的(简单)测试中进行协作。
所以,这里有一些待办事项:
  1. 将静态方法调用重构为对象方法调用。
  2. 使用依赖注入来传递对象。
这样你就能大大改进你的代码了。如果你认为自己无法做到,请不要浪费时间来进行单元测试,而是将时间用于维护你的应用程序,快速发布并赚取一些收入。但请不要让你的编程生涯被困在单元测试静态全局状态中,这样做只会让人感觉愚蠢。

我实际上只是发布了静态内容,以保持简洁,不必延长我的代码示例。没有什么是静态的;所有类都通过它们的构造函数实例化。 - Corey Ballou

2
你的类层次结构中每个依赖关系都必须对外可访问(不应高度耦合)。例如,如果你在类B中实例化类A,则类B必须实现类A实例持有者的setter/getter方法。更多信息请参考http://en.wikipedia.org/wiki/Dependency_injection

1
请看下面的翻译:

考虑为每个层次定义角色和职责,来分层你的应用程序。您可以从Apache-Axis消息流子系统中获得灵感。核心思想是创建一系列处理程序,通过这些处理程序请求流动直到被处理。这样的设计有助于可插入组件,这些组件可以捆绑在一起创建更高级别的功能。

此外,您可能会喜欢阅读有关函数对象/函数的内容,特别是闭包、谓词、转换器和供应商,以创建参与组件。希望这有所帮助。


0

你有没有看过状态设计模式?http://en.wikipedia.org/wiki/State_pattern 你可以将所有步骤作为状态机中的不同状态,并且它看起来像一个图形。你可以将这个图形存储在数据库表/XML中,每个提供者也可以拥有自己的图形,表示执行顺序。

因此,当你进入某个状态时,你可以触发事件(保存用户,获取用户)。我不知道你的应用程序具体情况,但是其他提供者可以重复使用这些事件。

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

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

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

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


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