Testing that All Service Methods Are Annotated with @Transactional Annotation

A common method of setting transaction boundaries in Spring Framework is to use its annotation driven transaction management and annotate service methods with @Transactional annotation. Seems pretty simple, right? Yes and no. Even though the annotation driven transaction management of Spring Framework is easy to setup and use, there are a few things which you must remember to do:

  • You must remember to annotate each service method with @Transactional annotation. This might seems a like an easy task but since you are probably a human being, you are also capable of making mistakes. A mistake like this might leave the database of your application in an inconsistent state if something goes wrong while your application is writing information to the database.
  • If you want to rollback the transaction when a service method throws a checked exception, you must specify the thrown checked exception class as a value of the rollbackFor property of the @Transactional annotation. This is needed because by default the Spring Framework will not rollback the transaction when a checked exception is thrown. If the rollbackFor attribute is of the @Transactional annotation is not set and a checked exception is thrown when your application is writing information to the database, the database of your application might end up in an inconsistent state.

Luckily is is quite easy to implement a test which ensures that

  1. Each method of a service class except getters and setters are annotated with @Transactional annotation.
  2. Each checked exception which is thrown by a service method is set as a value of the rollbackFor property of the @Transactional annotation.
  3. As a bonus, this test will also check that each service class is annotated with @Service annotation.
I recommend that you don't follow the advice given in this blog post. Instead, you should take a look at a tool called: ArchUnit.

I will describe next how you can write a unit test which verifies that both of the conditions given above are true by using JUnit and PathMatchingResourcePatternResolver class provided by Spring Framework. The source code of that unit test is given in following (The package declaration and the import statements are left out for the sake of readability):

public class ServiceAnnotationTest {

    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/bar/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 {
        serviceClasses = new ArrayList<Class>();
        PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
        Resource[] resources = resolver.getResources("classpath*:" + SERVICE_BASE_PACKAGE_PATH + "/**/*.class");
        for (Resource resource : resources) {
            if (isNotTestClass(resource)) {
                String serviceClassCandidateNameWithPackage = parseClassNameWithPackage(resource);
                ClassLoader classLoader = resolver.getClassLoader();
                Class serviceClassCandidate = classLoader.loadClass(serviceClassCandidateNameWithPackage);
                if (isNotInterface(serviceClassCandidate)) {
                    if (isNotException(serviceClassCandidate)) {
                        if (isNotEnum(serviceClassCandidate)) {
                            if (isNotAnonymousClass(serviceClassCandidate)) {
                                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 exceptionCanditate
     * @return
     */
    private boolean isNotException(Class exceptionCanditate) {
        return !Exception.class.isAssignableFrom(exceptionCanditate) &&
                !RuntimeException.class.isAssignableFrom(exceptionCanditate) &&
                !Throwable.class.isAssignableFrom(exceptionCanditate);
    }

    /**
     * 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 {
        String pathFromClasspathRoot = parsePathFromClassPathRoot(resource.getFile().getAbsolutePath());
        String pathWithoutFilenameSuffix = parsePathWithoutFilenameSuffix(pathFromClasspathRoot);
        return buildClassNameFromPath(pathWithoutFilenameSuffix);
    }

    /**
     * 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) {
        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) {
        int prefixIndex = path.indexOf(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) {
        return path.replace(FILE_PATH_SEPARATOR, PACKAGE_PATH_SEPARATOR);
    }

    /**
     * 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 (Class serviceClass : serviceClasses) {
            Method[] serviceMethods = serviceClass.getMethods();
            for (Method serviceMethod : serviceMethods) {
                if (isMethodDeclaredInServiceClass(serviceMethod, serviceClass)) {
                    if (isNotGetterOrSetterMethod(serviceMethod)) {
                        boolean transactionalAnnotationFound = serviceMethod.isAnnotationPresent(Transactional.class);
                        assertTrue("Method " + serviceMethod.getName() + " of " + serviceClass.getName() + " class must be annotated with @Transactional annotation.", transactionalAnnotationFound);
                        if (transactionalAnnotationFound) {
                            if (methodThrowsCheckedExceptions(serviceMethod)) {
                                boolean rollbackPropertySetCorrectly = 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) {
            Transactional transactional = (Transactional) annotation;
            List<Class<? extends Throwable>> rollbackForClasses = Arrays.asList(transactional.rollbackFor());
            for (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 (Class serviceClass : serviceClasses) {
            assertTrue(serviceClass.getSimpleName() + " must be annotated with @Service annotation", serviceClass.isAnnotationPresent(Service.class));
        }
    }
}

I have now described to you how you can write a unit test which ensures that requirements given before the code example are met. However, the solution which I introduced to you is not yet "perfect". My example has following limitations:

  • It checks all classes which are found from the service package or from its sub packages. It is possible that you might want to exclude some classes found from the service packages or exclude some methods of the included classes.
  • It expects that the transaction will be rolled back if a checked exception is thrown by a service method annotated with @Transactional annotation. In reality, you might not want to roll back the transaction for each thrown checked exception. If this is the case, the test should ensure that each thrown checked exception class is given either as a value of rollbackFor or as a value of noRollbackFor property of @Transactional annotation (Check the Spring reference manual for more details).

However, these improvements are left as an exercise for the reader.

6 comments… add one
  • Antti K Oct 4, 2011 @ 7:14

    Great stuff. One easy way to ensure that all service methods are transactional is to use a base calss for service implementations and annotate the base class with default @Transactional annotation.

    • Petri Oct 23, 2011 @ 13:03

      Antti,

      that is indeed an easy way to do it if you can accept the fact that each method of the actual service classes will be transactional.

  • Anonymous... Nov 20, 2019 @ 10:51

    Just for reference, now-days one can use archunit to do such checks. see https://github.com/TNG/ArchUnit (see it in action in this video https://www.youtube.com/watch?v=a9dF7fnArq0&t=1655 )
    PS: Thanks for a lot of content, which is relevant...

    • Petri Nov 22, 2019 @ 20:45

      Thanks for the tip. I updated this blog post and mentioned that nowadays one should use ArchUnit for this purpose.

  • Eaton Jul 5, 2023 @ 14:24

    Wouldn't the point of a test be to verify behaviour rather than couple the code to an annotation?
    This seems quite counter intuitive.

    • Petri Jul 5, 2023 @ 15:17

      You are right. It's indeed better to verify the behaviour of your code. However, I want point out two things:

      • This blog post was published at September 16, 2011. Back then we were using Spring Framework 3.1 and we didn't have access to the testing tools we have today. Also, writing automated tests wasn't as common as it is today. In fact, back in 2011 you were lucky if you were able to convince other developers to write unit tests. That's why sometimes we had to be creative and use techniques which are outdated and shouldn't be used today.
      • I have noticed that static analysis will help you to reduce "stupid" mistakes that happen for multiple different reasons, and trust me, everyone is making these mistakes. I am not saying that you should add tests like this to your test suite, but if you can get it for free, you should consider using it.

Leave a Reply