测试MS Access应用的最佳方法是什么?

45

在同一个数据库中,代码、表单和数据都在一起的情况下,我想知道为微软Access应用程序(例如Access 2007)设计测试套件的最佳实践是什么。

测试表单的主要问题之一是只有少数控件具有hwnd句柄,其他控件只有在获得焦点后才会有句柄。这使得自动化变得非常不透明,因为您无法获取表单上的控件列表并对其进行操作。

有没有经验可以分享?

12个回答

26

1. 编写可测试的代码

首先,停止在您的表单代码后面编写业务逻辑。那里不是它的位置,因为它无法得到适当的测试。实际上,你真的不应该测试你的表单本身。它应该是一个简单而死板的视图,只响应用户交互,然后将响应这些操作的责任委托给另一个可以进行测试的类。

怎么做呢?熟悉模型-视图-控制器模式是一个好的开始。

Model View Controller diagram

由于我们只有事件或接口中的一个,所以在VBA中无法完全做到,但您可以做得相当接近。考虑这个简单的表单,它有一个文本框和一个按钮。

simple form with text box and button

在表单的代码后面,我们将在一个公共属性中包装TextBox的值,并重新引发我们感兴趣的任何事件。

Public Event OnSayHello()
Public Event AfterTextUpdate()

Public Property Let Text(value As String)
    Me.TextBox1.value = value
End Property

Public Property Get Text() As String
    Text = Me.TextBox1.value
End Property

Private Sub SayHello_Click()
    RaiseEvent OnSayHello
End Sub

Private Sub TextBox1_AfterUpdate()
    RaiseEvent AfterTextUpdate
End Sub

现在我们需要一个模型来使用。我已经创建了一个名为MyModel的新类模块。这里是我们将要测试的代码。请注意,它自然地与我们的视图共享类似的结构。

Private mText As String
Public Property Let Text(value As String)
    mText = value
End Property

Public Property Get Text() As String
    Text = mText
End Property

Public Function Reversed() As String
    Dim result As String
    Dim length As Long

    length = Len(mText)

    Dim i As Long
    For i = 0 To length - 1
        result = result + Mid(mText, (length - i), 1)
    Next i

    Reversed = result
End Function

Public Sub SayHello()
    MsgBox Reversed()
End Sub

最后,我们的控制器将所有内容连接在一起。 控制器侦听表单事件,并将更改通知给模型并触发模型的例程。

Private WithEvents view As Form_Form1
Private model As MyModel

Public Sub Run()
    Set model = New MyModel
    Set view = New Form_Form1
    view.Visible = True
End Sub

Private Sub view_AfterTextUpdate()
    model.Text = view.Text
End Sub

Private Sub view_OnSayHello()
    model.SayHello
    view.Text = model.Reversed()
End Sub

现在这段代码可以从任何其他模块中运行。为了这个例子的目的,我使用了一个标准模块。我强烈鼓励您使用我提供的代码构建它并查看它的功能。

Private controller As FormController

Public Sub Run()
    Set controller = New FormController
    controller.Run
End Sub

那很好,但这与测试有什么关系?!朋友,这与测试有一切的关系。我们所做的是使我们的代码可以进行测试。在我提供的示例中,甚至没有理由尝试测试GUI。我们真正需要测试的是model。那里才是所有真正逻辑所在。

进入第二步。

2.选择一个单元测试框架

