Test for an Annotation

September 12, 2019

This test will make sure that a spring boot application has @Transactional on all the (non getter/setter) methods in a service. It looks for classes that end in ServiceImpl.java, in a specific package.

package com.company.test.global;

import static org.junit.Assert.assertTrue;

import java.io.IOException;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.stereotype.Service;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.transaction.annotation.Transactional;

@RunWith(SpringRunner.class)
@SpringBootTest
@ActiveProfiles("test")
public class ServiceTransactionalAnnotationT_e_s_t_s {
private static final String PACKAGE_PATH_SEPARATOR = ".";

/*
 * A string which is used to identify getter methods. All methods whose name
 * contains the given string are considered as getter methods.
 */
private static final String GETTER_METHOD_NAME_ID = "get";
private static final String FILE_PATH_SEPARATOR = System.getProperty("file.separator");

/*
 * The file path to the root folder of service package. If the absolute path to
 * the service package is /users/foo/classes/com/bar/service and the classpath
 * base directory is /users/foo/classes, the value of this constant must be
 * /com/bar/service.
 */
private static final String SERVICE_BASE_PACKAGE_PATH = "/com/company/service";

/*
 * A string which is used to identify setter methods. All methods whose name
 * contains the given string are considered as setter methods.
 */
private static final String SETTER_METHOD_NAME_ID = "set";

/*
 * A string which is used to identify the test classes. All classes whose name
 * contains the given string are considered as test classes.
 */
private static final String TEST_CLASS_FILENAME_ID = "Test";

private List<Class> serviceClasses;

/**
 * Iterates through all the classes found under the service base package path
 * (and its sub directories) and inserts all service classes to the
 * serviceClasses array.
 *
 * @throws IOException
 * @throws ClassNotFoundException
 */
@Before
public void findServiceClasses() throws IOException, ClassNotFoundException {
    this.serviceClasses = new ArrayList<Class>();
    final PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
    final Resource[] resources = resolver.getResources("classpath*:" + SERVICE_BASE_PACKAGE_PATH + "/**/*ServiceImpl.class");
    for (final Resource resource : resources) {
        if (this.isNotTestClass(resource)) {
            final String serviceClassCandidateNameWithPackage = this.parseClassNameWithPackage(resource);
            final ClassLoader classLoader = resolver.getClassLoader();
            final Class serviceClassCandidate = classLoader.loadClass(serviceClassCandidateNameWithPackage);
            if (this.isNotInterface(serviceClassCandidate)) {
                if (this.isNotException(serviceClassCandidate)) {
                    if (this.isNotEnum(serviceClassCandidate)) {
                        if (this.isNotAnonymousClass(serviceClassCandidate)) {
                            this.serviceClasses.add(serviceClassCandidate);
                        }
                    }
                }
            }
        }
    }
}

/**
 * Checks if the resource given a as parameter is a test class. This method
 * returns true if the resource is not a test class and false otherwise.
 *
 * @param resource
 * @return
 */
private boolean isNotTestClass(Resource resource) {
    return !resource.getFilename().contains(TEST_CLASS_FILENAME_ID);
}

/**
 * Checks if the resource given as a parameter is an exception class. This
 * method returns true if the class is not an exception class and false
 * otherwise.
 *
 * @param exceptionCandidate
 * @return
 */
private boolean isNotException(Class exceptionCandidate) {
    return !Exception.class.isAssignableFrom(exceptionCandidate) && !RuntimeException.class.isAssignableFrom(exceptionCandidate) && !Throwable.class.isAssignableFrom(exceptionCandidate);
}

/**
 * Parses a class name from the absolute path of the resource given as a
 * parameter and returns the parsed class name. E.g. if the absolute path of the
 * resource is /user/foo/classes/com/foo/Bar.class, this method returns
 * com.foo.Bar.
 *
 * @param resource
 * @return
 * @throws IOException
 */
private String parseClassNameWithPackage(Resource resource) throws IOException {
    final String pathFromClasspathRoot = this.parsePathFromClassPathRoot(resource.getFile().getAbsolutePath());
    final String pathWithoutFilenameSuffix = this.parsePathWithoutFilenameSuffix(pathFromClasspathRoot);
    final String returnValue = this.buildClassNameFromPath(pathWithoutFilenameSuffix);
    return returnValue;
}

/**
 * Parses the path which starts from the classpath root directory by using the
 * absolute path given as a parameter. Returns the parsed path. E.g. If the
 * absolute path is /user/foo/classes/com/foo/Bar.class and the classpath root
 * directory is /user/foo/classes/, com/foo/Bar.class is returned.
 *
 * @param absolutePath
 * @return
 */
private String parsePathFromClassPathRoot(String absolutePath) {
    final int classpathRootIndex = absolutePath.indexOf(SERVICE_BASE_PACKAGE_PATH);
    return absolutePath.substring(classpathRootIndex + 1);
}

/**
 * Removes the file suffix from the path given as a parameter and returns new
 * path without the suffix. E.g. If path is com/foo/Bar.class, com/foo/Bar is
 * returned.
 *
 * @param path
 * @return
 */
private String parsePathWithoutFilenameSuffix(String path) {
    final int prefixIndex = path.lastIndexOf(PACKAGE_PATH_SEPARATOR);
    return path.substring(0, prefixIndex);
}

/**
 * Builds a class name with package information from a path given as a parameter
 * and returns the class name with package information. e.g. If a path
 * com/foo/Bar is given as a parameter, com.foo.Bar is returned.
 *
 * @param path
 * @return
 */
private String buildClassNameFromPath(String path) {
    String returnValue = path.replace(FILE_PATH_SEPARATOR, PACKAGE_PATH_SEPARATOR);
            // bit of a hack to find the right part of the path
    returnValue = returnValue.substring(returnValue.indexOf("com."));
    return returnValue;
}

/**
 * Checks if the class given as an argument is an interface or not. Returns
 * false if the class is not an interface and true otherwise.
 *
 * @param interfaceCanditate
 * @return
 */
private boolean isNotInterface(Class interfaceCanditate) {
    return !interfaceCanditate.isInterface();
}

/**
 * Checks if the class given as an argument is an Enum or not. Returns false if
 * the class is not Enum and true otherwise.
 *
 * @param enumCanditate
 * @return
 */
private boolean isNotEnum(Class enumCanditate) {
    return !enumCanditate.isEnum();
}

/**
 * Checks if the class given as a parameter is an anonymous class. Returns true
 * if the class is not an anonymous class and false otherwise.
 *
 * @param anonymousClassCanditate
 * @return
 */
private boolean isNotAnonymousClass(Class anonymousClassCanditate) {
    return !anonymousClassCanditate.isAnonymousClass();
}

/**
 * Verifies that each method which is declared in a service class and which is
 * not a getter or setter method is annotated with Transactional annotation.
 * This test also ensures that the rollbackFor property of Transactional
 * annotation specifies all checked exceptions which are thrown by the service
 * method.
 */
@Test
public void eachServiceMethodHasTransactionalAnnotation() {
    for (final Class serviceClass : this.serviceClasses) {
        final Method[] serviceMethods = serviceClass.getMethods();
        for (final Method serviceMethod : serviceMethods) {
            if (this.isMethodDeclaredInServiceClass(serviceMethod, serviceClass)) {
                if (this.isNotGetterOrSetterMethod(serviceMethod)) {
                    final boolean transactionalAnnotationFound = serviceMethod.isAnnotationPresent(Transactional.class);
                    assertTrue("Method " + serviceMethod.getName() + " of " + serviceClass.getName() + " class must be annotated with @Transactional annotation.", transactionalAnnotationFound);
                    if (transactionalAnnotationFound) {
                        if (this.methodThrowsCheckedExceptions(serviceMethod)) {
                            final boolean rollbackPropertySetCorrectly = this.rollbackForPropertySetCorrectlyForTransactionalAnnotation(serviceMethod.getAnnotation(Transactional.class), serviceMethod.getExceptionTypes());
                            assertTrue("Method " + serviceMethod.getName() + "() of " + serviceClass.getName() + " class must set rollbackFor property of Transactional annotation correctly", rollbackPropertySetCorrectly);
                        }
                    }
                }
            }
        }
    }
}

/**
 * Checks that the method given as a parameter is declared in a service class
 * given as a parameter. Returns true if the method is declated in service class
 * and false otherwise.
 *
 * @param method
 * @param serviceClass
 * @return
 */
private boolean isMethodDeclaredInServiceClass(Method method, Class serviceClass) {
    return method.getDeclaringClass().equals(serviceClass);
}

/**
 * Checks if the method given as parameter is a getter or setter method. Returns
 * true if the method is a getter or setter method an false otherwise.
 *
 * @param method
 * @return
 */
private boolean isNotGetterOrSetterMethod(Method method) {
    return !method.getName().contains(SETTER_METHOD_NAME_ID) && !method.getName().contains(GETTER_METHOD_NAME_ID);
}

/**
 * Checks if the method given as a parameter throws checked exceptions. Returns
 * true if the method throws checked exceptions and false otherwise.
 *
 * @param method
 * @return
 */
private boolean methodThrowsCheckedExceptions(Method method) {
    return method.getExceptionTypes().length > 0;
}

/**
 * Checks if the transactional annotation given as a parameter specifies all
 * checked exceptions given as a parameter as a value of rollbackFor property.
 * Returns true if all exceptions are specified and false otherwise.
 *
 * @param annotation
 * @param thrownExceptions
 * @return
 */
private boolean rollbackForPropertySetCorrectlyForTransactionalAnnotation(Annotation annotation, Class<?>[] thrownExceptions) {
    boolean rollbackForSet = true;

    if (annotation instanceof Transactional) {
        final Transactional transactional = (Transactional) annotation;
        final List<Class<? extends Throwable>> rollbackForClasses = Arrays.asList(transactional.rollbackFor());
        for (final Class<?> thrownException : thrownExceptions) {
            if (!rollbackForClasses.contains(thrownException)) {
                rollbackForSet = false;
                break;
            }
        }
    }

    return rollbackForSet;
}

/**
 * Verifies that each service class is annotated with @Service annotation.
 */
@Test
public void eachServiceClassIsAnnotatedWithServiceAnnotation() {
    for (final Class serviceClass : this.serviceClasses) {
        assertTrue(serviceClass.getSimpleName() + " must be annotated with @Service annotation", serviceClass.isAnnotationPresent(Service.class));
    }
}
}