Ensuring Test Coverage Using ArchUnit
A simple approach to method coverage in Spring Boot through naming conventions

Search for a command to run...
A simple approach to method coverage in Spring Boot through naming conventions

No comments yet. Be the first to comment.
TL;DR This post explains how iOS Universal Links work and what is required to implement them. It covers how to handle incoming links in an iOS app, how to create and configure the Apple App Site Association (AASA) file, and how to correctly host the ...

TL;DR The Page Object Model (POM) is the industry standard, but it is not easily scalable. The Flow Model Pattern improves upon the basic POM, offering a perfectly balanced solution that is not overly complex yet still scalable. The Screenplay Patter...

Preface In a previous article from the start of this year, I mentioned various frameworks that can be used for mobile test automation. The one that came up repeatedly was WebdriverIO, which I have been using actively for over two years now. It is a s...

Let’s be honest - who wakes up and and is excited to read button labels or error messages? But have you ever clicked a vague “Next” button and immediately regretted it because you had no idea where it was taking you? Or stared at an error message lik...

Part 1 - An Introduction to the matter What will this series be about? During this series of blog posts, I will do my best to provide my opinion on specific models, determine which use case each one fits, discuss the pros and cons of using that model...

In a previous article, we've seen an introduction to what ArchUnit can do in terms of imposing coding standards. This is great. Not only is your code safe when being refactored by having tests, but with the help of ArchUnit, architectural decisions are easy to impose and maintain on a test level, with pressure being let off of the code review process.
Another detail that is also usually verified within the code review process is the writing of said tests. Often, a comment like "needs more tests" might pop up, sending the code back to the developer. As we'll see, along with ensuring architectural unity, through some nifty usage of ArchUnit and naming conventions, test coverage can also be ensured on a test level.
Say the first rule we want to impose is that there is a test class created for all our controllers, services, and repositories. ArchUnit makes writing this very easy, giving us just a bit of work to do implementing a custom condition.
@ArchTest
static final ArchRule ensureTestClasses =
classes()
.that()
.areAnnotatedWith(RestController.class)
.or()
.areAnnotatedWith(Service.class)
.should(haveTheirEquivalentTestClass());
The only method that we need to write is haveTheirEquivalentTestClass().
private static ArchCondition<? super JavaClass> haveTheirEquivalentTestClass() {
return new ArchCondition<>("have associated test classes") {
@Override
public void check(JavaClass item, ConditionEvents events) {
final String className = item.getSimpleName();
final boolean hasTestClass = getEquivalentTestClasses(item).size() > 0;
if (!hasTestClass) {
events.add(
new SimpleConditionEvent(
item, false, "%s does not have a test class".formatted(className)));
}
}
};
}
The ArchCondition class is used to check the required conditions, its check() method accepting a JavaClass parameter (our controllers, services, or repositories) and ConditionEvents which is going to be used to pass any violations.
The getEquivalentTestClasses(item) method will fetch all test classes associated with the one passed as a parameter following the next conventions:
it has to be within the same package
it has to have the form ClassNameTest or ClassNameIntegrationTest
Of course, different conventions can be implemented here depending on the needs and context of the project. This indirectly ensures a naming convention consistency for test classes which is just an added benefit.
As you might have noticed, any references to Repository beans are missing. We will tackle them in the next section where I show how to impose a percentage of methods coverage for any particular type of beans and thus will be writing one more method.
Simply put, we may want to enforce an x% test coverage for any or all of our classes. Let's assume we enforce a 100% method test coverage for our repositories. What does this mean? First of all, we ensure that all our repositories have an associated test class (explained in the previous section), and then, we ensure that all declared methods have an associated method with the same name (or following some other convention) in any of those test classes.
public static final double MIN_COVERAGE_REPOS = 1.0;
@ArchTest
static final ArchRule fullMethodTestCoverageForRepositories =
classes()
.that()
.areMetaAnnotatedWith(Repository.class)
.should(haveTheirEquivalentTestClass())
.andShould(havePercentMethodCoverage(MIN_COV_REPOS));
So how should the havePercentMethodCoverage(MIN_COV_REPOS) work?
It should take all public methods from the evaluated class, all public methods from the test classes associated with it, subtract the ones from the test class from the ones found in the evaluated class, and based on the percentage passed in the method, decide on whether or not to add a SimpleConditionEvent alert.
After running the tests, assuming a @Service class with 3 public methods, 1 private one, but only 2 of those public methods having their equivalent in the test class, the message you should expect to receive running the ArchUnit tests will look like this:

(assuming anything above 0.667 is passed as minimum test coverage in the parameter)
A final touch that can be added are excluded classes. Say that for whatever reason, some classes need to be excluded from these checks. This is very easy to do. All one needs is an array of class names that will be checked before any of these custom ArchUnit methods do any checks.
private static final String[] excludedClasses = {
"ExcludedService",
};
...
public void check(JavaClass clasz, ConditionEvents events) {
final String className = clasz.getSimpleName();
if (Arrays.asList(excludedClasses).contains(className)) {
return;
}
...
In the end, havePercentMethodCoverage() method will look like this
private static ArchCondition<? super JavaClass> havePercentMethodCoverage(double coverage) {
return new ArchCondition<>("have most their methods covered") {
@Override
public void check(JavaClass clasz, ConditionEvents events) {
final String className = clasz.getSimpleName();
if (Arrays.asList(excludedClasses).contains(className)) {
return;
}
final List<JavaClass> equivalentTestClasses = getEquivalentTestClasses(clasz);
if (!equivalentTestClasses.isEmpty()) {
final List<String> classMethods =
getPublicNonStaticMethodsOfClass(clasz)
.stream().map(JavaMember::getName)
.toList();
final Set<String> testMethods =
equivalentTestClasses.stream()
.map(CoverageTests::getPublicNonStaticMethodsOfClass)
.flatMap(List::stream)
.map(JavaMember::getName)
.collect(Collectors.toSet());
final List<String> missingMethods =
classMethods.stream()
.filter(method -> !testMethods.contains(method))
.collect(Collectors.toList());
final int claszMethodsSize = classMethods.size();
final double percentMissing = (double) missingMethods.size() / claszMethodsSize;
if (1 - percentMissing < coverage) {
events.add(
new SimpleConditionEvent(
clasz, false,
"%s has just %.2f%% (%d/%d) of its methods covered [missing: %s]".formatted(className, (1 - percentMissing) * 100,(claszMethodsSize - missingMethods.size()), claszMethodsSize,String.join(", ", missingMethods))));
}
}
}
};
}
There are obvious limitations to using something like this. Should any "sneaky developer" want to trick these conditions, they could simply create empty test methods and these checks will not know any better than to consider that they are covered. Oftentimes, however, seeing an empty test method more readily creates the want to fill it up. Furthermore, we assume good intent from the people we work together with, it being a necessary precondition for any team to function at peak performance.
This also does not cover anything in the way of cyclomatic complexity, branching methods, and, well, anything within those tests or equivalent methods. Just like in the previous article linked above in which we found an introduction to ArchUnit, these tests do not check substance, their purpose being of enforcing form.
All source code can be found in this repository.