Wednesday, September 11, 2024

Spring Boot Testing tips: Check integration test existence for REST Controllers

Code quality and unit/integration testing is extremely important in software development, especially when a software gets larger and larger and it can be challenging to maintain which service has tests.

In this short article I'd like to present a way how you can validate that all of your application's endpoint has an integration test, but of course the method can be applied to other layers(e.g.: service) of the system as well.

The best way to check this, we're going to implement a simple unit test in Java, using JUnit5, Mockito, AssertJ and Reflections libraries.

Step 1: Interfaces

Let's add the first interface that will be used for all REST controllers base.

public interface BaseController {
}


Next, we need an interface that we use for our intergation tests.

public interface BaseIntegrationTest {
}


Step 2: Sample REST controllers

Now let's implement some REST controller.

@RestController
public class SampleAController implements BaseController {

@GetMapping("/one")
public ResponseEntity<String> one() {
return ResponseEntity.ok("OK");
}
}

Step 3: Annotations

As a next step we need some annotations that we will use in the tests.

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
public @interface TestedClass {

Class<?> value();
boolean skip() default false;
}


@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface TestedMethod {

String value();
}


Let's add integration test for the first controller:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestedClass(SampleAController.class)
class SampleAIntegrationTest implements BaseIntegrationTest {

@LocalServerPort
private int port;

@Autowired
private RestTemplate restTemplate;

@Test
@TestedMethod("one")
void testOne() {
ResponseEntity<String> response = restTemplate             .getForEntity("http://localhost:"+port+"/one", String.class);

assertEquals("OK", response.getBody());
assertEquals(HttpStatus.OK, response.getStatusCode());
}
}


Now let's create the unit test:

public class IntegrationAnnotationCheckerTest {

private static final Set<String> IGNORED_METHODS = Set.of(
"$jacocoInit", "equals", "hashCode", "toString", "notify",             "notifyAll", "wait", "getClass", "finalize", "wait0", "clone"
);
private static final Map<String, TestClassData> integrationTests;

static {
try {
integrationTests = getAllIntegrationTestClasses();
} catch (Exception e) {
throw new RuntimeException(e);
}
}

@Test
void shouldControllerHaveProperIntegrationTests() throws Exception {
Map<String, ClassData> controllers = getAllControllerClasses();
controllers             .forEach(IntegrationAnnotationCheckerTest::assertController);
    }

@Getter
@Setter
static class ClassData {
private Set<String> methods = new HashSet<>();
}

@Getter
@Setter
static class TestClassData {
private String testClassName;
private Set<String> methods = new HashSet<>();
private boolean skip;
}

private static Map<String, ClassData> getAllControllerClasses()             throws Exception {
        Map<String, ClassData> resultMap = new HashMap<>();
Set<Class<?>> controllers = getAllSubClasses(BaseController.class);

for (Class<?> controller : controllers) {
ClassData classData = new ClassData();
Set<String> controllerMethods =                     Stream.of(controller.getDeclaredMethods())                     .map(Method::getName)                     .filter(name -> !name.startsWith("lambda$"))                     .collect(Collectors.toSet());

controllerMethods.addAll(Stream.of(controller.getSuperclass()                     .getDeclaredMethods())
                    .filter(method -> Modifier
                        .isPublic(method.getModifiers()))
.filter(method -> !IGNORED_METHODS                         .contains(method.getName()) && !method.getName()                             .contains("$"))
.map(Method::getName)
.collect(Collectors.toSet()));

classData.setMethods(controllerMethods);
resultMap.put(controller.getSimpleName(), classData);
}

return resultMap;
}

private static Map<String, TestClassData> getAllIntegrationTestClasses()
         throws Exception {
return getAllSpecificTestClasses();
}


private static Map<String, TestClassData> getAllSpecificTestClasses()
            throws Exception {
Map<String, TestClassData> resultMap = new HashMap<>();
Set<Class<?>> testClasses =
            getAllSubClasses(BaseIntegrationTest.class);

for (Class<?> test : testClasses) {
TestedClass testedClassAnnotation =
                test.getAnnotation(TestedClass.class);
assertNotNull("Annotation @TestedClass is missing from "
            + test.getSimpleName(), testedClassAnnotation);
Class<?> originalClass = testedClassAnnotation.value();
boolean skip = testedClassAnnotation.skip();

TestClassData classData = new TestClassData();
Set<String> testMethods = Stream.of(test.getDeclaredMethods())
.filter(method ->
                        method.getAnnotation(TestedMethod.class) != null)
.map(methods -> {
TestedMethod testedMethodAnnotation =
                            methods.getAnnotation(TestedMethod.class);
return testedMethodAnnotation.value();
})
.collect(Collectors.toSet());
classData.setTestClassName(test.getSimpleName());
classData.setMethods(testMethods);
classData.setSkip(skip);
resultMap.put(originalClass.getSimpleName(), classData);
}

return resultMap;
}

private static Set<Class<?>> getAllSubClasses(Class<?> inputClazz) {
Reflections reflections =             new Reflections("hu.peterszrnka.springboottests");
return reflections.getSubTypesOf(inputClazz)
            .stream()
            .filter(cls -> !Modifier.isAbstract(cls.getModifiers()))
            .collect(Collectors.toSet());
}

private static void assertController(
String key,
ClassData classData) {
assertTrue("Integration test is missing for " + key,
            integrationTests.containsKey(key));

assertEquals(key + " has some untested methods!",             postFilterMethods(                 classData.getMethods()), integrationTests.get(key)
            .getMethods());
}

private static Set<String> postFilterMethods(Set<String> methodNames) {
return methodNames.stream()
            .filter(name -> !name.contains("$"))
            .collect(Collectors.toSet());
}

private static String printUncoveredTestMethods(             Map<String, Set<String>> missingSecurityTestMethods) {
StringBuilder sb = new StringBuilder("\r\n");
missingSecurityTestMethods.forEach((k, v) -> {
sb.append(k).append(": ").append(v).append("\r\n");
});

return sb.toString();
}
}


If we run the test, it will pass. 



Now let's add a new endpoint:

@RestController
public class SampleBController implements BaseController {

@GetMapping("/two")
public ResponseEntity<String> two() {
return ResponseEntity.ok("NO");
}
}


If we run the IntegrationTestChecker test, it will fail:



It's a good practice if you work on a large application that is changed frequently.

The full source code available on GitHub:

https://github.com/peter-szrnka/spring-boot-integration-test-checker

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.

Configure and use VSCode for Java web development

Embarking on Java web development often starts with choosing the right tools that streamline the coding process while enhancing productivity...