Testable Code
We sometimes take for granted the idea that code should be testable, but what does that really mean? We’re going to dig into a few ways that we can incrementally improve the testability of our codebases. This section still matters to you if you’re writing brand new code! You can use these techniques to make your code more testable from the start.
Parameterize all the things!
Passing data through parameters, rather than using globals or data in files, is our starting point for testable code. It’s not uncommon (pardon the pun) to see code that uses commons/globals to control the operation of subroutines/functions that are called. This is a very common pattern in legacy codebases and is a major source of pain when trying to test the code. Let’s take a look at a simple example to show you what I mean:
main
record
global common
fld1, d10
endcommon
record
result, i4
proc
fld1 = 5
xcall increment()
Console.WriteLine(result)
endmain
subroutine increment
common
fld1, d10
endcommon
proc
fld1 += 1
xreturn
endsubroutine
Why didn’t everyone just parameterize everything from the start? Well, there are a few reasons, but the big ones are solved now. Before the DBL compiler had support for strong prototyping, you had no way to reliably detect that the parameter list for a routine had changed. This meant that it was potentially a massive effort to grep through your entire codebase to find all the places that called a routine and potentially update the parameter list. This operation was potentially recursive, as you might need to update the parameter list of the routines that called the routine you were updating. Given the pressure to ship working software, this situation was often avoided by just putting tons of stuff into global/common data. Today, not only can the compiler tell you if you’ve missed a parameter, but navigating the codebase to find and update the calling routines is trivial with Visual Studio.
Let’s take a look at a few of the benefits of parameterizing your code:
-
Isolation of functions/methods: Parameters allow functions or methods to operate in isolation. They don’t rely on external state (like global variables) or external resources (like files), making them predictable and consistent. This isolation simplifies the testing process because you only need to consider the inputs and outputs of the function, not the state of the entire system.
-
Control of test conditions: When you use parameters, you can easily control the input for testing purposes. This enables you to create a wide range of test cases, including edge cases, without needing to manipulate global state or file contents, which can be cumbersome and error-prone.
-
Reduced side effects: Global variables and file operations often lead to side effects, where a change in one part of the system unexpectedly affects another part. By using parameters, you minimize these side effects, making it easier to understand and test each part of the code in isolation.
-
Easier mocking and stubbing: In unit testing, it’s common to use mocks or stubs to simulate parts of the system. Parameters make it easier to inject these mocks or stubs, as you’re simply passing different values or objects through the parameters. This is more complex with global variables or file-based inputs, as it might require changing the global state or file content for each test.
-
Concurrency and parallel testing: When tests rely on parameters rather than shared global state or files, they can be run in parallel on .NET without the risk of interfering with each other. This is crucial for efficient testing, especially in large codebases or when running tests in a continuous integration environment.
-
Documentation and readability: Functions that explicitly declare their inputs through parameters are generally more readable and self-documenting. This clarity is beneficial for testing, as it makes it easier to understand what a function does and what it needs to be tested with.
-
Refactoring and maintenance: When your code relies on parameters rather than globals or files, it’s often easier to refactor. You can change the internals of a function without worrying about how it will impact the rest of the system, which is particularly important when maintaining and updating older code.
Parameterizing existing code
Hopefully you’re convinced that parameters are preferable to globals, but you have a massive codebase and you’re not sure where to start. The good news is that you can start small and work your way up. Let’s talk about using adapters and bridges to incrementally improve the testability of your codebase.
Before The caller communicates with the original function through a combination of zero or more parameters plus global state in the form of a GDS or COMMON.
Bridge functions In a bridge function, the main idea is to connect global state to a more modular, parameterized function. The bridge function handles the passing of global state as parameters.
Adapter functions An adapter function is used to adapt the interface of one function to another. It takes parameters from the caller, modifies the global state, and then calls your original parameterless function.
Both adapter functions and bridge functions fall under the category of wrappers. They wrap around the original function, providing a controlled interface to it. The difference is that a bridge function is a wrapper that takes global state as parameters, whereas an adapter function is a wrapper that takes parameters from the caller and modifies global state.
An example
We’re going to start with a couple of small functions that rely on global state, and then we’ll show both approaches to parameterizing them.
subroutine my_interesting_routine
out result, n
common
fld1, d10
fld2, d10
proc
xcall another_routine()
result = fld1 + fld2
xreturn
endsubroutine
subroutine another_routine
common
fld1, d10
fld2, d10
record
tmp, i4
proc
tmp = fld1 + fld2
if((tmp .band. 1) == 1)
fld1 = fld1 + 1
xreturn
endsubroutine
main
record
global common
fld1, d10
fld2, d10
endcommon
record
result, i4
proc
fld1 = 5
fld2 = 9000
xcall my_interesting_routine(result)
Console.WriteLine(result)
endmain
This is obviously a silly example, but it will demonstrate many of the problems we have to solve when refactoring legacy code. Let’s start by creating a parameterized version of my_interesting_routine
:
subroutine my_interesting_routine_p
in fld1, d10
in fld2, d10
out result, n
proc
xcall another_routine()
result = fld1 + fld2
xreturn
endsubroutine
This is a good start, but we still have a problem: another_routine
is still using global state. Let’s try making a parameterized version of another_routine
:
subroutine another_routine_p
inout fld1, d10
in fld2, d10
record
tmp, i4
proc
tmp = fld1 + fld2
;;if tmp is an odd number
if((tmp .band. 1) == 1)
fld1 = fld1 + 1
xreturn
endsubroutine
If you try to call another_routine_p
from my_interesting_routine_p
, you’ll get a compiler error. The compiler is telling you that you can’t pass an in
parameter to an inout
parameter. This is a good thing! The compiler is telling you that you’re trying to change the value of a parameter that you’ve told it is read only. This is a great example of how the compiler can help you write better code.
subroutine my_interesting_routine_p
inout fld1, d10
in fld2, d10
out result, n
proc
xcall another_routine_p(fld1, fld2)
result = fld1 + fld2
xreturn
endsubroutine
subroutine another_routine_p
inout fld1, d10
in fld2, d10
record
tmp, i4
proc
tmp = fld1 + fld2
if((tmp .band. 1) == 1)
fld1 = fld1 + 1
xreturn
endsubroutine
Okay, now the parameterized version of my_interesting_routine
can be used as a functional equivalent to the original. Let’s wrap it with our bridge function:
subroutine my_interesting_routine
out result, n
common
fld1, d10
fld2, d10
proc
xcall my_interesting_routine_p(fld1, fld2, result)
endsubroutine
That’s not so hard, but notice that this method of wrapping our global state away is somewhat viral. We had to change the original function to call the parameterized version, and then we had to change the parameterized version to call the parameterized version of the other function. If we had tried to mix a parameterized version with the original or with a wrapper, it would have been very easy to subtly change the behavior of the code, introducing hard-to-track-down bugs. If you’re going to use bridge functions as a strategy, this is a good example of why we want to start small and work our way up.
Now that you’ve seen how to use a bridge function to wrap a function that uses global state, let’s look at how we can use an adapter function to do the same job:
subroutine my_interesting_routine_p
inout param1, d10
in param2, d10
out result, n
common
fld1, d10
fld2, d10
proc
fld1 = param1
fld2 = param2
xcall my_interesting_routine()
param1 = fld1
xreturn
endsubroutine
This version is functionally equivalent to the original, but now it takes parameters and uses them to pass in and out the global state. If you need to start big and a little dirtier, using adapter functions is a good way to go. You can wrap the entire function and then start breaking it down into smaller pieces. This is a good strategy if you’re trying to get a large codebase under test quickly. You will of course need to watch out for any unknown side effects for routines called by the function you’re wrapping. In our example, we had already learned that fld1
was being modified by another routine, so we knew we needed to pass it in and out of our wrapper function. If we hadn’t known that, we would have had to discover it by testing the function and then refactoring it to use parameters.
Dependency injection (.NET)
Dependency injection (DI) is a design pattern that changes how dependencies are managed within your codebase. It’s a technique where dependencies (such as services, objects, or functions) are “injected” into a component (like a class or function) from the outside, rather than being created inside the component.
Without DI, components often create instances of their own dependencies. This tight coupling makes it hard to modify or test individual components, as changes in one dependency can ripple through the entire system. Moreover, testing such components in isolation becomes challenging, as they rely on the actual implementation of their dependencies.
Dependency injection addresses these issues by decoupling components from their dependencies. Instead of a component initializing its dependencies, the dependencies are provided to it, often through constructors, setters, or specific DI frameworks. This decoupling means components don’t need to know where their dependencies come from or how they are implemented. They just need to know that the dependencies adhere to a certain interface or contract.
The impact on testability is substantial:
Easier unit testing: With DI, you can easily provide mock implementations of dependencies when unit testing a component. This allows for testing the component in isolation, without worrying about the intricacies of its dependencies.
Reduced test complexity: Since dependencies can be replaced with simpler, controlled versions (like stubs or mocks), the complexity of setting up test environments is significantly reduced. This simplifies writing, understanding, and maintaining tests.
Increased code reusability: DI encourages writing more modular code. Modules or components that are designed to be independent from their dependencies are inherently more reusable in different contexts.
Flexibility and scalability: Changing or upgrading dependencies becomes easier and safer. Since components are not tightly bound to specific implementations, swapping or modifying dependencies has minimal impact on the components themselves.
Design for testability: DI fosters a design mindset where developers think about testability from the outset. Designing components with DI in mind leads to cleaner interfaces and more focused component responsibilities.
Later on in this chapter, we’ll discuss using fakes for .NET and Traditional DBL, as an alternative to injecting mocks with DI.