Search code examples
vbaexcelms-wordrubberduck

How to design and unit test a VBA class module for adding formatted tables to document


The motivation for this question is to obtain a concrete example of what good, unit testable code would look like for modifying a document. By way of background I appreciate that classes are great for defining and validating things, like for example:

Whether or not class modules are suitable for modifying a document seems to 'depend' (see Mat's Mug answer here: In VBA should code that modifies document be avoided in a class module) And I'm unable to find many examples where unit testing is used for code that modifies a document (perhaps there is a good reason for this?).

At any rate, based on my limited understanding of things I think that, for a word Add-In that 'adds formatted tables to documents', a Class Module provides the basis for a reasonable approach to adding a formatted table to a document... (let me know if I'm wrong).

Although I've tagged VBA-Excel I really am more interested in MS Word examples (which are so lacking) so by way of a fairly trivial example for MS Word, lets say I have code that adds formatted tables to documents at specified ranges.

For the purpose of a example lets assume:

  • basic order of events is:
    • add default table to document
    • subsequently format it in accordance with an INI file
  • formatting specified for all tables is:
    • table border line color
    • table row1 shading color
  • there are several tables specified by the INI file
    • tbl1-Border=wdRed
    • tbl2-Border=wdGreen
    • tbl1-Shading=wdRed
    • tbl1-Shading=wdGreen

So my next questions are:

  • how many classes should I plan for?
    • 1 for adding and formatting tables
    • 1 for reading INI file data
  • what would the structure of each class module look like?
  • should I (can I) unit test code that:
    • modifies a document (add a table)?
    • reads INI files?

I'm not expecting anyone to provide actual working code; but pseudo code, general advise and perhaps a few specific pointers would be much appreciated.

NOTE: If this question is too broad I'd be happy enough to split into separate questions


Solution

  • A worksheet (or a Word document) is nothing more than an object encapsulating state / data.

    You could go out of your way and wrap the worksheet/document with an interface that your code would depend on (e.g. IWorksheet, or IDocument), but that would be a tremendous amount of effort, for indeed little to no benefit - the unit tests would have to be using a "fake" implementation of that interface, which would be responsible for storing the test data/state so that your tests can assert that the code you're testing is working as intended. Complete overkill.

    Instead, write your code so that it's given a Worksheet instance (i.e. avoid working against ActiveWorkbook and/or ActiveSheet), and does whatever it needs to do with it. Split up responsibilities so that when you invoke a method you don't have 20,000 things to assert to ensure your code does what it's written to do - but that shouldn't be anything new or different from what you're already doing, right?

    '@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
    

    A test for such a method might look like this:

    '@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
    

    The sheet setup & cleanup code could be moved to dedicated TestInitialize/TestCleanup methods in the test module, as every single test method in that test module is likely going to need a fresh worksheet to play with, because you'll want every test to be independent and not share any state with the other tests.

    Extracting the setup & cleanup code to dedicated methods in the test module removes fluff from the actual test methods. After all, a test module is a standard module that can have its own private fields and module-level constants:

    '@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
    

    So you keep writing tests, each verifying one specific bit of behavior:

    '@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
    

    That way when you, future you, or whoever inherits your code looks at your test suite, they'll know exactly what your code is supposed to do, and by running the tests they'll know that your code does what it's intended to do.

    Whether you want a test that breaks when the code is modified to make the tables have blue borders instead of green, is entirely up to you and your requirements.

    In your specific case involving an INI file, IMO the "file" part is an implementation detail, and you wouldn't want a unit test to depend on some file somewhere on the network. Instead, you'll have a class or data structure to hold the configuration key/value pairs; the test's "arrange" part would be responsible for setting up the configuration data, and when you "act" you pass the configuration to your SUT and then assert that the resulting state matches the specified configuration.

    The code that reads/writes the actual INI file would be another concern entirely, with its own test code, which would also avoid hitting the file system: you want to test your code, not whether the Scripting Runtime's FileSystemObject does its job.

    Note that whether AddTable is a member of MyAwesomeClass or some utility standard procedural module makes absolutely no difference as far as testing is concerned; unit tests don't tell you how you regroup/abstract functionality and organize your code.


    The latest version of Rubberduck (pre-release 2.1.x builds) include the beginning of a "fakes/stubs" framework that can be setup to intercept a number of specific standard library calls, by hooking into the VBA runtime itself. For example you don't want a unit test to pop a MsgBox, but if the method you're testing requires one, you can intercept the MsgBox call while the test is running (and even setup its return value, e.g. to simulate the user clicking on [Yes] or [No] or [Cancel]), but that's another topic entirely.