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 GitHubhere

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 5Dim Assert as cUnitTesterSet Assert = New cUnitTesterAssert.Equal(Result,5)

You can have code that reads more naturally like so:

Dim Result as cFluentSet Result = new cFluentResult.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 IBeEnd PropertyPublic Property Get Have() As IHaveEnd PropertyPublic Function Contain(value As Variant) As BooleanEnd FunctionPublic Function StartWith(value As Variant) As BooleanEnd FunctionPublic Function EndWith(value As Variant) As BooleanEnd 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 BooleanEnd FunctionPublic Function LessThan(value As Variant) As BooleanEnd FunctionPublic Function EqualTo(value As Variant) As BooleanEnd 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 BooleanEnd FunctionPublic Function MaxLengthOf(value As Double) As BooleanEnd FunctionPublic Function MinLengthOf(value As Double) As BooleanEnd 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 ExplicitPrivate pShould As cShouldPrivate pShouldSet As ISetExpressionPrivate pShouldNot As cShouldNotPrivate pShouldNotSet As ISetExpressionPrivate pExpressions As cExpressionsPrivate pMeta As cMetaPrivate pMetaSet As ISetExpressionPublic Property Let TestValue(value As Variant)    pExpressions.TestValue = valueEnd PropertyPublic Property Get TestValue() As Variant    TestValue = pExpressions.TestValueEnd PropertyPublic 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 = pShouldSetEnd PropertyPublic 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 = pShouldNotSetEnd PropertyPublic Property Get Meta() As cMeta    Set Meta = pMetaEnd PropertyPrivate Sub Class_Initialize()    Set pExpressions = New cExpressions    Set pMeta = New cMeta    Set pExpressions.setMeta = pMetaEnd 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 ExplicitPrivate pPrintResults As BooleanPrivate pTestName As StringPublic Property Let TestName(value As String)    pTestName = valueEnd PropertyPublic Property Get TestName() As String    TestName = pTestNameEnd PropertyPublic Property Let PrintResults(value As Boolean)    pPrintResults = valueEnd PropertyPublic Property Get PrintResults() As Boolean    PrintResults = pPrintResultsEnd 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 ExplicitPrivate pTestValue As VariantPrivate pMeta As cMetaPublic Property Let TestValue(value As Variant)    pTestValue = valueEnd PropertyPublic Property Get TestValue() As Variant    TestValue = pTestValueEnd PropertyPublic Property Set setMeta(value As cMeta)    Set pMeta = valueEnd PropertyPublic 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 IfEnd FunctionPublic 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 IfEnd FunctionPublic 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 IfEnd FunctionPublic 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 IfEnd FunctionPublic 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 IfEnd FunctionPublic 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 IfEnd FunctionPublic 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 IfEnd FunctionPublic 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 IfEnd FunctionPublic 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 IfEnd FunctionFriend 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 IfEnd 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 ExplicitImplements IShouldImplements ISetExpressionPrivate pShouldVal As VariantPrivate pBe As cBePrivate pBeSet As ISetExpressionPrivate pHave As cHavePrivate pHaveSet As ISetExpressionPrivate pExpressions As cExpressionsPublic Property Set ISetExpression_setExpr(value As cExpressions)    Set pExpressions = value    pShouldVal = pExpressions.TestValueEnd PropertyPublic 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 = pHaveSetEnd PropertyPublic 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 = pBeSetEnd PropertyPublic Function IShould_Contain(value As Variant) As Boolean    IShould_Contain = pExpressions.Contain(pShouldVal, value)End FunctionPublic Function IShould_StartWith(value As Variant) As Boolean    IShould_StartWith = pExpressions.StartWith(pShouldVal, value)End FunctionPublic 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 ExplicitImplements IBeImplements ISetExpressionPrivate pExpressions As cExpressionsPrivate pBeValue As VariantPublic Property Set ISetExpression_setExpr(value As cExpressions)    Set pExpressions = value    pBeValue = pExpressions.TestValueEnd PropertyPublic Function IBe_GreaterThan(value As Variant) As Boolean    IBe_GreaterThan = pExpressions.GreaterThan(pBeValue, value)End FunctionPublic Function IBe_LessThan(value As Variant) As Boolean    IBe_LessThan = pExpressions.LessThan(pBeValue, value)End FunctionPublic 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 ExplicitImplements IHaveImplements ISetExpressionPrivate pExpressions As cExpressionsPrivate pHaveVal As VariantPublic Property Set ISetExpression_setExpr(value As cExpressions)    Set pExpressions = value    pHaveVal = pExpressions.TestValueEnd PropertyPublic Function IHave_LengthOf(value As Double) As Boolean    IHave_LengthOf = pExpressions.LengthOf(CDbl(pHaveVal), value)End FunctionPublic Function IHave_MaxLengthOf(value As Double) As Boolean    IHave_MaxLengthOf = pExpressions.MaxLengthOf(CDbl(pHaveVal), value)End FunctionPublic 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 ExplicitImplements IShouldImplements ISetExpressionPrivate pNotBe As cNotBePrivate pNotBeSet As ISetExpressionPrivate pNotHave As cNotHavePrivate pNotHaveSet As ISetExpressionPrivate pExpressions As cExpressionsPrivate pShouldNotVal As VariantPublic Property Set ISetExpression_setExpr(value As cExpressions)    Set pExpressions = value    pShouldNotVal = pExpressions.TestValueEnd PropertyPublic 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 = pNotHaveSetEnd PropertyPublic 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 = pNotBeSetEnd PropertyPublic Function IShould_Contain(value As Variant) As Boolean    IShould_Contain = Not pExpressions.Contain(pShouldNotVal, value, True)End FunctionPublic Function IShould_StartWith(value As Variant) As Boolean    IShould_StartWith = Not pExpressions.StartWith(pShouldNotVal, value, True)End FunctionPublic 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 ExplicitImplements IBeImplements ISetExpressionPrivate pNotBeValue As VariantPrivate pBe As IBePrivate pExpressions As cExpressionsPublic Property Set ISetExpression_setExpr(value As cExpressions)    Set pExpressions = value    pNotBeValue = pExpressions.TestValueEnd PropertyPublic Function IBe_GreaterThan(value As Variant) As Boolean    IBe_GreaterThan = Not pExpressions.GreaterThan(pNotBeValue, value, True)End FunctionPublic Function IBe_LessThan(value As Variant) As Boolean    IBe_LessThan = Not pExpressions.LessThan(pNotBeValue, value, True)End FunctionPublic 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 ExplicitImplements IHaveImplements ISetExpressionPrivate pNotHaveVal As VariantPrivate pExpressions As cExpressionsPublic Property Set ISetExpression_setExpr(value As cExpressions)    Set pExpressions = value    pNotHaveVal = pExpressions.TestValueEnd PropertyPublic Function IHave_LengthOf(value As Double) As Boolean    IHave_LengthOf = Not pExpressions.LengthOf(CDbl(pNotHaveVal), value, True)End FunctionPublic Function IHave_MaxLengthOf(value As Double) As Boolean    IHave_MaxLengthOf = Not pExpressions.MaxLengthOf(CDbl(pNotHaveVal), value, True)End FunctionPublic 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's user avatar
mdfst13
22.4k6 gold badges34 silver badges70 bronze badges
askedSep 9, 2021 at 19:21
Brian Gonzalez's user avatar
\$\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\$CommentedSep 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\$CommentedSep 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\$CommentedSep 10, 2021 at 2:17

