3
\$\begingroup\$

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.

mdfst13
22.4k6 gold badges34 silver badges70 bronze badges
asked Sep 9, 2021 at 19:21
\$\endgroup\$
3
  • 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\$ Commented 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\$ Commented 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\$ Commented Sep 10, 2021 at 2:17

1 Answer 1

3
\$\begingroup\$

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.

answered Sep 13, 2021 at 13:42
\$\endgroup\$
2
  • 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\$ Commented Sep 14, 2021 at 17:45
  • \$\begingroup\$ This is really great feedback. I'll look into implementing some of your suggestions. \$\endgroup\$ Commented Sep 18, 2021 at 0:31

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.