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 模块),我需要注册、更新、删除等。这些提供者中的每一个都与一个用户帐户相关联。

我想以一种在触发创建新提供者时可以指定操作顺序(步骤)的方式构建这个系统。换句话说,允许我指定在事件链中接下来触发哪个提供程序/方法组合,因为创建可以跨越很多步骤。目前,我通过主题/观察者模式发生了这一系列事件。我正在寻找可能将此代码移动到数据库表中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')

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

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

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

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

4

6 回答 6

6

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

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

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

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

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

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

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

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

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

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

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

这就是我会考虑的方法。它可能不适合你,但这是你自己决定的。

于 2013-06-07T18:55:39.623 回答
4

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

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

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

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

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

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

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

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

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

于 2013-06-03T13:21:05.420 回答
2

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

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

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

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

所以这里有一个 TODO:

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

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

于 2013-06-10T08:28:30.643 回答
2

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

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

于 2013-06-06T02:24:05.473 回答
1

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

此外,您可能想阅读有关Functors/Function Objects的信息,特别是Closure、Predicate、Transformer 和 Supplier以创建您的参与组件。希望有帮助。

于 2013-06-11T07:45:54.667 回答
0

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

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

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

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

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

这是一些示例:https ://github.com/Metabor/Statemachine

于 2013-06-11T18:04:55.353 回答