Dependency Injection (sort of)
For a few common dependencies, we can cheat by using a singleton class. Within that class, we'll type those dependencies as interfaces.
In previous articles, I discussed interfaces both conceptually and practically. So, we've discussed what interfaces are and how to use them in VBA. Now, let's talk about when to use them by going through a few scenarios where interfaces make sense. I'll cover the third scenario in this article.
- Backward-compatible refactoring
- Generic forms in a code library
- Dependency injection
- Cleaner IntelliSense
Dependency injection is one technique to achieve inversion of control. The concept itself is well beyond the scope of this article. For our purposes, we're only going to worry about the practical reasons you would want to use dependency injection. In particular, we want to use the technique to make our code more testable.
The other reason I'm focusing on the practical reason for using dependency injection is entirely self-serving. That's because the technique I'm going to discuss is not technically dependency injection. However, it serves the same purpose in terms of improving the testability of our code.
Rather than actually inject our dependency into our class routines, I'm going to cheat and use a singleton class to provide a subset of common dependencies for our application.
I'm going to discuss defining and using a messaging service and an Active Directory service.
I should narrow down what I mean by "improving testability." I'm going to focus on two important features of unit testing: automatability and repeatability.
We need to be able to automate the running of our tests. That means we can't have any blocking code that waits for user input, like a
Given the same set of inputs, our tests should return the same results regardless of when or how often we run the tests.
Imagine we have the following routine. It checks to see if a particular Active Directory user exists. If the user exists, it returns True. If the user does not exist, it shows a message box and returns False.
Function LoginExists(Login As String) As Boolean Dim AD As New clsActiveDirectory If AD.UserExists(Login) Then LoginExists = True Else LoginExists = False MsgBox "User " & Login & " does not exist." End If End Function
Now imagine if we tried to test this. The above code is not repeatable or automatable. A test run where the Login does not exist will display a message box, requiring us to click out of it to continue with the remaining tests. Thus, it is not automatable.
It is also not repeatable. While I'm not showing an implementation for the Active Directory class, it's safe to assume that it will run differently within our client's domain than it will in our development environment. Even if we're testing within the same domain as where our code will run, a user who exists in the domain today could be removed tomorrow.
Let's cheat a little
How do we deal with these problems? As I alluded to earlier, the best way to deal with these problems generically is with actual dependency injection. But for a few common dependencies, we can cheat by using a singleton class.
Within the singleton class, we'll include properties that are objects typed as interfaces, rather than concrete classes. This will allow us to swap out the production versions of our class implementations for test-specific versions when it comes time to test our code.
calling code: Using the singleton class
The singleton class I'll be using is one I include in all my projects, clsApp. I originally wrote the class to turn the Application.Echo method into a property, but that's another story. Here's what our function above looks like using App, the global instance of the clsApp class:
Function LoginExists(Login As String) As Boolean If App.ADSvc.UserExists(Login) Then LoginExists = True Else LoginExists = False App.MsgSvc.ShowInfo "User " & Login & " does not exist." End If End Function
clsApp: The singleton class
Below is an excerpt of the clsApp class, with only the relevant parts shown:
'--== clsApp class module (excerpt) ==-- Private m_objADSvc As iActiveDirectoryService Private m_objMsgService As iMsgService 'Usage: add the following lines to the ConfigureServices() routine: ' Set App.ADSvc = New svcAD_WinNT ' App.ADSvc.DomainController = "dc1.contoso.local" ' : for testing, add the following line to the Test setup routine: ' Set App.ADSVc = New svcAD_Testing Public Property Get ADSvc() As iActiveDirectoryService If m_objADSvc Is Nothing Then ConfigureServices Set ADSvc = m_objADSvc End Property Public Property Set ADSvc(Value As iActiveDirectoryService) Set m_objADSvc = Value End Property 'Usage: add the following line to the ConfigureServices() routine: ' Set App.MsgSvc = New svcMsg_MsgBox ' : for testing, add the following line to the Test setup routine: ' Set App.MsgSvc = New svcMsg_Testing Public Property Get MsgSvc() As iMsgService If m_objMsgService Is Nothing Then ConfigureServices Set MsgSvc = m_objMsgService End Property Public Property Set MsgSvc(Value As iMsgService) Set m_objMsgService = Value End Property Private Sub Class_Initialize() ConfigureServices End Sub
Notice that when the above class gets initialized, it calls out to a ConfigureServices procedure that will vary from one application to another.
xx_Main: The application-specific setup
I place the ConfigureServices routine in my "main" application-specific standard module. Here's what that looks like:
' --== xx_Main (standard module) where "xx" is an application-specific prefix Public Sub ConfigureServices() Set App.ADSvc = New svcAD_WinNT App.ADSvc.DomainController = "dc.myclient.local" Set App.MsgSvc = New svcMsg_MsgBox End Sub
test_Main: The test setup routine
When the program runs normally, the ConfigureServices method uses our production-ready implementation classes. But, when we are testing, we need to use test classes. These test classes are often "mock" objects (creating mock objects for testing is beyond the scope of this article).
' --== test_Main ==-- (standard module) ' This module includes routines for setup and teardown of the test environment Public Sub ConfigureServicesForTesting() Set App.ADSvc = New svcAD_Testing Set App.MsgSvc = New svcMsg_Testing End Sub
testPermissions: The actual test module
Finally, the payoff. This is our actual test module. The tests themselves are not shown below, but this should be enough to illustrate the concept.
Also, in an actual application, we probably wouldn't be calling ConfigureServicesForTesting directly from the RunIdentityTests procedure. Rather, we would likely have called it from a TestEnvironmentSetup routine.
' --== testIdentity ==-- (standard module) ' This module tests user identity and permissions methods. Public Sub RunIdentityTests() ConfigureServicesForTesting TestLoginExists TestUserHasPermission TestGroupHasPermission End Sub
Image by Arek Socha from Pixabay