这里没有很多选择。大多数框架需要安装COM插件,大量的样板文件,奇怪的语法,将测试作为注释编写等等。这就是为什么我参与自己构建一个框架,所以我的回答这部分不是客观的,但我会尽力给出一个公正的总结。

  1. AccUnit

    • 只在Access中工作。
    • 要求你将测试编写为注释和代码的奇怪混合物。(注释部分没有智能提示)
    • 有一个图形界面来帮助您编写那些看起来奇怪的测试。
    • 该项目自2013年以来没有更新。
  2. VB Lite Unit。我个人没用过它。它存在,但自2005年以来没有更新。

  3. xlUnit。xlUnit不是很糟糕,但也不是很好。它很笨重,有很多样板文件。它是最好的选择,但在Access中无法使用。所以就此打住。

  4. 构建您自己的框架

    曾经走过这条路。这可能超出了大多数人想要涉足的范围,但完全有可能在原生VBA代码中构建一个单元测试框架。

  5. Rubberduck VBE Add-In's单元测试框架
    免责声明:我是其中一位合作开发者.

    我有偏见,但这是我最喜欢的选择。

  • 几乎没有样板代码。
  • 可用智能感知。
  • 该项目活跃。
  • 比大多数这些项目都有更多文档。
  • 它在大多数主要办公应用程序中运行,而不仅仅是在Access中。
  • 不幸的是,它是一个COM插件,因此必须安装到您的计算机上。
'@TestModule
Private Assert As New Rubberduck.AssertClass

'@TestMethod
Public Sub ReversedReversesCorrectly()

Arrange:
    Dim model As New MyModel
    Const original As String = "Hello"
    Const expected As String = "olleH"
    Dim actual As String

    model.Text = original

Act:
    actual = model.Reversed

Assert:
    Assert.AreEqual expected, actual

End Sub

编写良好测试的准则

  1. 单次只测试一个事物。
  2. 好的测试仅在系统引入错误或需求改变时才会失败。
  3. 不要包含外部依赖,如数据库和文件系统。这些外部依赖可能导致测试因您无法控制的原因而失败。其次,它们会拖慢你的测试速度。如果测试速度很慢,你就不会运行它们。
  4. 使用描述测试内容的测试名称。不必担心名称过长,最重要的是它应该具有描述性。

我知道答案有点长,并且晚了一些,但希望能帮助一些人开始为他们的VBA代码编写单元测试。


“Late”是一个委婉语 ;) - Mathieu Guindon
控制器代码需要在名为FormController的类模块中才能使Model-View-Controller代码正常工作。我第一次尝试代码时错过了这一点。 - AndrewM
我运行了示例代码并注意到模型没有更新视图,而是通过MsgBox呈现结果。对我来说不清楚反向函数的结果是否应该返回给控制器,以便将view.textbox1设置为新值(假设我想将结果返回到输入文本框)。另一种选择是添加一个表单引用到模型并从模型中编写结果,但这对我来说似乎很丑陋。尝试了第一种选项,但无法使其工作。请提供有关如何从模型返回值到视图的线索。 - AndrewM
@AndrewM 我更新了视图的控制器事件处理程序。现在它设置了视图的文本并让模型说hello。 - RubberDuck
@RubberDuck 我自己编写了一个单元测试框架,只有大约100行代码。现在我在我的表单中放置了一个名为“Tests”的公共函数,并将单元测试放在其中。我不喜欢MVC模式,因为我希望能够在设计模式中单击按钮并跟踪代码以查看其功能。通过在表单中添加测试,我还可以在失败的测试中设置断点,这将直接带我到问题所在。我必须从标准模块中调用Tests,在那里我将表单打开为表单变量,然后调用Tests方法。结果显示在即时窗口中。 - AndrewM
1
可以使用ViewAdapter对象同时拥有事件和接口,如我在这个问题新答案中所述。 - Pieter Geerkens

17

我很欣赏Knox和David的回答。我的答案将介于他们之间:只需制作不需要调试的表单!

我认为表单应该仅作为其基本用途,即图形界面而已,这意味着它们不必被调试!然后,调试工作仅限于您的VBA模块和对象,这要容易得多。

当然,有一种自然倾向于向表单和/或控件添加VBA代码,特别是当Access为您提供这些出色的“更新后”和“更改时”事件时,但我绝对建议您不要在表单的模块中放置任何特定于表单或控件的代码。这会使进一步的维护和升级非常昂贵,因为您的代码分为VBA模块和表单/控件模块。

