如何 运行 `Create` 函数中的子项以及如何为图表 `Series` 创建 mock/stub/fake?

How to run a sub in a `Create` function and how to make a mock/stub/fake for a chart `Series`?

前言

大约 10 年前,我开始重构和改进 John Walkenbach 的 ChartSeries class。不幸的是,原来的它似乎不再在线。

跟随 Rubberduck Blog 一段时间后,我努力提高自己的 VBA 技能。但过去我只写过——我想专家们会称之为——“脚本般的上帝程序”(因为不知道更好)。所以我对 classes 很陌生,尤其是接口和工厂。

实际问题

我尝试通过将其分成多个 class 来重构整个 class,同时使用接口并添加单元测试。对于仅 阅读 公式的部分,获取 Series.Formula 然后进行所有处理就足够了。所以最好在 Create 函数中调用 Run sub。但是到目前为止我尝试做的一切都失败了。因此,我目前 运行 Run 在所有 Get 属性等中(并测试,如果公式更改并退出 Run 比。这可能吗?如果是,如何?

其次,要添加单元测试——当然要为它们使用 ——我目前依赖 real Charts/ChartObjects.如何为 Series 创建 stub/mock/fake? (抱歉,我不知道正确的术语。)

这里是代码的简化版本。

非常感谢您的帮助。

普通模块

'@Folder("ChartSeries")

Option Explicit

Public Sub ExampleUsage()

    Dim wks As Worksheet
    Set wks = ThisWorkbook.Worksheets(1)
    
    Dim crt As ChartObject
    Set crt = wks.ChartObjects(1)
    
    Dim srs As Series
    Set srs = crt.Chart.SeriesCollection(3)
    
    Dim MySeries As IChartSeries
    Set MySeries = ChartSeries.Create(srs)
    With MySeries
        Debug.Print .XValues.FormulaPart
    End With
End Sub

IChartSeries.cls

'@Folder("ChartSeries")
'@Interface

Option Explicit

Public Function IsSeriesAccessible() As Boolean
End Function

Public Property Get FullFormula() As String
End Property

Public Property Get XValues() As ISeriesPart
End Property

'more properties ...

ChartSeries.cls

'@PredeclaredId
'@Exposed
'@Folder("ChartSeries")

Option Explicit
Implements IChartSeries

Private Type TChartSeries
   Series As Series
   FullSeriesFormula As String
   OldFullSeriesFormula As String
   IsSeriesAccessible As Boolean
   SeriesParts(eElement.[_First] To eElement.[_Last]) As ISeriesPart
End Type
Private This As TChartSeries

Public Function Create(ByVal Value As Series) As IChartSeries
'NOTE: I would like to run the 'Run' sub somewhere here (if possible)
   With New ChartSeries
      .Series = Value
      Set Create = .Self
   End With
End Function

Public Property Get Self() As IChartSeries
    Set Self = Me
End Property

Friend Property Let Series(ByVal Value As Series)
   Set This.Series = Value
End Property

Private Function IChartSeries_IsSeriesAccessible() As Boolean
   Call Run
   IChartSeries_IsSeriesAccessible = This.IsSeriesAccessible
End Function

Private Property Get IChartSeries_FullFormula() As String
   Call Run
   IChartSeries_FullFormula = This.FullSeriesFormula
End Property

Private Property Get IChartSeries_XValues() As ISeriesPart
   Call Run
   Set IChartSeries_XValues = This.SeriesParts(eElement.eXValues)
End Property

'more properties ...

Private Sub Class_Initialize()
   With This
      Dim Element As eElement
      For Element = eElement.[_First] To eElement.[_Last]
         Set .SeriesParts(Element) = New SeriesPart
      Next
   End With
End Sub

Private Sub Class_Terminate()
   With This
      Dim Element As LongPtr
      For Element = eElement.[_First] To eElement.[_Last]
         Set .SeriesParts(Element) = Nothing
      Next
   End With
End Sub

Private Sub Run()

   If Not GetFullSeriesFormula Then Exit Sub
   If Not HasFormulaChanged Then Exit Sub
   Call GetSeriesFormulaParts

End Sub

'(simplified version)
Private Function GetFullSeriesFormula() As Boolean

   GetFullSeriesFormula = False

   With This
'---
'dummy to make it work
.FullSeriesFormula = _
"=SERIES(Tabelle1!$B,Tabelle1!$A:$A,Tabelle1!$B:$B,1)"
'---
      .OldFullSeriesFormula = .FullSeriesFormula
      .FullSeriesFormula = .Series.Formula
   End With
   
   GetFullSeriesFormula = True

End Function

Private Function HasFormulaChanged() As Boolean
   With This
      HasFormulaChanged = (.OldFullSeriesFormula <> .FullSeriesFormula)
   End With
End Function

Private Sub GetSeriesFormulaParts()
   
   Dim MySeries As ISeriesFormulaParts
   '(simplified version without check for Bubble Chart)
   Set MySeries = SeriesFormulaParts.Create( _
         This.FullSeriesFormula, _
         False _
   )
   
   With MySeries
      Dim Element As eElement
      For Element = eElement.[_First] To eElement.[_Last] - 1
         This.SeriesParts(Element).FormulaPart = _
               .PartSeriesFormula(Element)
      Next
'---
'dummy which normally would be retrieved
'by 'MySeries.PartSeriesFormula(eElement.eXValues)'
This.SeriesParts(eElement.eXValues).FormulaPart = _
"Tabelle1!$A:$A"
'---
   End With
   
   Set MySeries = Nothing

End Sub

'more subs and functions ...

ISeriesPart.cls

'@Folder("ChartSeries")
'@Interface

Option Explicit

Public Enum eEntryType
   eNotSet = -1
   [_First] = 0
   eInaccessible = eEntryType.[_First]
   eEmpty
   eInteger
   eString
   eArray
   eRange
   [_Last] = eEntryType.eRange
End Enum

Public Property Get FormulaPart() As String
End Property

Public Property Let FormulaPart(ByVal Value As String)
End Property

Public Property Get EntryType() As eEntryType
End Property

Public Property Get Range() As Range
End Property

'more properties ...

SeriesPart.cls

'@PredeclaredId
'@Folder("ChartSeries")
'@ModuleDescription("A class to handle each part of the 'Series' string.")

Option Explicit

Implements ISeriesPart

Private Type TSeriesPart
   FormulaPart As String
   EntryType As eEntryType
   Range As Range
   RangeString As String
   RangeSheet As String
   RangeBook As String
   RangePath As String
End Type
Private This As TSeriesPart

Private Property Get ISeriesPart_FormulaPart() As String
   ISeriesPart_FormulaPart = This.FormulaPart
End Property

Private Property Let ISeriesPart_FormulaPart(ByVal Value As String)
   This.FormulaPart = Value
   Call Run
End Property

Private Property Get ISeriesPart_EntryType() As eEntryType
   ISeriesPart_EntryType = This.EntryType
End Property

Private Property Get ISeriesPart_Range() As Range
   With This
      If .EntryType = eEntryType.eRange Then
         Set ISeriesPart_Range = .Range
      Else
'         Call RaiseError
      End If
   End With
End Property

Private Property Set ISeriesPart_Range(ByVal Value As Range)
   Set This.Range = Value
End Property

'more properties ...

Private Sub Class_Initialize()
   This.EntryType = eEntryType.eNotSet
End Sub

Private Sub Run()
   '- set 'EntryType'
   '- If it is a range then find the range parts ...
End Sub

'a lot more subs and functions ...

ISeriesParts.cls

'@Folder("ChartSeries")
'@Interface

Option Explicit

Public Enum eElement
   [_First] = 1
   eName = eElement.[_First]
   eXValues
   eYValues
   ePlotOrder
   eBubbleSizes
   [_Last] = eElement.eBubbleSizes
End Enum

'@Description("fill me")
Public Property Get PartSeriesFormula(ByVal Element As eElement) As String
End Property

SeriesFormulaParts.cls

'@PredeclaredId
'@Exposed
'@Folder("ChartSeries")

Option Explicit

Implements ISeriesFormulaParts

Private Type TSeriesFormulaParts
   FullSeriesFormula As String
   IsSeriesInBubbleChart As Boolean
   WasRunCalled As Boolean
   
   SeriesFormula As String

   RemainingFormulaPart(eElement.[_First] To eElement.[_Last]) As String
   PartSeriesFormula(eElement.[_First] To eElement.[_Last]) As String
End Type
Private This As TSeriesFormulaParts

Public Function Create( _
   ByVal FullSeriesFormula As String, _
   ByVal IsSeriesInBubbleChart As Boolean _
      ) As ISeriesFormulaParts
'NOTE: I would like to run the 'Run' sub somewhere here (if possible)
   With New SeriesFormulaParts
      .FullSeriesFormula = FullSeriesFormula
      .IsSeriesInBubbleChart = IsSeriesInBubbleChart
      Set Create = .Self
   End With
End Function

Public Property Get Self() As ISeriesFormulaParts
    Set Self = Me
End Property

'@Description("Set the full series formula ('ChartSeries')")
Public Property Let FullSeriesFormula(ByVal Value As String)
   This.FullSeriesFormula = Value
End Property

Public Property Let IsSeriesInBubbleChart(ByVal Value As Boolean)
   This.IsSeriesInBubbleChart = Value
End Property

Private Property Get ISeriesFormulaParts_PartSeriesFormula(ByVal Element As eElement) As String
'NOTE: Instead of running 'Run' here, it would be better to run it in 'Create'
   Call Run
   ISeriesFormulaParts_PartSeriesFormula = This.PartSeriesFormula(Element)
End Property

'(replaced with a dummy)
Private Sub Run()

   If This.WasRunCalled Then Exit Sub
   'extract stuff from
   This.WasRunCalled = True
   
End Sub

'a lot more subs and functions ...

你已经可以了!

Public Function Create(ByVal Value As Series) As IChartSeries
   With New ChartSeries <~ With block variable has access to members of the ChartSeries class
      .Series = Value
      Set Create = .Self
   End With
End Function

...只是,像 .Series.Self 属性一样,它必须是 ChartSeries interface/class 的 Public 成员( VBA 中的线条很模糊,因为每个 class 都有一个默认界面/也是一个界面)。

惯用对象分配

关于此的说明 属性:

Friend Property Let Series(ByVal Value As Series)
   Set This.Series = Value
End Property

Property Let 成员用于 Set 对象引用将起作用 - 但它不再是惯用的 VBA 代码,如您在 .Create 中所见功能:

      .Series = Value

如果我们在不知道 属性 的性质的情况下阅读这一行,这看起来就像任何其他赋值。唯一的问题是,我们没有分配 value,而是分配 reference - VBA 中的引用分配通常使用Set 关键字。如果我们在 Series 属性 定义中将 Let 更改为 Set,我们将不得不这样做:

      Set .Series = Value

这看起来更像是参考作业!没有它,似乎隐含的 let-coercion 正在发生,这使得代码变得模棱两可:VBA 需要一个 Set 关键字来进行引用赋值,因为任何给定的对象 都可以 有一个无参数的默认值 属性(例如 foo = Range("A1") 如何将 foo 隐式分配给 RangeValue


缓存和职责

现在,回到 Run 方法 - 如果它在 ChartSeries class 上创建 Public,但未在实现的 IChartSeries 接口上公开,则它是一个只能从 1) ChartSeries 默认实例或 2) 任何具有 ChartSeries 声明类型的对象变量调用的成员。由于我们的 "client code" 正在处理 IChartSeries,我们可以防范 1 ​​而摆脱 2.

