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

Kon*_*nov 8 c# tdd f# entity-framework dependency-injection

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

我试着在那里发布问题.但是,由于网站上的一些故障,似乎无法再注册帖子.所以,这里是:

我想知道该帖子中描述的场景将如何工作/转化为更真实的例子.下面的数字是从天空稍微有点,所以,请在需要时调整它们.

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

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

每个接口都有一个生产级实现,具有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我不是在寻找确切的答案.有一些想法的"方向"就足够了.

Tom*_*cek 10

我认为答案所依赖的一个关键问题是应用程序遵循的外部I/O通信模式是什么,控制交互的逻辑有多复杂.

在简单的场景中,你有这样的事情:

+-----------+      +---------------+      +---------------+      +------------+
| Read data | ---> | Processing #1 | ---> | Processing #2 | ---> | Write data |
+-----------+      +---------------+      +---------------+      +------------+
Run Code Online (Sandbox Code Playgroud)

在这种情况下,很少需要在设计良好的功能代码库中进行模拟.原因是您可以在没有任何I/O的情况下测试所有处理函数(它们只是获取一些数据并返回一些数据的函数).至于阅读和写作,实际测试很少 - 这些只是做你在模拟接口的"实际"实现中所做的工作.通常,您可以使读取和写入功能尽可能简单,并在处理功能中具有所有逻辑.这是功能风格的甜蜜点!

在更复杂的场景中,您有这样的事情:

+----------+      +----------------+      +----------+      +------------+      +----------+
| Some I/O | ---> | A bit of logic | ---> | More I/O | ---> | More logic | ---> | More I/O |
+----------+      +----------------+      +----------+      +------------+      +----------+
Run Code Online (Sandbox Code Playgroud)

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

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

  • 使用更加面向对象的接口--F#是一种混合的FP和OO语言,所以它也很好地支持它.特别是使用匿名接口实现意味着您通常不需要模拟库.

  • 使用"解释器"模式,其中计算以(嵌入式)域特定语言编写,该语言描述了需要完成哪些计算和I/O(实际上没有这样做).然后,您可以在实际和测试模式下以不同方式解释DSL.

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


Gru*_*oon 8

只是为了增加Tomas的出色答案,这里还有其他一些建议。

为每个工作流程使用管道

正如Tomas所提到的,在FP设计中,我们倾向于使用面向管道的设计,每个用例/工作流/场景都有一个管道。

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

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

例如,如果工作流确实确实需要五个以上的参数,那么可能值得创建一个数据结构来容纳这些依赖关系并将其传递给:

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 ...
        }
Run Code Online (Sandbox Code Playgroud)

注意,依赖关系通常只是单个功能,而不是整个接口。您只应要求您!

考虑具有多个“合成根”

您可能会考虑拥有多个“组合根”-一个用于内部服务,一个用于外部。

我通常将代码分解为仅包含纯代码的“ Core”程序集和“ API”或“ WebService”程序集,该程序集读取配置并设置外部服务。“内部”合成词根位于“核心”程序集中,而“外部”合成词根位于“ API”程序集中。

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

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 = ...
Run Code Online (Sandbox Code Playgroud)

然后,将此模块用于“集成测试(级别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 = ...
Run Code Online (Sandbox Code Playgroud)

然后,在“集成测试(第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 
      ]
  ]   
Run Code Online (Sandbox Code Playgroud)

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