1 Answer1

3
\$\begingroup\$

Really great stuff!

PredeclaredIds

It might be useful to consider declaring many of your classes with theirVB_PredeclaredId attribute set toTrue. Doing so makes each class essentially 'static' (a default instance will always exist). Thestatic instance can act as a class factory and enforce the requiredcExpressions instance/dependency in a single statement. I would point youhere for a more in-depth explanation.

As an example, this would allow changing:

'From the cFluent classPublic 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 = pShouldSetEnd Property

To become:

Public Property Get Should() As IShould    If pShould Is Nothing Then        Set pShould = cShould.Create(pExpressions)    End If    Set Should = pShouldEnd Property

ThecShould class would need a newPublic 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 = newShouldEnd Function

One more class?

When considering the example:

Dim Result as cFluentSet Result = new cFluentResult.TestValue = ReturnsFive()Result.Should.Be.EqualTo(5)

It seemed to me that setting theTestValue property detracted a little bit from the general 'fluency' of the API.

InitializingcFluent 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 fromhere ):

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 tocFluent, likecFluentTestResult. Doing so could result expressions like:

Dim testResult as cFluentTestResultSet testResult = new cFluentTestResulttestResult.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 settingcFluentTestResult's VB_PredeclaredId attribute toTrue, 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 Stringactual = "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 thecExpressions class will need some further work especially in theEqualTo function. Passing values around asVariant is necessary because VBA supports neither generics nor method overloads. Still, comparing twoVariant values using the= operator is insufficient in many cases. Comparisons depend greatly on the specific Type involved. As an example, when comparingDoubles, some type of tolerance parameter is needed. In some cases 4.6 = 4.56 returningTrue, isgood enough ... and sometimes it's not. All the actual = expected comparisons incExpressions need to be reviewed carefully for all potential VBA Types that can be encountered.

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

You mustlog in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.