请注意,Call 关键字是多余的,Run 方法实际上只是从封装的 Series 对象中提取元数据,并将其缓存在实例级别 - 我会给它起一个听起来更像 "refresh cached properties" 而不是 "run something".

的名字

您的预感很好:Property Get 应该是一个简单的 return 函数,没有任何副作用。在 Property Get 访问器中调用扫描对象并重置实例状态的方法会产生副作用,这在理论上是一种设计味道。

如果RunCreate函数return实例创建之前立即被调用,那么这个Run方法归结为"parse the series and cache some metadata I'll reuse later",这并没有错:从 Create 调用它,并从 Property Get 访问器中删除它。

结果是一个状态为只读且定义更可靠的对象;对应的是你现在有一个对象,其状态可能与工作表上的实际 Excel Series 对象不同步:如果代码(或用户)调整 Series IChartSeries 初始化后的对象,对象及其状态是陈旧的。

一个解决方案是不遗余力地识别系列何时过时,并确保保持缓存最新。

另一种解决方案是通过不再缓存状态来完全解决问题 - 这意味着以下两种情况之一:

  1. 在创建时生成一次对象图,有效地将缓存责任转移给调用者:调用代码得到一个只读的 "snapshot" 来处理。

  2. 每次调用代码需要它时,都会从系列元数据中生成一个新的对象图:有效地将缓存责任转移给调用者,这根本不是一个坏主意。

