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.