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 FunctionIt 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 FunctionIt 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 FunctionIt 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 PropertyIt 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 SubThe 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 PropertyThe 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 SubThe 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 FunctionThe 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 FunctionThe 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 FunctionThe 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 FunctionThe 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 FunctionThe 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 FunctionFinal 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:00CommentedSep 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:00CommentedSep 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:00CommentedSep 10, 2021 at 2:17
1 Answer1
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 PropertyTo become:
Public Property Get Should() As IShould If pShould Is Nothing Then Set pShould = cShould.Create(pExpressions) End If Set Should = pShouldEnd PropertyThecShould 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 FunctionOne 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.
- 1\$\begingroup\$Minor point, but would probably go for
.AndAlsoin 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:00CommentedSep 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:00CommentedSep 18, 2021 at 0:31
You mustlog in to answer this question.
Explore related questions
See similar questions with these tags.