Ensuring Test Coverage Using ArchUnit
A simple approach to method coverage in Spring Boot through naming conventions
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.
Ensuring classes have their equivalent test classes
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.
Percentage of methods test coverage
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))));
}
}
}
};
}
Caveats
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.