真实项目依赖注入的 F# 模拟

F# analog of dependency injection for a real project

这个问题是基于一个很好的 F#/DI 相关 post:https://fsharpforfunandprofit.com/posts/dependency-injection-1/

我试着post那里的问题。然而,由于网站上的一些故障,posts 似乎无法再注册。所以,这里是:

我想知道 post 中描述的场景如何工作/转化为更真实的示例。下面的数字有点天方夜谭,请大家酌情调整。

考虑一些相当小的基于 C# 的基于 DI / TDD / EF Code First 的项目:

组合根:20 个接口,每个接口有 10 个方法(平均)。好的,这可能是每个接口的方法太多,但不幸的是,随着代码的开发,它们往往会膨胀。我见过更多。其中,10 个是没有任何 IO 的内部服务(func 世界中没有数据库/"pure" 函数),5 个是内部 IO(本地数据库和类似的),最后 5 个是外部服务(如外部数据库或调用某些远程第三方服务的任何其他东西)。

每个接口都有一个生产级实现,带有 4 个注入接口(平均),每个接口使用 5 个成员,每个实现总共使用 20 个方法(平均)。

有多个级别的测试:单元测试、集成测试(两个级别)、验收测试。

单元测试:所有调用都使用适当的模拟设置进行模拟(例如使用一些标准工具,例如 Moq)。因此,至少有 20 * 10 = 200 个单元测试。通常会有更多,因为测试了几种不同的场景。

集成测试(级别 1):所有没有 IO 的内部服务都是真实的,所有与 IO 相关的内部服务都是假的(通常是内存数据库),所有外部服务都代理到一些假的/模拟的。基本上这意味着所有内部 IO 服务,如 SomeInternalIOService : ISomeInternalIOService 被替换为 FakeSomeInternalIOService : ISomeInternalIOService 和所有外部 IO 服务,如 SomeExternalIOService : ISomeExternalIOService 被替换为 FakeSomeExternalIOService : ISomeExternalIOService。因此,有 5 个伪造的内部 IO 和 5 个伪造的外部 IO 服务以及与上述相同数量的测试。

集成测试(级别 2):所有外部服务(包括现在与本地数据库相关的服务)都是真实的,并且所有外部服务都代理到其他一些假货/模拟,这允许测试外部服务的故障。基本上这意味着所有外部 IO 服务,如 SomeExternalIOService : ISomeExternalIOService 被 BreakableFakeSomeExternalIOService : ISomeExternalIOService 取代。有 5 种不同的(可破坏的)外部 IO 虚假服务。假设我们有大约 100 个这样的测试。

验收测试:一切都是真实的,但配置文件指向外部服务的一些“测试”版本。假设有大约 50 个这样的测试。

我想知道这将如何转化为 F# 世界。显然,很多东西 非常 不同,有些东西甚至可能不存在于 F# 世界 中!

非常感谢!

PS 我不是在寻找确切的答案。 "direction" 有一些想法就足够了。

我认为答案取决于一个关键问题是应用程序遵循的与外部 I/O 的通信模式是什么以及控制交互的逻辑有多复杂。

在简单的场景中,你有这样的东西:

+-----------+      +---------------+      +---------------+      +------------+
| Read data | ---> | Processing #1 | ---> | Processing #2 | ---> | Write data |
+-----------+      +---------------+      +---------------+      +------------+

在这种情况下,几乎不需要在设计精美的功能代码库中进行模拟。原因是您可以在没有任何 I/O 的情况下测试所有处理函数(它们只是获取一些数据和 return 一些数据的函数)。至于阅读和写作,几乎没有什么可以在那里实际测试的——这些大部分只是在做你在你的可模拟接口的 "actual" 实现中所做的工作。通常,您可以使读写功能尽可能简单,并将所有逻辑放在处理功能中。这是功能风格的最佳选择!

在更复杂的情况下,你有这样的事情:

+----------+      +----------------+      +----------+      +------------+      +----------+
| Some I/O | ---> | A bit of logic | ---> | More I/O | ---> | More logic | ---> | More I/O |
+----------+      +----------------+      +----------+      +------------+      +----------+

