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.

\$\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\$
    – Freeflow
    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\$ 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\$ 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.

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

Your Answer

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

Not the answer you're looking for? Browse other questions tagged or ask your own question.