将内容设置为只读可以消除很多复杂性!我会选择第一个选项。


总的来说,代码看起来干净整洁(尽管不清楚为此 post 擦除了多少)并且您似乎已经理解 工厂方法 模式利用默认实例并公开外观界面 - 荣誉!命名总体上非常好(尽管 "Run" 突出了 IMO),并且每个对象看起来都有一个明确、明确的目的。干得好!


单元测试

I currently rely on real Charts/ChartObjects. How do I create a stub/mock/fake for a Series? (Sorry, I don't know the correct term.)

目前,您不能。当 if/when this PR gets merged 时,您将能够 模拟 Excel 的接口(以及更多,更多)并针对您的 classes 编写测试注入一个模拟 Excel.Series 对象,你可以为你的测试目的配置它......但在那之前,这就是墙所在的地方。

与此同时,你能做的最好的事情就是用你自己的接口包装它,然后 stub 它。换句话说,只要你的代码和 Excel 的对象模型之间存在接缝,我们就会在两者之间插入一个接口:你不会接受 Excel.Series 对象,而是接受一些 ISeriesWrapper,然后实际代码将使用 ExcelSeriesWrapper 来处理 Excel.Series,测试代码可能使用 StubSeriesWrapper,其属性 return 或者硬编码值,或由测试配置的值:在 Excel 库和您的项目之间的接缝处工作的代码无法测试 - 我们无论如何都不想,因为那样我们会正在测试 Excel,而不是我们自己的代码。

您可以在下一篇即将发布的 RD 新闻文章的示例代码中看到这一点 here;那篇文章将使用 ADODB 连接来讨论这一点。原理是相同的:该项目中的 94 个单元测试中的 none 曾经打开任何实际连接,但是通过依赖注入和包装器接口,我们能够测试每一个功能,从打开数据库连接开始提交事务...而无需访问实际数据库。