Dependency Injection (sort of)
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
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.
Improving Testability
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.
Automatability
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 MsgBox
call.
Repeatability
Given the same set of inputs, our tests should return the same results regardless of when or how often we run the tests.
Example
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