I wanted to get some feedback on a fluent unit testing framework I’ve written. I call the project Fluent VBA. You can find a link to the project on GitHub here
Motivation
This project was inspired when I read about Fluent Assertions in C# as I was reading a book on unit testing.
Usage
Fluent frameworks are intended to be read like natural language. So instead of having something like:
Dim result = returnsFive() ‘returns the number 5
Dim Assert as cUnitTester
Set Assert = New cUnitTester
Assert.Equal(Result,5)
You can have code that reads more naturally like so:
Dim Result as cFluent
Set Result = new cFluent
Result.TestValue = ReturnsFive()
Result.Should.Be.EqualTo(5)
High level overview
Fluent VBA is broken down into 13 class modules: Nine classes and four interfaces. All of the class modules have an instancing property of PublicNotCreatable. So the project can be referenced in an external testing project. To do that, you’d just need to create an instance of cFluent using the MakeFluent() method in the mInit module. But you don’t have to do that if you don’t want to. You can also write your testing code in the cFluent project.
The project has a few main components: A Should component, a Be component, and a Have component. I also have components for their opposite: A ShouldNot component, and NotBe component, and a NotHave component. These various components are implemented in the project using composition.
Since the project is a unit-testing framework, I can use the project to test itself. So in the mTests module, I have a procedure called MetaTests where I do this. The meta tests mainly use the Fluent.Should.Be.EqualTo method with debug.assert to do this. Since all other methods rely on this method, I test this test extensively. I also test its opposite (i.e. Fluent.ShouldNot.Be.EqualTo) to ensure that it contains the expected value. In addition to these MetaTests, I also have lots of different examples showing how you can use this framework in a variety of different ways.
Detailed overview
Interfaces:
The IShould Interface:
This interface contains the following procedures:
Public Property Get Be() As IBe
End Property
Public Property Get Have() As IHave
End Property
Public Function Contain(value As Variant) As Boolean
End Function
Public Function StartWith(value As Variant) As Boolean
End Function
Public Function EndWith(value As Variant) As Boolean
End Function
It is implemented by both the cShould and cShouldNot classes.
The IBe interface:
This interface contains the following procedures:
Public Function GreaterThan(value As Variant) As Boolean
End Function
Public Function LessThan(value As Variant) As Boolean
End Function
Public Function EqualTo(value As Variant) As Boolean
End Function
It is implemented by both the cBe and cNotBe classes.
The IHave interface
This interface contains the following procedures:
Public Function LengthOf(value As Double) As Boolean
End Function
Public Function MaxLengthOf(value As Double) As Boolean
End Function
Public Function MinLengthOf(value As Double) As Boolean
End Function
It is implemented by both the cHave and cNotHave classes.
The ISetExpression interface:
This interface implements the following procedure:
Public Property Set setExpr(value As cExpressions)
End Property
It is implemented by the cBe, cNotBe, cHave, cNotHave, cShould, and cShouldNot classes.
Classes
The cFluent class
The highest level object in the project. It is responsible for accepting the initial test value. From the client, you can access the cMeta class to access meta-level test properties. And you can use the cShould and cShouldNot classes to access additional classes to be described.
This is the code in the cFluent class:
Option Explicit
Private pShould As cShould
Private pShouldSet As ISetExpression
Private pShouldNot As cShouldNot
Private pShouldNotSet As ISetExpression
Private pExpressions As cExpressions
Private pMeta As cMeta
Private pMetaSet As ISetExpression
Public Property Let TestValue(value As Variant)
pExpressions.TestValue = value
End Property
Public Property Get TestValue() As Variant
TestValue = pExpressions.TestValue
End Property
Public Property Get Should() As IShould
If pShould Is Nothing Then
Set pShould = New cShould
End If
Set pShouldSet = pShould
Set pShouldSet.setExpr = pExpressions
Set Should = pShouldSet
End Property
Public Property Get ShouldNot() As IShould
If pShouldNot Is Nothing Then
Set pShouldNot = New cShouldNot
End If
Set pShouldNotSet = pShouldNot
Set pShouldNotSet.setExpr = pExpressions
Set ShouldNot = pShouldNotSet
End Property
Public Property Get Meta() As cMeta
Set Meta = pMeta
End Property
Private Sub Class_Initialize()
Set pExpressions = New cExpressions
Set pMeta = New cMeta
Set pExpressions.setMeta = pMeta
End Sub
The cMeta class
This object is responsible for some test-related settings. These are both implemented as properties which both implement setters and getters. The PrintResult property is a Boolean property. If the property is set to true, results of the results are printed in the immediate window. The second is the TestName field. If it’s given a value, that value is printed to the immediate window when the PrintResults property is set to true.
This is the code in the cMeta class:
Option Explicit
Private pPrintResults As Boolean
Private pTestName As String
Public Property Let TestName(value As String)
pTestName = value
End Property
Public Property Get TestName() As String
TestName = pTestName
End Property
Public Property Let PrintResults(value As Boolean)
pPrintResults = value
End Property
Public Property Get PrintResults() As Boolean
PrintResults = pPrintResults
End Property
The cExpressions class
This object is responsible for the evaluation and printing of all expressions. It contains all methods for evaluation. It also uses an instance of cMeta to determine if and how tests are to be printed. And it contains the TestValue value which the tests are to be evaluated against.
This is the code in the cExpressions class:
Option Explicit
Private pTestValue As Variant
Private pMeta As cMeta
Public Property Let TestValue(value As Variant)
pTestValue = value
End Property
Public Property Get TestValue() As Variant
TestValue = pTestValue
End Property
Public Property Set setMeta(value As cMeta)
Set pMeta = value
End Property
Public Function GreaterThan(OrigVal As Variant, NewVal As Variant, Optional NegateValue As Boolean = False) As Boolean
GreaterThan = (OrigVal > NewVal)
If pMeta.PrintResults Then
If NegateValue Then
NegateValue = Not GreaterThan
PrintEval (NegateValue)
Else
PrintEval (GreaterThan)
End If
End If
End Function
Public Function LessThan(OrigVal As Variant, NewVal As Variant, Optional NegateValue As Boolean = False) As Boolean
LessThan = (OrigVal < NewVal)
If pMeta.PrintResults Then
If NegateValue Then
NegateValue = Not LessThan
PrintEval (NegateValue)
Else
PrintEval (LessThan)
End If
End If
End Function
Public Function EqualTo(OrigVal As Variant, NewVal As Variant, Optional NegateValue As Boolean = False) As Boolean
EqualTo = (OrigVal = NewVal)
If pMeta.PrintResults Then
If NegateValue Then
NegateValue = Not EqualTo
PrintEval (NegateValue)
Else
PrintEval (EqualTo)
End If
End If
End Function
Public Function Contain(OrigVal As Variant, NewVal As Variant, Optional NegateValue As Boolean = False) As Boolean
If OrigVal Like "*" & NewVal & "*" Then
Contain = True
End If
If pMeta.PrintResults Then
If NegateValue Then
NegateValue = Not Contain
PrintEval (NegateValue)
Else
PrintEval (Contain)
End If
End If
End Function
Public Function StartWith(OrigVal As Variant, NewVal As Variant, Optional NegateValue As Boolean = False) As Boolean
Dim valLength As Long
valLength = Len(NewVal)
If Left(OrigVal, valLength) = CStr(NewVal) Then
StartWith = True
End If
If pMeta.PrintResults Then
If NegateValue Then
NegateValue = Not StartWith
PrintEval (NegateValue)
Else
PrintEval (StartWith)
End If
End If
End Function
Public Function EndWith(OrigVal As Variant, NewVal As Variant, Optional NegateValue As Boolean = False) As Boolean
Dim valLength As Long
valLength = Len(NewVal)
If Right(OrigVal, valLength) = CStr(NewVal) Then
EndWith = True
End If
If pMeta.PrintResults Then
If NegateValue Then
NegateValue = Not EndWith
PrintEval (NegateValue)
Else
PrintEval (EndWith)
End If
End If
End Function
Public Function LengthOf(OrigVal As Double, NewVal As Double, Optional NegateValue As Boolean = False) As Boolean
LengthOf = (Len(CStr(OrigVal)) = NewVal)
If pMeta.PrintResults Then
If NegateValue Then
NegateValue = Not LengthOf
PrintEval (NegateValue)
Else
PrintEval (LengthOf)
End If
End If
End Function
Public Function MaxLengthOf(OrigVal As Double, NewVal As Double, Optional NegateValue As Boolean = False) As Boolean
MaxLengthOf = (Len(CStr(OrigVal)) <= NewVal)
If pMeta.PrintResults Then
If NegateValue Then
NegateValue = Not MaxLengthOf
PrintEval (NegateValue)
Else
PrintEval (MaxLengthOf)
End If
End If
End Function
Public Function MinLengthOf(OrigVal As Double, NewVal As Double, Optional NegateValue As Boolean = False) As Boolean
MinLengthOf = (Len(CStr(OrigVal)) >= NewVal)
If pMeta.PrintResults Then
If NegateValue Then
NegateValue = Not MinLengthOf
PrintEval (NegateValue)
Else
PrintEval (MinLengthOf)
End If
End If
End Function
Friend Sub PrintEval(ByVal value As Boolean)
Dim Result As String
Dim TestPassed As Boolean
Result = ""
TestPassed = value
If TestPassed Then
Result = "Passed"
If pMeta.TestName <> Empty Then
Debug.Print pMeta.TestName & Result
Else
Debug.Print "Passed: " & Result
End If
Else
Result = "Failed"
If pMeta.TestName <> Empty Then
Debug.Print pMeta.TestName & Result
Else
Debug.Print "Failed: " & Result
End If
End If
End Sub
The cShould class
Responsible for creating instances of the Have and Be classes. Also responsible for testing a few methods described in the IShould interface. These methods use methods implemented by the cExpressions object under the hood.
This is the code in the cShould class:
Option Explicit
Implements IShould
Implements ISetExpression
Private pShouldVal As Variant
Private pBe As cBe
Private pBeSet As ISetExpression
Private pHave As cHave
Private pHaveSet As ISetExpression
Private pExpressions As cExpressions
Public Property Set ISetExpression_setExpr(value As cExpressions)
Set pExpressions = value
pShouldVal = pExpressions.TestValue
End Property
Public Property Get IShould_Have() As IHave
If pHave Is Nothing Then
Set pHave = New cHave
End If
Set pHaveSet = pHave
Set pHaveSet.setExpr = pExpressions
Set IShould_Have = pHaveSet
End Property
Public Property Get IShould_Be() As IBe
If pBe Is Nothing Then
Set pBe = New cBe
End If
Set pBeSet = pBe
Set pBeSet.setExpr = pExpressions
Set IShould_Be = pBeSet
End Property
Public Function IShould_Contain(value As Variant) As Boolean
IShould_Contain = pExpressions.Contain(pShouldVal, value)
End Function
Public Function IShould_StartWith(value As Variant) As Boolean
IShould_StartWith = pExpressions.StartWith(pShouldVal, value)
End Function
Public Function IShould_EndWith(value As Variant) As Boolean
IShould_EndWith = pExpressions.EndWith(pShouldVal, value)
End Function
The cBe class
Responsible for implementing and executing the methods described earlier in the IBe interface. These methods use methods implemented by the cExpressions object under the hood.
This is the code in the cBe class:
Option Explicit
Implements IBe
Implements ISetExpression
Private pExpressions As cExpressions
Private pBeValue As Variant
Public Property Set ISetExpression_setExpr(value As cExpressions)
Set pExpressions = value
pBeValue = pExpressions.TestValue
End Property
Public Function IBe_GreaterThan(value As Variant) As Boolean
IBe_GreaterThan = pExpressions.GreaterThan(pBeValue, value)
End Function
Public Function IBe_LessThan(value As Variant) As Boolean
IBe_LessThan = pExpressions.LessThan(pBeValue, value)
End Function
Public Function IBe_EqualTo(value As Variant) As Boolean
IBe_EqualTo = pExpressions.EqualTo(pBeValue, value)
End Function
The cHave class
Responsible for implementing and executing the methods described earlier in the IHave interface. These methods use methods implemented by the cExpressions object under the hood.
This is the code in the cHave class:
Option Explicit
Implements IHave
Implements ISetExpression
Private pExpressions As cExpressions
Private pHaveVal As Variant
Public Property Set ISetExpression_setExpr(value As cExpressions)
Set pExpressions = value
pHaveVal = pExpressions.TestValue
End Property
Public Function IHave_LengthOf(value As Double) As Boolean
IHave_LengthOf = pExpressions.LengthOf(CDbl(pHaveVal), value)
End Function
Public Function IHave_MaxLengthOf(value As Double) As Boolean
IHave_MaxLengthOf = pExpressions.MaxLengthOf(CDbl(pHaveVal), value)
End Function
Public Function IHave_MinLengthOf(value As Double) As Boolean
IHave_MinLengthOf = pExpressions.MinLengthOf(CDbl(pHaveVal), value)
End Function
The Not classes (cShouldNot,cNotBe, cNotHave) Responsible for implementing and executing the methods in their respective interfaces (i.e. IShould, IBe, and IHave) For the implementation of the various methods, they use the same methods in the cExpessions object as their non-negated counterparts. The only difference is that these methods are negated with a not operator to get the opposite result.
The cShouldNot class
This is the code in the cShouldNot class:
Option Explicit
Implements IShould
Implements ISetExpression
Private pNotBe As cNotBe
Private pNotBeSet As ISetExpression
Private pNotHave As cNotHave
Private pNotHaveSet As ISetExpression
Private pExpressions As cExpressions
Private pShouldNotVal As Variant
Public Property Set ISetExpression_setExpr(value As cExpressions)
Set pExpressions = value
pShouldNotVal = pExpressions.TestValue
End Property
Public Property Get IShould_Have() As IHave
If pNotHave Is Nothing Then
Set pNotHave = New cNotHave
End If
Set pNotHaveSet = pNotHave
Set pNotHaveSet.setExpr = pExpressions
Set IShould_Have = pNotHaveSet
End Property
Public Property Get IShould_Be() As IBe
If pNotBe Is Nothing Then
Set pNotBe = New cNotBe
End If
Set pNotBeSet = pNotBe
Set pNotBeSet.setExpr = pExpressions
Set IShould_Be = pNotBeSet
End Property
Public Function IShould_Contain(value As Variant) As Boolean
IShould_Contain = Not pExpressions.Contain(pShouldNotVal, value, True)
End Function
Public Function IShould_StartWith(value As Variant) As Boolean
IShould_StartWith = Not pExpressions.StartWith(pShouldNotVal, value, True)
End Function
Public Function IShould_EndWith(value As Variant) As Boolean
IShould_EndWith = Not pExpressions.EndWith(pShouldNotVal, value, True)
End Function
The cNotBe class
This is the code in the cNotBe class:
Option Explicit
Implements IBe
Implements ISetExpression
Private pNotBeValue As Variant
Private pBe As IBe
Private pExpressions As cExpressions
Public Property Set ISetExpression_setExpr(value As cExpressions)
Set pExpressions = value
pNotBeValue = pExpressions.TestValue
End Property
Public Function IBe_GreaterThan(value As Variant) As Boolean
IBe_GreaterThan = Not pExpressions.GreaterThan(pNotBeValue, value, True)
End Function
Public Function IBe_LessThan(value As Variant) As Boolean
IBe_LessThan = Not pExpressions.LessThan(pNotBeValue, value, True)
End Function
Public Function IBe_EqualTo(value As Variant) As Boolean
IBe_EqualTo = Not pExpressions.EqualTo(pNotBeValue, value, True)
End Function
The cNotHave class
This is the code in the cNotHave class:
Option Explicit
Implements IHave
Implements ISetExpression
Private pNotHaveVal As Variant
Private pExpressions As cExpressions
Public Property Set ISetExpression_setExpr(value As cExpressions)
Set pExpressions = value
pNotHaveVal = pExpressions.TestValue
End Property
Public Function IHave_LengthOf(value As Double) As Boolean
IHave_LengthOf = Not pExpressions.LengthOf(CDbl(pNotHaveVal), value, True)
End Function
Public Function IHave_MaxLengthOf(value As Double) As Boolean
IHave_MaxLengthOf = Not pExpressions.MaxLengthOf(CDbl(pNotHaveVal), value, True)
End Function
Public Function IHave_MinLengthOf(value As Double) As Boolean
IHave_MinLengthOf = Not pExpressions.MinLengthOf(CDbl(pNotHaveVal), value, True)
End Function
Final notes
After LOTS of changes to the API, I think I finally have a design I’m satisfied with. I’d appreciate any feedback.
-
1\$\begingroup\$ You need lots lots lots more examples demonstrating how your code works and a detailed help file explaining what each of your classes does/ how it should be used. \$\endgroup\$Freeflow– Freeflow2021年09月09日 22:20:04 +00:00Commented Sep 9, 2021 at 22:20
-
\$\begingroup\$ In terms of testing, I have over 500 lines of code relating to tests. The MetaTests procedure details usage of every method in the API. You can see that in the mTests.bas file I have on github here: github.com/b-gonzalez/Fluent-VBA/blob/main/Source/mTests.bas The API as used by the client when an instance is created is pretty simple and well explained by the tests. \$\endgroup\$Brian Gonzalez– Brian Gonzalez2021年09月10日 02:10:11 +00:00Commented Sep 10, 2021 at 2:10
-
\$\begingroup\$ I do agree that some of the methods in cExpressions can be explained in better detail. So I'll focus on adding comments detailing what those methods do. \$\endgroup\$Brian Gonzalez– Brian Gonzalez2021年09月10日 02:17:41 +00:00Commented Sep 10, 2021 at 2:17
1 Answer 1
Really great stuff!
PredeclaredIds
It might be useful to consider declaring many of your classes with their VB_PredeclaredId
attribute set to True
. Doing so makes each class essentially 'static' (a default instance will always exist). The static instance can act as a class factory and enforce the required cExpressions
instance/dependency in a single statement. I would point you here for a more in-depth explanation.
As an example, this would allow changing:
'From the cFluent class
Public Property Get Should() As IShould
If pShould Is Nothing Then
Set pShould = New cShould
End If
Set pShouldSet = pShould
Set pShouldSet.setExpr = pExpressions
Set Should = pShouldSet
End Property
To become:
Public Property Get Should() As IShould
If pShould Is Nothing Then
Set pShould = cShould.Create(pExpressions)
End If
Set Should = pShould
End Property
The cShould
class would need a new Public
factory/constructor function like:
Public Function Create(ByVal testExpression As cExpressions) As IShould
Dim newShould As ISetExpression
Set newShould = new cShould
Set newShould.setExpr = testExpression
Set Create = newShould
End Function
One more class?
When considering the example:
Dim Result as cFluent
Set Result = new cFluent
Result.TestValue = ReturnsFive()
Result.Should.Be.EqualTo(5)
It seemed to me that setting the TestValue
property detracted a little bit from the general 'fluency' of the API.
Initializing cFluent
with a test result seems to be largely driven by limitations of VBA compared to advantages of other languages. For example, in C#, Extension methods allows expressions like the one below where the test result is part of the fluent expression (the example is from here ):
string actual = "ABCDEFGHI";
actual.Should().StartWith("AB").And.EndWith("HI").And.Contain("EF").And.HaveLength(9);
Although VBA does not support Extension methods, it might also be interesting to explore adding one additional layer prior to cFluent
, like cFluentTestResult
. Doing so could result expressions like:
Dim testResult as cFluentTestResult
Set testResult = new cFluentTestResult
testResult.Of(ReturnsFive()).Should.Be.EqualTo(5)
The test result is simply passed from class to class. Consequently, cFluentTestResult
can be a stateless class that simply initiates the assert expression. So, by setting cFluentTestResult's
VB_PredeclaredId attribute to True
, the expression can become as terse as:
cFluentTestResult.Of(ReturnsFive()).Should.Be.EqualTo(5)
Using this additional layer with the C# example above
Dim actual As String
actual = "ABCDEFGHI"
cFluentTestResult.Of(actual).Should().StartWith("AB").And.EndWith("HI").And.Contain("EF").And.HaveLength(9);
Not quite as nice as C#, but now the test result is also built into the assert expression...food for thought.
I'll also comment that the cExpressions
class will need some further work especially in the EqualTo
function. Passing values around as Variant
is necessary because VBA supports neither generics nor method overloads. Still, comparing two Variant
values using the =
operator is insufficient in many cases. Comparisons depend greatly on the specific Type involved. As an example, when comparing Doubles
, some type of tolerance parameter is needed. In some cases 4.6 = 4.56 returning True
, is good enough ... and sometimes it's not. All the actual = expected comparisons in cExpressions
need to be reviewed carefully for all potential VBA Types that can be encountered.
-
1\$\begingroup\$ Minor point, but would probably go for
.AndAlso
in the API to avoid clashing with the protected keyword and to suggest you can short circuit the API by mimicking VB.Net naming - i.e. if one assert fails they all do. \$\endgroup\$Greedo– Greedo2021年09月14日 17:45:13 +00:00Commented Sep 14, 2021 at 17:45 -
\$\begingroup\$ This is really great feedback. I'll look into implementing some of your suggestions. \$\endgroup\$Brian Gonzalez– Brian Gonzalez2021年09月18日 00:31:58 +00:00Commented Sep 18, 2021 at 0:31