在这种情况下,I/O 与程序逻辑交错太多,因此如果没有某种形式的模拟,很难对较大的逻辑组件进行任何测试。在这种情况下,the series by Mark Seemann是一个很好的综合资源。我认为你的选择是:

  • 传递函数(并使用部分应用程序)- 这是一种简单的函数式方法,除非您需要传递太多参数,否则它会起作用。

  • 使用带有接口的更面向对象的体系结构 - F# 是一种混合的 FP 和 OO 语言,因此它对此也有很好的支持。特别是使用匿名接口实现意味着您通常不需要模拟库。

  • 使用 "interpreter" 模式,其中计算是用(嵌入式)领域特定语言编写的,该语言描述了哪些计算以及 I/O 需要完成的工作(无需实际执行)它)。然后你可以在真实模式和测试模式下对 DSL 进行不同的解释。

  • 在一些函数式语言中(主要是 Scala 和 Haskell),人们喜欢使用一种称为 "free monads" 的技术来完成上述操作,但对此的典型描述往往是在我看来过于复杂。 (也就是说,如果你知道什么是免费的 monad,这可能是有用的指针,但除此之外,你可能最好不要进入这个兔子洞)。

为了补充 Tomas 的出色回答,这里还有一些其他建议。

为每个工作流程使用管道

正如 Tomas 所说,在 FP 设计中,我们倾向于使用面向流水线的设计,每个流水线对应一个 use-case/workflow/scenario。

这种方法的好处在于,这些管道中的每一个都可以独立设置,具有自己的组合根。

你说你有 20 个接口,每个接口有 10 个方法。 每个 工作流程是否需要所有 这些接口和方法? 根据我的经验,一个单独的工作流可能只需要其中的几个,在这种情况下,组合根中的逻辑变得容易得多。

如果一个工作流确实需要超过 5 个参数,比如说,那么可能值得创建一个数据结构来保存这些依赖项并将其传递给:

module BuyWorkflow =

    type Dependencies = {
       SaveSomething : Something -> AsyncResult<unit,DbError>
       LoadSomething : Key -> AsyncResult<Something,DbError>
       SendEmail : EmailMessage -> AsyncResult<unit,EmailError>
       ...
       }

    // define the workflow 
    let buySomething (deps:Dependencies) = 
        asyncResult {
           ...
           do! deps.SaveSomething ...
           let! something = deps.LoadSomething ...
        }

请注意,依赖项通常只是单个函数,而不是整个接口。你应该只要求你需要的!

考虑拥有多个 "composition root"

您可以考虑拥有多个 "composition root" -- 一个用于内部服务,一个用于外部服务。

我通常将我的代码分解成一个 "Core" 程序集,只有纯代码和一个 "API" 或 "WebService" 程序集 它读取配置并设置外部服务。 "internal" 组合根位于 "Core" 程序集中,"external" 组合根位于 "API" 程序集中。

例如,在 "Core" 程序集中,您可以拥有一个在内部纯服务中烘焙的模块。这是一些伪代码:

module Workflows =

    // set up pure services
    let internalServiceA = ...
    let internalServiceB = ...
    let internalServiceC = ...

    // set up workflows
    let homeWorkflow = homeWorkflow internalServiceA.method1 internalServiceA.method2 
    let buyWorkflow = buyWorkflow internalServiceB.method2 internalServiceC.method1 
    let sellWorkflow = ...

然后您将此模块用于您的 "Integration Tests (level 1)"。 此时工作流仍然缺少它们的外部依赖性,因此您将需要提供用于测试的模拟。

同样,在 "API" 程序集中,您可以有一个提供外部服务的组合根。

module Api =

    // load from configuration
    let dbConnectionA = ...
    let dbConnectionB = ...

    // set up impure services
    let externalServiceA = externalServiceA(dbConnectionA)
    let externalServiceB = externalServiceB(dbConnectionB)
    let externalServiceC = ...

    // set up workflows
    let homeWorkflow = Workflows.homeWorkflow externalServiceA.method1 externalServiceA.method2 
    let buyWorkflow = Workflows.buyWorkflow externalServiceB.method2 externalServiceC.method1 
    let sellWorkflow = ...

然后在您的 "Integration Tests (level 2)" 和其他顶级代码中,您使用 Api 工作流程:

// setup routes (using Suave/Giraffe style)
let routes : WebPart =
  choose [
    GET >=> choose [
      path "/" >=> Api.homeWorkflow 
      path "/buy" >=> Api.buyWorkflow 
      path "/sell" >=> Api.sellWorkflow 
      ]
  ]   

验收测试(使用不同的配置文件)可以使用相同的代码。