Legacy Code Modernisation: Speeding Up Approval Test Writing with CombinationsApproval
Manually writing Approval Tests can become cumbersome, especially if various input parameter combinations exist. Libraries like ApprovalTests can mitigate these problems.
In a previous article, I showed how to write approval tests manually. This method can easily be employed in small codebases, but larger functions with many input parameters can make it cumbersome to write tests for every parameter combination.
Approval Tests is a library that makes it very easy to automate that task. It provides CombinationApprovals: for every defined input parameter, the library automatically creates every combination of them, easily covering a multitude of test cases with a single test.
I will show here how to install and apply it to your codebase, and some pitfalls to avoid when setting it up.
Approval Testing
The basic idea of combinations approval testing is the same as described in my article on manual approval testing:
There are 5 steps to manually writing Approval Tests (DAAARt 🎯):
Define: what method should be tested
Act: call the method under test
Arrange: add missing dependencies
Assert: the state of affected object as string
Run test coverage: to see what branches have not been covered yet
Installing the Library
Builds can be found for a variety of languages. I’m using the Java Maven dependency for these examples:
<dependency>
<groupId>com.approvaltests</groupId>
<artifactId>approvaltests</artifactId>
<version>24.17.0</version>
<scope>test</scope>
</dependency>
Understanding the Library Function
I use the following codebase. Like in the previous article, I will create an approval test for the outermost controller method:
ParkingSpotReservationController::reserveParkingSpot takes a ParkingSpotReservationService as a constructor input and ParkingReservationRequest as a method parameter.
Let’s create a usual Unit Test for it:
The method I’m interested in is CombinationApprovals.verifyAllCombinations(). This method takes 2 types of inputs:
The method to test as a
Function
0 - N input parameters into that method, each represented as
InputType[]{}
The arrays for the input parameters allow the framework to automatically generate all combinations, increasing coverage. We don’t need to manually consider every possible input combination anymore.
Creating a Testing Function
Let’s create the Function to pass to verifyAllCombinations. The behaviour we want to fix in place is the following:
ParkingSpotReservationController takes 1 service argument in it’s constructor and 1 request argument in the reserveParkingSpot method. A first approach could be to create a function that passes these two parameters and returns the response, like:
However, we don’t want to stub the ParkingSpotReservationService, which is the argument passed to ParkingSpotReservationController. Like with manual approval tests, only awkward, meaning hard-to-instantiate, slow, or nondeterministic dependencies should be stubbed. So we instantiate the service inside that function as-is and pass it to the controller.
The two dependencies passed to the ParkingSpotReservationService are indeed such awkward dependencies. They are framework-specific JPA repository interfaces (ParkingReservationRepository and ParkingSpotRepository) and should be stubbed and passed to the method:

Now one issue remains: the returned ResponseEntity<Object> class only has a framework-specific toString() method that we cannot predict what it will return.
To counter that, as with manual approval tests, we create a static stateAsString method that retrieves only those fields we are interested in. This method will be the state that we want to save to fix the behaviour in place:
The final function to pass to verifyAllCombinations is therefore going to be:
It can now be used as a static function call like:
ParkingSpotReservationControllerApprovalTest::createAssertableOutput
Creating a Test
The simplest test that runs through with an empty output looks like the following:
As a result, we get two output files:
These two output files are currently empty. Once we add working parameters in their respective arrays, these files will contain either the output to assert (received.txt) or the approved output (approved.txt).
If we run the test again without changing anything, the received.txt file will disappear because we “approved” that empty output is the state to fix in place, and the test becomes green.
First Output to Assert
Now we need to apply DAAARt (see above), So let’s check the code and arrange the dependencies to test to cover as much of the code as possible per test.
The first test case that gets into the body of the service and covers as much code as possible can be achieved by creating two stubs for the awkward repository dependencies, one that returns no active reservation, and one that finds an empty spot. Plus, the requested reservation period needs to be within operating hours and at least 30 minutes long. This results in the following code:
I intentionally override the toString methods of the two repository stubs and return exactly what their purpose is, withoutActiveReservation and findSpot because it makes it easy to see in the output what test case was covered:
If we use IntelliJ, we can now simply press the » button in the middle of the screen and add the new parts to the approved.txt file.
Now, the contents are identical, and if we run the test again, it becomes green and the received.txt file disappears.
Let’s also investigate the test coverage now (see the other article to learn about viewing test coverage):
With this 1 test alone, we covered already 74% of all lines of code. By exploring the code first and choosing the input parameters reasonably, we can quickly cover a large portion of the code.
Automatically Testing Parameter Combinations
If we now add 2 additional parameters, for example two repository stubs for “withActiveReservation” and “findNoSpot”, we get the following output:
Now we got 4 (1*2*2) input combinations because the library recombines them all.
How convenient. In manual approval tests, we would have had to write a test for each combination or use a parameterised test, but even then we would not have received an output that is that easy to update like with received.txt and approved.txt.
Quickly Covering Lots of Ground
Let’s try to cover all the branches. With 4 requests, 2 reservation-, and 2 spot repo stubs, we can create 4*2*2 = 16 combinations in 10 lines of code.
By using this framework, we can be sure not to miss combinations like we could with manual approval tests. Even though at the moment some cases may not be relevant, as soon as we start refactoring, we could create code that could
Handling Issues with Line Breaks
Depending on the IDE used, it may happen that even though the result of the approval tests didn’t change, the IDE appends a whitespace after each line. In IntelliJ, this can be avoided by using the following line in the .editorconfig:
trim_trailing_whitespace = false

Example
See the full example in the following video:
Conclusion
I have demonstrated how CombinationApproval can fix a method’s behaviour quickly in place, much faster than doing it manually using manual approval tests, while simultaneously combining all the parameters to create more test cases.
With these tests in place, we can be more confident when we make changes to the code that it will not break.
Another important technique that can help us finding uncovered combinations is called Mutation Testing. In a next article, I will explore what it is and how to apply it, and how it can be used in Legacy applications to improve confidence in test coverage even more.
Resources
To get rid of the yellow test name, you can change the test name pattern in IntelliJ to
[A-Z][A-Za-z\d]*(Test(s|Case)?|Should|TestShould)|Test[A-Z][A-Za-z\d]*|IT(.*)|(.*)IT(Case)?
in the following menu:
To effectively refactor Legacy code, we need to have a specific goal towards we want to move our design. Our O’Reilly course “DDD, EventStorming, and Clean Architecture” can help with that, as it introduces Domain-Driven Design and how to implement Aggregates in Hexagonal and Clean Architecture.
If you want to learn how to effectively separate concerns in Legacy code and move your application towards a better design, you could also have a look at our on-premise, remote or hybrid workshops Modern Software Architecture Design Patterns and Untangle Your Legacy Code with Domain-Driven Refactoring, where we dive into the presented topics in much greater detail.
To effectively learn how to identify code smells and refactor them, check out our workshop on Clean Code.