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.
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:
- Am I using interfaces in an intelligent way?
- Is there anyway to ditch the boilerplate code in
AssertConditionTest
? 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.
Was it a stupid decision to make
Assert
it's own class and keeping a Parent UnitTest property?