这并不意味着您不能再使用AfterUpdate事件!只需在事件中放入标准代码,如下所示:

Private Sub myControl_AfterUpdate()  
    CTLAfterUpdate myControl
    On Error Resume Next
    Eval ("CTLAfterUpdate_MyForm()")
    On Error GoTo 0  
End sub

Where:

  • CTLAfterUpdate 是在表单中每次控件更新时运行的标准过程。

  • CTLAfterUpdateMyForm 是在 MyForm 上每次控件更新时运行的特定过程。

然后我有两个模块。第一个是

  • utilityFormEvents
    在这里我会有我的 CTLAfterUpdate 通用事件

第二个是

  • MyAppFormEvents
    包含 MyApp 应用程序所有特定表单的特定代码,并包括 CTLAfterUpdateMyForm 过程。当然,如果没有特定代码要运行,CTLAfterUpdateMyForm 可能不存在。这就是为什么我们将“On error”转换为“resume next”的原因...

选择这样的通用解决方案意义重大。这意味着您正在达到高水平的代码规范化(即无痛维护代码)。当您说您没有任何特定于表单的代码时,它还意味着表单模块完全标准化,它们的生产可以自动化:只需说明要管理的表单/控件级事件,并定义您的通用/特定过程术语。
编写一次自动化代码。
它需要几天的工作,但可以产生令人兴奋的结果。我过去两年一直在使用这个解决方案,它显然是正确的:我的表单完全是自动从“表单表”创建的,链接到“控件表”。
然后,如果需要,我可以花时间在表单的特定过程上。

即使在 MS Access 中,代码规范化也是一个漫长的过程。但它真的值得努力!


1
这听起来很有趣,你为什么不在某个地方发布一些例子呢? - GUI Junkie
@GUI迷,我会及时通知你。 - Philippe Grondier
1
为什么不直接将AfterUpdate属性设置为=myModule.AfterUpdate(me.controlname)?这样,您就可以编写一个漂亮的通用函数,该函数会传递对特定控件的引用,而无需使用任何eval魔法。或者我有什么遗漏吗? - Jauco
这是一种避免我的“eval magic”的方法。但它会强制你处理特定的“afterUpdate”过程,无论是通过更新控件属性还是在过程级别添加CASE行。 “Eval”解决方案避免了这种额外的开销。您只需在需要时创建函数即可。 - Philippe Grondier
1
我想看一下你提到的表单和控件表的架构。我不太明白它们是如何工作的。 - Knobloch
1
@PhilippeGrondier,我也很感激你发布一些accdb的例子。顺带一提,这是一个非常好的博客主题;) - Anton Kaiser

6

3
我实际上已经做过这件事。我强烈推荐这种方式,因为这样你就能够利用 .net 的所有优势来测试你的Access/VBA应用程序。 - KevinDeus

5
虽然这是一个非常古老的答案:
AccUnit,这是一个专门为Microsoft Access设计的单元测试框架。

我认为这可能是最有用的答案,所以我将其更改为被接受的答案。 - Renaud Bompuis

5
我借鉴了Python的doctest概念,并在Access VBA中实现了一个DocTests过程。这显然不是一个完整的单元测试解决方案。它仍然相对年轻,所以我怀疑我已经解决了所有的错误,但我认为它已经足够成熟,可以发布到公众使用中。

只需将以下代码复制到标准代码模块中,然后在Sub内按F5即可看到其效果:

