如何设计和单元测试 VBA class 模块以将格式化表格添加到文档
How to design and unit test a VBA class module for adding formatted tables to document
这个问题的动机是获得一个具体的例子,说明修改文档时单元 testable 代码是什么样子的。作为背景,我很欣赏 classes 非常适合定义和验证事物,例如:
- 形状 - https://codereview.stackexchange.com/questions/172224/understanding-classes-in-vba-help-improve-these-comments
- 用户输入 - https://github.com/rubberduck-vba/Rubberduck/wiki/Unit-Testing#example
无论 class 模块是否适合 table 用于修改文档似乎 'depend'(请参阅此处的 Mat's Mug 回答:)而且我无法找到许多将单元测试用于修改文档的代码的示例 (也许这是一个很好的理由?)。
无论如何,基于我对事物的有限理解,我认为,对于 'adds formatted tables to documents' 的一个单词插件,一个 Class 模块为向文档添加格式化 table 的合理方法提供了基础... (如果我错了请告诉我).
虽然我已经标记了 VBA-Excel 我真的对 MS Word 示例(非常缺乏)更感兴趣所以通过一个相当简单的 MS Word 示例,让我们说我有将格式化的 tables 添加到指定范围内的文档的代码。
为了示例的目的让我们假设:
- 事件的基本顺序是:
- 将默认 table 添加到文档
- 后续按照INI文件格式化
- 为所有 table 指定的格式是:
- table边框线颜色
- table row1 底纹颜色
- INI文件指定了几个table
- tbl1-Border=wdRed
- tbl2-Border=wdGreen
- tbl1-Shading=wdRed
- tbl1-Shading=wdGreen
所以我接下来的问题是:
- 我应该计划多少 classes?
- 1 用于添加和格式化 tables
- 1 用于读取 INI 文件数据
- 每个 class 模块的结构是什么样的?
- 应该我(我可以)单元测试代码:
- 修改文档(添加一个table)?
- 读取 INI 文件?
我不指望任何人提供实际工作代码;但伪代码、一般建议和一些具体的指示将不胜感激。
注意:如果这个问题太宽泛,我很乐意分成几个单独的问题
工作表(或Word文档)无非是封装状态/数据的对象。
您可以不费吹灰之力,用您的代码所依赖的接口(例如 IWorksheet
或 IDocument
)包装 worksheet/document,但那将是巨大的努力,实际上几乎没有什么好处 - 单元测试必须使用该接口的 "fake" 实现,它将负责存储测试 data/state 以便您的测试可以断言您正在测试的代码是否按预期工作。完全矫枉过正。
相反,编写您的代码以便为它提供一个 Worksheet
实例(即避免针对 ActiveWorkbook
and/or ActiveSheet
),并执行它需要执行的任何操作用它。拆分职责,这样当您调用一个方法时,您就没有 20,000 件事情要断言以确保您的代码执行其编写的目的 - 但这不应该是任何新的或与您已经在做的事情不同,对吧?
'@Description("Adds a table named [tableName] on [sheet]. Returns the created table.")
Public Function AddTable(ByVal sheet As Worksheet, ByVal tableName As String) As ListObject
'TODO: implement
End Function
对这种方法的测试可能如下所示:
'@TestMethod
Public Sub AddsListObjectToSpecifiedWorksheet()
'Arrange
Dim sheet As Worksheet
Set sheet = ThisWorkbook.Worksheets.Add
Dim sut As MyAwesomeClass
Set sut = New MyAwesomeClass
Const tableName As String = "TestTable1"
If sheet.ListObjects.Count <> 0 Then _
Assert.Inconclusive "Sheet already has a table."
'Act
sut.AddTable sheet, tableName
'Assert
Assert.IsTrue sheet.ListObjects.Count = 1, "Table was not added."
sheet.Delete
End Sub
sheet
设置和清理代码可以移动到测试模块中的专用 TestInitialize
/TestCleanup
方法,因为该测试模块中的每个测试方法都可能会需要一个新的工作表来玩,因为您希望每个测试都是独立的并且不与其他测试共享任何状态。
将设置和清理代码提取到测试模块中的专用方法可以消除实际测试方法中的错误。毕竟,测试模块是一个标准模块,可以有自己的私有字段和模块级常量:
'@TestMethod
Public Sub ReturnsListObjectReference()
'Arrange
Dim sut As MyAwesomeClass
Set sut = New MyAwesomeClass
If testSheet.ListObjects.Count <> 0 Then _
Assert.Inconclusive "Sheet already has a table."
'Act
Dim result As ListObject
Set result = sut.AddTable(testSheet, tableName)
'Assert
Assert.IsNotNothing result, "Table was not returned."
Assert.AreSame result, testSheets.ListObjects(1), "Wrong table was returned."
End Sub
所以你继续编写测试,每个测试验证一个特定的行为:
'@TestMethod
Public Sub TableNameIsAsSpecified()
'Arrange
Dim sut As MyAwesomeClass
Set sut = New MyAwesomeClass
If testSheet.ListObjects.Count <> 0 Then _
Assert.Inconclusive "Sheet already has a table."
'Act
Dim result As ListObject
Set result = sut.AddTable(testSheet, tableName)
'Assert
Assert.AreEqual tableName, result.Name, "Table name wasn't set."
End Sub
这样,当您、未来的您或任何继承您代码的人查看您的测试套件时,他们就会确切地知道您的代码应该做什么,并且通过运行 测试他们将 知道 您的代码执行了预期的操作。
您是否想要一个在修改代码以使表格具有蓝色边框而不是绿色边框时中断的测试,完全取决于您和您的要求。
在涉及 INI 文件的特定情况下,IMO "file" 部分是一个实现细节,您不希望单元测试依赖于网络上某处的某个文件。相反,您将拥有一个 class 或数据结构来保存配置 key/value 对;测试的 "arrange" 部分将负责设置配置数据,当您 "act" 将配置传递给 SUT,然后断言结果状态与指定配置匹配。
reads/writes 实际 INI 文件的代码完全是另一个问题,它有自己的测试代码,这也可以避免命中文件系统:你想测试 你的 代码,而不是 脚本运行时 的 FileSystemObject
是否完成了它的工作。
请注意,AddTable
是 MyAwesomeClass
的成员还是某些实用程序标准程序模块,就测试而言绝对没有区别;单元测试不会告诉您 regroup/abstract 功能和组织代码的方式。
Rubberduck 的最新版本(预发行版 2.1.x 构建)包含一个 "fakes/stubs" 框架的开头,可以设置该框架以通过挂钩拦截许多特定的标准库调用进入 VBA 运行时本身。例如,您不希望单元测试弹出一个 MsgBox
,但如果您正在测试的方法需要一个,您可以在测试为 运行 时拦截 MsgBox
调用(甚至设置它的 return 值,例如模拟用户点击 [Yes] 或 [No] 或 [Cancel]),但这完全是另一个话题。
这个问题的动机是获得一个具体的例子,说明修改文档时单元 testable 代码是什么样子的。作为背景,我很欣赏 classes 非常适合定义和验证事物,例如:
- 形状 - https://codereview.stackexchange.com/questions/172224/understanding-classes-in-vba-help-improve-these-comments
- 用户输入 - https://github.com/rubberduck-vba/Rubberduck/wiki/Unit-Testing#example
无论 class 模块是否适合 table 用于修改文档似乎 'depend'(请参阅此处的 Mat's Mug 回答:
无论如何,基于我对事物的有限理解,我认为,对于 'adds formatted tables to documents' 的一个单词插件,一个 Class 模块为向文档添加格式化 table 的合理方法提供了基础... (如果我错了请告诉我).
虽然我已经标记了 VBA-Excel 我真的对 MS Word 示例(非常缺乏)更感兴趣所以通过一个相当简单的 MS Word 示例,让我们说我有将格式化的 tables 添加到指定范围内的文档的代码。
为了示例的目的让我们假设:
- 事件的基本顺序是:
- 将默认 table 添加到文档
- 后续按照INI文件格式化
- 为所有 table 指定的格式是:
- table边框线颜色
- table row1 底纹颜色
- INI文件指定了几个table
- tbl1-Border=wdRed
- tbl2-Border=wdGreen
- tbl1-Shading=wdRed
- tbl1-Shading=wdGreen
所以我接下来的问题是:
- 我应该计划多少 classes?
- 1 用于添加和格式化 tables
- 1 用于读取 INI 文件数据
- 每个 class 模块的结构是什么样的?
- 应该我(我可以)单元测试代码:
- 修改文档(添加一个table)?
- 读取 INI 文件?
我不指望任何人提供实际工作代码;但伪代码、一般建议和一些具体的指示将不胜感激。
注意:如果这个问题太宽泛,我很乐意分成几个单独的问题
工作表(或Word文档)无非是封装状态/数据的对象。
您可以不费吹灰之力,用您的代码所依赖的接口(例如 IWorksheet
或 IDocument
)包装 worksheet/document,但那将是巨大的努力,实际上几乎没有什么好处 - 单元测试必须使用该接口的 "fake" 实现,它将负责存储测试 data/state 以便您的测试可以断言您正在测试的代码是否按预期工作。完全矫枉过正。
相反,编写您的代码以便为它提供一个 Worksheet
实例(即避免针对 ActiveWorkbook
and/or ActiveSheet
),并执行它需要执行的任何操作用它。拆分职责,这样当您调用一个方法时,您就没有 20,000 件事情要断言以确保您的代码执行其编写的目的 - 但这不应该是任何新的或与您已经在做的事情不同,对吧?
'@Description("Adds a table named [tableName] on [sheet]. Returns the created table.")
Public Function AddTable(ByVal sheet As Worksheet, ByVal tableName As String) As ListObject
'TODO: implement
End Function
对这种方法的测试可能如下所示:
'@TestMethod
Public Sub AddsListObjectToSpecifiedWorksheet()
'Arrange
Dim sheet As Worksheet
Set sheet = ThisWorkbook.Worksheets.Add
Dim sut As MyAwesomeClass
Set sut = New MyAwesomeClass
Const tableName As String = "TestTable1"
If sheet.ListObjects.Count <> 0 Then _
Assert.Inconclusive "Sheet already has a table."
'Act
sut.AddTable sheet, tableName
'Assert
Assert.IsTrue sheet.ListObjects.Count = 1, "Table was not added."
sheet.Delete
End Sub
sheet
设置和清理代码可以移动到测试模块中的专用 TestInitialize
/TestCleanup
方法,因为该测试模块中的每个测试方法都可能会需要一个新的工作表来玩,因为您希望每个测试都是独立的并且不与其他测试共享任何状态。
将设置和清理代码提取到测试模块中的专用方法可以消除实际测试方法中的错误。毕竟,测试模块是一个标准模块,可以有自己的私有字段和模块级常量:
'@TestMethod
Public Sub ReturnsListObjectReference()
'Arrange
Dim sut As MyAwesomeClass
Set sut = New MyAwesomeClass
If testSheet.ListObjects.Count <> 0 Then _
Assert.Inconclusive "Sheet already has a table."
'Act
Dim result As ListObject
Set result = sut.AddTable(testSheet, tableName)
'Assert
Assert.IsNotNothing result, "Table was not returned."
Assert.AreSame result, testSheets.ListObjects(1), "Wrong table was returned."
End Sub
所以你继续编写测试,每个测试验证一个特定的行为:
'@TestMethod
Public Sub TableNameIsAsSpecified()
'Arrange
Dim sut As MyAwesomeClass
Set sut = New MyAwesomeClass
If testSheet.ListObjects.Count <> 0 Then _
Assert.Inconclusive "Sheet already has a table."
'Act
Dim result As ListObject
Set result = sut.AddTable(testSheet, tableName)
'Assert
Assert.AreEqual tableName, result.Name, "Table name wasn't set."
End Sub
这样,当您、未来的您或任何继承您代码的人查看您的测试套件时,他们就会确切地知道您的代码应该做什么,并且通过运行 测试他们将 知道 您的代码执行了预期的操作。
您是否想要一个在修改代码以使表格具有蓝色边框而不是绿色边框时中断的测试,完全取决于您和您的要求。
在涉及 INI 文件的特定情况下,IMO "file" 部分是一个实现细节,您不希望单元测试依赖于网络上某处的某个文件。相反,您将拥有一个 class 或数据结构来保存配置 key/value 对;测试的 "arrange" 部分将负责设置配置数据,当您 "act" 将配置传递给 SUT,然后断言结果状态与指定配置匹配。
reads/writes 实际 INI 文件的代码完全是另一个问题,它有自己的测试代码,这也可以避免命中文件系统:你想测试 你的 代码,而不是 脚本运行时 的 FileSystemObject
是否完成了它的工作。
请注意,AddTable
是 MyAwesomeClass
的成员还是某些实用程序标准程序模块,就测试而言绝对没有区别;单元测试不会告诉您 regroup/abstract 功能和组织代码的方式。
Rubberduck 的最新版本(预发行版 2.1.x 构建)包含一个 "fakes/stubs" 框架的开头,可以设置该框架以通过挂钩拦截许多特定的标准库调用进入 VBA 运行时本身。例如,您不希望单元测试弹出一个 MsgBox
,但如果您正在测试的方法需要一个,您可以在测试为 运行 时拦截 MsgBox
调用(甚至设置它的 return 值,例如模拟用户点击 [Yes] 或 [No] 或 [Cancel]),但这完全是另一个话题。