31
\$\begingroup\$

Unit testing in VBA is... lacking. (What isn't lacking in VBA though?) Since I've become more interested in unit testing lately, I decided I needed something better than Debug.Assert(), so I started building this framework. Currently there is a ton of functionality missing, but since I'm new to unit testing and interfaces, I don't want to get too deep before realizing I've made a huge mistake. The code is simple, but works just fine.

I want to be able to run the output to either a file or the immediate window, so I created a simple IOutput interface that contains one subroutine.

IOutput.cls

Public Sub PrintLine(Optional ByVal object As Variant)
End Sub

And a Console class implementing it. Console uses VBPredeclaredId = True to create a default instance. The Logger class remains unimplemented for the moment.

Console.cls

Implements IOutput
Public Sub PrintLine(Optional ByVal object As Variant)
 If IsMissing(object) Then
 'newline
 Debug.Print vbNullString
 Else
 Debug.Print object
 End If
End Sub
Private Sub IOutput_PrintLine(Optional ByVal object As Variant)
 PrintLine object
End Sub

The UnitTest class then takes in an IOutput object in and stores it as a property. I need the Output stream to be available to the local project, but I don't want to expose it to external projects referencing it, so I declared it at a Friend scope (more on that later).

UnitTest.cls

Private Type TUnitTest
 Name As String
 OutStream As IOutput
 Assert As Assert
End Type
Private this As TUnitTest
Public Property Get Name() As String
 Name = this.Name
End Property
Friend Property Get OutStream() As IOutput
 Set OutStream = this.OutStream
End Property
Public Property Get Assert() As Assert
 Set Assert = this.Assert
End Property
Friend Sub Initialize(Name As String, out As IOutput)
 this.Name = Name
 Set this.OutStream = out
 Set this.Assert = New Assert
 Set this.Assert.Parent = Me
End Sub

The UnitTest creates it's own instance of the Assert object. I have a real concern here. I don't like that I have to pass in the test name along with the actual conditions I'm testing.

Assert.cls

Private Const PASS As String = "Pass"
Private Const FAIL As String = "Fail"
Private Type TAssert
 Parent As UnitTest
End Type
Private this As TAssert
Public Static Property Get Parent() As UnitTest
 Set Parent = this.Parent
End Property
Public Static Property Set Parent(ByVal Value As UnitTest)
 Set this.Parent = Value
End Property
Public Sub IsTrue(testName As String, condition As Boolean, Optional message As String)
 Dim output As String
 output = IIf(condition, PASS, FAIL)
 Report testName, output, message
End Sub
Public Sub IsFalse(testName As String, condition As Boolean, Optional message As String)
 Dim output As String
 output = IIf(condition, FAIL, PASS)
 Report testName, output, message
End Sub
Private Sub Report(testName As String, output As String, message As String)
 output = this.Parent.Name & "." & testName & ": " & output
 If message <> vbNullString Then
 output = output & ": " & message
 End If
 this.Parent.OutStream.PrintLine output
End Sub

Finally, I don't want to import all of these classes into each project I'm working on. It will be a nightmare to keep them all synced as I make changes to the VBAUnit project. So I changed their instancing to "PublicNotCreatable".

If the instancing property is PublicNotCreatable, the class behaves normally when used within the same project, but a variable can be declared of that class type in other projects. The other project cannot create a new instance of the class, but can have a variable of the class's type. To allow another project to use a new instance of the class, the project containing the class must provide a global-scope function that creates a new instance of a class and returns it to the caller. For example, suppose Project1 contains a class named Class1, whose Instancing property is PublicNotCreatable. Suppose also that Project2 references Project1.

Cpearson.com

So I have a regular *.bas module named Provider that contains this single function.

Provider.bas

Public Function New_UnitTest(Name As String, out As IOutput) As UnitTest
 Set New_UnitTest = New UnitTest
 New_UnitTest.Initialize Name, out
End Function

Then, from another project, I go just add VBAUnit to the references. (If you don't have it open, you have to click browse and navigate to the actual file.) I did just that and wrote some tests that essentially test themselves.

This is where the Friend scope comes into play. VBAUnit has access to the Initialize subroutine and to the OutputStream property, but they're not visible to any external projects.

AssertConditionTest

This code is boilerplate code. Each new test I create will need these line. Also, once I have implemented a file logger, this is where you would need to decide where to output the results to. I don't like boiler plate, but I can't think of a way to get around it. I'm way open to suggestion on this.

Private test As VBAUnit.UnitTest
Private Sub Class_Initialize()
 Set test = VBAUnit.New_UnitTest(TypeName(Me), VBAUnit.Console)
End Sub
Private Sub Class_Terminate()
 Set test = Nothing
End Sub

Followed by the actual tests.

Public Sub RunAllTests()
 IsTrueShouldPass
 IsTrueShouldFail
 IsFalseShouldPass
 IsFalseShouldFail
End Sub
Public Sub IsTrueShouldPass()
 test.Assert.IsTrue "IsTrueShouldPass", True
End Sub
Public Sub IsTrueShouldFail()
 test.Assert.IsTrue "IsTrueShouldFail", False
End Sub
Public Sub IsFalseShouldPass()
 test.Assert.IsFalse "IsFalseShouldPass", False, "with a message."
End Sub
Public Sub IsFalseShouldFail()
 test.Assert.IsFalse "IsFalseShouldFail", True, "with a message."
End Sub

Finally, in this project, we have a regular *bas. This is just kind of throw away code that we use to run the tests we're interested in.

Public Sub TestTheTests()
 Dim test As New AssertConditionTest
 test.RunAllTests
 test.IsFalseShouldPass
End Sub

To summarize:

  1. Am I using interfaces in an intelligent way?
  2. Is there anyway to ditch the boilerplate code in AssertConditionTest?
  3. How can I avoid passing a "Test name" into each Assert statement and still get results like this? My method feels like a dirty hack at best.

    AssertConditionTest.IsTrueShouldPass: Pass
    AssertConditionTest.IsTrueShouldFail: Fail
    AssertConditionTest.IsFalseShouldPass: Pass: with a message.
    AssertConditionTest.IsFalseShouldFail: Fail: with a message.
    
  4. Was it a stupid decision to make Assert it's own class and keeping a Parent UnitTest property?

asked Sep 12, 2014 at 19:31
\$\endgroup\$

1 Answer 1

23
\$\begingroup\$

IOutput class module (Interface)

Looking at how the interface is being used:

this.Parent.OutStream.PrintLine output

Where output is clearly a String, which makes sense. But the interface's signature doesn't reflect that, and is confusing:

Public Sub PrintLine(Optional ByVal object As Variant)

Why is the parameter optional? and why is it a Variant? ...and why is it called object? I would have expected this:

Public Sub PrintLine(ByVal output As String)

Which leads me to the implementation:

Console class module

If the parameter is a String, and isn't optional, the PrintLine implementation gets... a little bit simpler:

Option Explicit 'always. even if you're not **yet** declaring anything.
Implements IOutput
Public Sub PrintLine(ByVal output As String)
 Debug.Print output
End Sub
Private Sub IOutput_PrintLine(ByVal output As String)
 PrintLine output
End Sub

It seems your Console class was intended to be used a bit like a .net System.Console, a static class.

In the context of the IOutput interface implementation, it doesn't make sense for that class to be static, nor to have an optional parameter to its PrintLine method. However, if you encapsulate a test result in its own class...

Option Explicit
Public Enum TestOutcome
 Inconclusive
 Failed
 Succeeded
End Enum
Private Type TTestResult
 outcome As TestOutcome
 output As String
End Type
Private this As TTestResult
Public Property Get TestOutcome() As TestOutcome
 TestOutcome = this.outcome
End Property
Friend Property Let TestOutcome(ByVal value As TestOutcome)
 this.outcome = value
End Property
Public Property Get TestOutput() As String
 TestOutput = this.output
End Property
Friend Property Let TestOutput(ByVal value As String)
 this.output = value
End Property
Public Function Create(ByVal outcome As TestOutcome, ByVal output As String)
 Dim result As New TestResult
 result.TestOutcome = outcome
 result.TestOutput = output
 Set Create = result
End Function

...then I'd renamed IOutput to ITestOutput, and change the signature like this:

Public Sub WriteResult(ByVal result As TestResult)
End Sub

and Console would look ilke this:

Option Explicit
Implements ITestOutput
Public Sub WriteResult(ByVal result As TestResult)
 Debug.Print result.TestOutput
End Sub
Private Sub ITestOutput_WriteResult(ByVal result As TestResult)
 WriteResult result
End Sub

That gives WriteResult a much clearer intent than PrintLine, and doesn't stop you from implementing a WriteLine(String) method and keeping Console as a static utility class, and as a bonus you have a concept of a test result that can be inconclusive, failed or successful.


UnitTest class module

Mucho kudos, this is the first time I'm seeing a warranted use of the Friend keyword in VBA. This is pretty clever, and enables several things that aren't otherwise possible in VBA:

  • Factories: a class can now be created and initialized with parameter values, as if created with a constructor.
  • Immutability: a class can only expose getters, and be immutable from the client code's perspective.

Impressive. I wish I had realized VBAProjects could reference each other, 10 years ago!


Provider code module

I don't like this. I would have made it a "static" class module (with a default instance), and called it UnitTestFactory.

I don't like the method name either - again, underscores in identifiers are confusing in VBA. If that code is in a class called UnitTestFactory, the method's name could simply be Create.

I don't like that you're assigning the result, and then calling a method on that reference - it looks very awkward and would be much clearer with a result variable, and I would make the IOutput implementation/reference a property of the factory class, removing it from the method's signature:

Option Explicit
Private Type TUnitTestFactory
 TestOutput As IOutput
End Type
Private this As TUnitTestFactory
Public Property Get TestOutput() As IOutput
 Set TestOutput = this.TestOutput 
End Property
Public Property Set TestOutput(ByVal value As IOutput)
 Set this.TestOutput = value
End Property
Public Function Create(ByVal testName As String) As UnitTest
 Dim result As New UnitTest
 Set result.Initialize testName, TestOutput
 Set Create = result
End Function

Assert class module

I've never seen the Static keyword used like this (what's a static property in VBA anyway?), and the Parent property makes me think that class is doing more than it should. I believe the TestOutcome enum and the TestResult class I've suggested above, would be helpful here... but I don't think it's Assert's job to report the test's outcome - by keeping that responsibility at the test level, you remove the need to pass the test's name to the Assert methods.

Question is, how to do that?

I think I'd expose an event:

Public Event AssertCompleted(ByVal result As TestResult)

This would make a method like IsTrue look like this:

Public Sub IsTrue(ByVal condition As Boolean, Optional message As String)
 Dim outcome As TestOutcome
 outcome = IIf(condition, TestOutcome.Succeeded, TestOutcome.Failed)
 result = TestResult.Create(outcome, message)
 RaiseEvent AssertCompleted(result)
End Sub

For this to work, a UnitTest class only needs this - note that I'd want to call the variable assert, so I'd rename the class to TestAssert, and forfeit the default instance / static-ness of the type:

Private WithEvents assert As TestAssert
Private Sub assert_AssertCompleted(ByVal result As TestResult)
 OutStream.WriteResult result
End Sub

And... Bingo!


This code is boilerplate code. Each new test I create will need these line.

Don't worry about that boilerplate code. It's needed because it's the only logical place for the client code to specify an implementation for the output interface - a test might want to output to a text file, another might want to output to the immediate pane, and another might want to send the output to a listbox on a modal form... and it's really the client code's job to specify that.

answered Sep 12, 2014 at 22:18
\$\endgroup\$
4
  • 2
    \$\begingroup\$ Using a static class with a default instance is genius. I should have thought of it, but I was smoking MSDN's documentation I guess. Using events to report that the assert is completed is brilliant as well. Great advice. You're also completely right about separating the concerns of asserting and reporting. The static property snuck in there because I was using the "Insert" tool..... lesson learned there. I'll just type them out from now on. \$\endgroup\$ Commented Sep 14, 2014 at 1:20
  • \$\begingroup\$ Also, IOutput takes an optional variant because if I call Console.PrintLine or Logger.PrintLine without a parameter, I want it to print a blank line. It takes variant because objects will be cast to a string and printed. I must have picked that up in Ruby. Not sure I'll change that part. \$\endgroup\$ Commented Sep 14, 2014 at 13:58
  • \$\begingroup\$ @RubberDuck thinking again, ...what prevents Assert.IsTrue from being a function and return a TestResult, instead of raising an event? \$\endgroup\$ Commented Sep 14, 2014 at 19:33
  • 2
    \$\begingroup\$ As far as I can tell, it's behavior does. It doesn't ask if this is true, it asserts it. It takes an action. \$\endgroup\$ Commented Sep 14, 2014 at 21:12

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.