'>>> 1 + 1
'2
'>>> 3 - 1
'0
Sub DocTests()
Dim Comp As Object, i As Long, CM As Object
Dim Expr As String, ExpectedResult As Variant, TestsPassed As Long, TestsFailed As Long
Dim Evaluation As Variant
    For Each Comp In Application.VBE.ActiveVBProject.VBComponents
        Set CM = Comp.CodeModule
        For i = 1 To CM.CountOfLines
            If Left(Trim(CM.Lines(i, 1)), 4) = "'>>>" Then
                Expr = Trim(Mid(CM.Lines(i, 1), 5))
                On Error Resume Next
                Evaluation = Eval(Expr)
                If Err.Number = 2425 And Comp.Type <> 1 Then
                    'The expression you entered has a function name that ''  can't find.
                    'This is not surprising because we are not in a standard code module (Comp.Type <> 1).
                    'So we will just ignore it.
                    GoTo NextLine
                ElseIf Err.Number <> 0 Then
                    Debug.Print Err.Number, Err.Description, Expr
                    GoTo NextLine
                End If
                On Error GoTo 0
                ExpectedResult = Trim(Mid(CM.Lines(i + 1, 1), InStr(CM.Lines(i + 1, 1), "'") + 1))
                Select Case ExpectedResult
                Case "True": ExpectedResult = True
                Case "False": ExpectedResult = False
                Case "Null": ExpectedResult = Null
                End Select
                Select Case TypeName(Evaluation)
                Case "Long", "Integer", "Short", "Byte", "Single", "Double", "Decimal", "Currency"
                    ExpectedResult = Eval(ExpectedResult)
                Case "Date"
                    If IsDate(ExpectedResult) Then ExpectedResult = CDate(ExpectedResult)
                End Select
                If (Evaluation = ExpectedResult) Then
                    TestsPassed = TestsPassed + 1
                ElseIf (IsNull(Evaluation) And IsNull(ExpectedResult)) Then
                    TestsPassed = TestsPassed + 1
                Else
                    Debug.Print Comp.Name; ": "; Expr; " evaluates to: "; Evaluation; " Expected: "; ExpectedResult
                    TestsFailed = TestsFailed + 1
                End If
            End If
NextLine:
        Next i
    Next Comp
    Debug.Print "Tests passed: "; TestsPassed; " of "; TestsPassed + TestsFailed
End Sub

从名为Module1的模块中复制、粘贴并运行上述代码会产生以下结果:

Module: 3 - 1 evaluates to:  2  Expected:  0 
Tests passed:  1  of  2

几个快速注意事项:

  • 它没有依赖项(在Access内使用时)
  • 它使用了Access.Application对象模型中的Eval函数;这意味着你可以在Access之外使用它,但需要创建一个Access.Application对象并完全限定Eval调用
  • 有一些Eval相关的特殊情况需要注意
  • 它只能用于返回适合单行的结果的函数

尽管它有一些限制,但我仍然认为它提供了相当不错的性价比。

编辑:这里是一个带有“doctest规则”的简单函数。

Public Function AddTwoValues(ByVal p1 As Variant, _
        ByVal p2 As Variant) As Variant
'>>> AddTwoValues(1,1)
'2
'>>> AddTwoValues(1,1) = 1
'False
'>>> AddTwoValues(1,Null)
'Null
'>>> IsError(AddTwoValues(1,"foo"))
'True

On Error GoTo ErrorHandler

    AddTwoValues = p1 + p2

ExitHere:
    On Error GoTo 0
    Exit Function

ErrorHandler:
    AddTwoValues = CVErr(Err.Number)
    GoTo ExitHere
End Function

这个测试到底有什么作用,编译VBA时不已经处理了吗? - David-W-Fenton
2
@David:它验证逻辑的正确性。当然,编译不会这样做。 - mwolfe02
我根本看不到这种测试的价值。在Access应用程序中发生的绝大多数错误都不是算法相关的,而是与UI相关和运行时特定的(即由于遇到与代码编写假设不符的数据而引起的)。而且,Access应用程序不仅仅是VBA代码。 - David-W-Fenton
3
@David-W-Fenton 以自动化的方式测试代码非常有用,如果您在某处进行更改可能会破坏其他地方的功能。通过系统性地运行测试,您可以验证代码的全局一致性:失败的测试将突出显示问题,否则这些问题可能会一直隐藏,直到手动UI测试人员或最终用户发现它们。代码测试不是为了测试所有内容,而是仅为了测试代码本身。它也有缺点(如损坏的测试和创建测试所需的额外时间),但对于较大的项目是值得的。 - Renaud Bompuis
我并不是说自动化测试本身没有用处。我只是在暗示,使用类似Access这样的平台并不能以任何有意义的方式进行自动化测试。 - David-W-Fenton
@David-W-Fenton 我正在以自动化的方式使用单元测试来测试我的Access代码。花了一段时间才弄明白,但现在我已经搞定了。 - AndrewM

