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.

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