4
我会设计应用程序,使尽可能多的工作在查询和vba子例程中完成,以便您的测试可以由填充测试数据库、运行生产查询和vba对这些数据库进行测试,然后查看输出并进行比较以确保输出正确。显然,这种方法不会测试GUI,因此您可以通过一系列测试脚本(我指的是像打开表单1并单击控件1这样的Word文档)手动执行来增强测试。

测试方面所需的自动化水平取决于项目范围。


2
我发现我的应用程序中很少有单元测试的机会。我编写的大部分代码与表格数据或文件系统交互,因此从根本上很难进行单元测试。早期,我尝试了一种类似于模拟(欺骗)的方法,其中我创建了一个具有可选参数的代码。如果使用了该参数,则该过程将使用该参数而不是从数据库获取数据。很容易设置一个用户定义类型,它具有与数据行相同的字段类型,并将其传递给函数。现在我有一种方法可以将测试数据传递到要测试的过程中。在每个过程内部,都有一些代码可以将真实数据源替换为测试数据源。这使我能够使用自己的单元测试函数对更广泛的功能进行单元测试。编写单元测试很容易,只是重复和乏味。最终,我放弃了单元测试并开始使用不同的方法。
我主要为自己编写内部应用程序,这样我就能够等待问题出现而不必拥有完美的代码。如果我为客户编写应用程序,通常客户并不完全意识到软件开发的成本,因此我需要一种低成本的方法来获得结果。编写单元测试就是编写一个测试,将坏数据推送到一个过程中,以查看该过程是否能够适当地处理它。单元测试还确认好的数据是否得到适当的处理。我的当前方法是在应用程序中的每个过程中编写输入验证,并在代码成功完成时引发一个成功标志。每个调用过程在使用结果之前检查成功标志。如果出现问题,则通过错误消息报告。每个函数都有一个成功标志、一个返回值、一个错误消息、一个注释和一个起源。用户定义的类型(fr表示函数返回)包含数据成员。任何给定的函数都可以填充用户定义类型中的一些数据成员。运行函数时,它通常返回success=true和一个返回值,有时会返回一个注释。如果函数失败,它将返回success=false和一个错误消息。如果一系列函数失败,错误消息会被链式传递,但实际上结果比普通的堆栈跟踪要更可读。起源也被链接起来,所以我知道问题发生的位置。应用程序很少崩溃,并准确地报告任何问题。结果比标准的错误处理好得多。
Public Function GetOutputFolder(OutputFolder As eOutputFolder) As  FunctRet

        '///Returns a full path when provided with a target folder alias. e.g. 'temp' folder

            Dim fr As FunctRet

            Select Case OutputFolder
            Case 1
                fr.Rtn = "C:\Temp\"
                fr.Success = True
            Case 2
                fr.Rtn = TrailingSlash(Application.CurrentProject.path)
                fr.Success = True
            Case 3
                fr.EM = "Can't set custom paths – not yet implemented"
            Case Else
                fr.EM = "Unrecognised output destination requested"
            End Select

    exitproc:
        GetOutputFolder = fr

    End Function

代码解释。 eOutputFolder 是用户定义的枚举,如下所示。
Public Enum eOutputFolder
    eDefaultDirectory = 1
    eAppPath = 2
    eCustomPath = 3
End Enum

我正在使用枚举将参数传递给函数,因为这样可以创建一组有限的已知选择,函数可以接受这些选择。枚举还在输入参数到函数时提供智能感知。我想它们为函数提供了一个基本的接口。
'Type FunctRet is used as a generic means of reporting function returns
Public Type  FunctRet
    Success As Long     'Boolean flag for success, boolean not used to avoid nulls
    Rtn As Variant      'Return Value
    EM As String        'Error message
    Cmt As String       'Comments
    Origin As String    'Originating procedure/function
End Type

用户定义的类型(如 FunctRet)还提供了代码完成功能,有助于编写。在过程中,我通常会将内部结果存储到一个匿名内部变量(fr)中,然后再将结果分配给返回变量(GetOutputFolder)。这使得重命名过程非常容易,只需要更改顶部和底部即可。
因此,总的来说,我已经开发了一个涵盖所有涉及 VBA 的操作的 ms-access 框架。测试被永久地编写进了过程中,而不是作为开发时间单元测试。实际上,代码仍然运行非常快。我非常注意优化可以每分钟调用一万次的低级函数。此外,我可以在开发过程中使用正在开发的代码。如果发生错误,它是用户友好的,并且错误的源和原因通常很明显。错误是从调用表单报告的,而不是从业务层的某个模块报告的,这是应用程序设计的重要原则。此外,在演进设计而不是编写明确概念化设计时,我没有维护单元测试代码的负担,这真的很重要。
有一些潜在的问题。测试没有自动化,只有在运行应用程序时才能检测到新的错误代码。代码看起来不像标准的VBA代码(通常比较短)。尽管如此,这种方法还是有一些优点的。相对于使用错误处理程序仅记录错误,它更好,因为用户通常会联系我并给出有意义的错误消息。它还可以处理与外部数据相关的过程。JavaScript让我想起了VBA,我想知道为什么JavaScript是框架之乡,而在ms-access中的VBA却不是。
在写完这篇文章几天后,我发现了一篇CodeProject上的文章,内容与我上面所写的非常接近。该文章比较和对比了异常处理和错误处理。我上面提出的建议类似于异常处理。

刚刚审查了我正在开发的应用程序。105个功能中只有大约15个可以在普通意义下进行单元测试。其余的从操作系统、文件系统或记录集(而不是单个记录)获取值。我需要更像是集成测试和模拟/伪造。将继续以上方法,因为到目前为止,我找不到任何简单的集成测试方法。伪造是使用虚假数据表替换测试数据。 - AndrewM
我已经弄清楚了如何在MS-Access中使用单元测试,并且现在正在使用测试驱动的设计。关键是要使用许多小代码模块,并将创建或更改值的过程与使用这些值或存储这些值的过程分开。然后,我可以在使用任何值之前对其进行单元测试。在高级代码中,使用成功标志的方法仍然有用,因为许多事情需要正确运行代码,而这些事情中的许多都处于不受管理的外部环境中。 - AndrewM

2

这里有一些好的建议,但我很惊讶没有人提到集中式错误处理。您可以获取插件,允许快速添加函数/子模板和添加行号(我使用MZ-tools)。然后将所有错误发送到一个单独的函数中,您可以记录它们。您还可以通过设置单个断点来在所有错误时中断。


值得一提的是,EverythingAccess有一个处理Access应用程序中全局错误的产品。虽然我还没有尝试过,但我正在考虑使用它。 - Renaud Bompuis

2
如果你想更加精细地测试Access应用程序,特别是其VBA代码,可以使用VB Lite Unit作为一个很好的单元测试框架。请参阅VB Lite Unit

1

我没有尝试过这个方法,但你可以尝试将你的访问表单发布为数据访问网页到像SharePoint这样的平台上(链接1),或者直接作为网页(链接2),然后使用像Selenium这样的工具来驱动浏览器进行一系列测试(链接3)

显然,这不如通过单元测试直接驱动代码理想,但它可能会帮助你解决部分问题。祝好运!


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接