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):

  1public class ServiceAnnotationTest {
  2
  3    private static final String PACKAGE_PATH_SEPARATOR = ".";
  4
  5    /*
  6     * A string which is used to identify getter methods. All methods whose name contains the given string
  7     * are considered as getter methods.
  8     */
  9    private static final String GETTER_METHOD_NAME_ID = "get";
 10    private static final String FILE_PATH_SEPARATOR = System.getProperty("file.separator");
 11
 12    /*
 13     * The file path to the root folder of service package. If the absolute path to the service package
 14     * is /users/foo/classes/com/bar/service and the classpath base directory is /users/foo/classes,
 15     * the value of this constant must be /com/bar/service.
 16     */
 17    private static final String SERVICE_BASE_PACKAGE_PATH = "/com/bar/service";
 18
 19    /*
 20     * A string which is used to identify setter methods. All methods whose name contains the given string
 21     * are considered as setter methods.
 22     */
 23    private static final String SETTER_METHOD_NAME_ID = "set";
 24
 25    /*
 26     * A string which is used to identify the test classes. All classes whose name contains the given string
 27     * are considered as test classes.
 28     */
 29    private static final String TEST_CLASS_FILENAME_ID = "Test";
 30
 31    private List<Class> serviceClasses;
 32
 33    /**
 34     * Iterates through all the classes found under the service base package path (and its sub directories)
 35     * and inserts all service classes to the serviceClasses array.
 36     *
 37     * @throws IOException
 38     * @throws ClassNotFoundException
 39     */
 40    @Before
 41    public void findServiceClasses() throws IOException, ClassNotFoundException {
 42        serviceClasses = new ArrayList<Class>();
 43        PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
 44        Resource[] resources = resolver.getResources("classpath*:" + SERVICE_BASE_PACKAGE_PATH + "/**/*.class");
 45        for (Resource resource : resources) {
 46            if (isNotTestClass(resource)) {
 47                String serviceClassCandidateNameWithPackage = parseClassNameWithPackage(resource);
 48                ClassLoader classLoader = resolver.getClassLoader();
 49                Class serviceClassCandidate = classLoader.loadClass(serviceClassCandidateNameWithPackage);
 50                if (isNotInterface(serviceClassCandidate)) {
 51                    if (isNotException(serviceClassCandidate)) {
 52                        if (isNotEnum(serviceClassCandidate)) {
 53                            if (isNotAnonymousClass(serviceClassCandidate)) {
 54                                serviceClasses.add(serviceClassCandidate);
 55                            }
 56                        }
 57                    }
 58                }
 59            }
 60        }
 61    }
 62
 63    /**
 64     * Checks if the resource given a as parameter is a test class. This method returns
 65     * true if the resource is not a test class and false otherwise.
 66     *
 67     * @param resource
 68     * @return
 69     */
 70    private boolean isNotTestClass(Resource resource) {
 71        return !resource.getFilename().contains(TEST_CLASS_FILENAME_ID);
 72    }
 73
 74    /**
 75     * Checks if the resource given as a parameter is an exception class. This method returns true
 76     * if the class is not an exception class and false otherwise.
 77     *
 78     * @param exceptionCanditate
 79     * @return
 80     */
 81    private boolean isNotException(Class exceptionCanditate) {
 82        return !Exception.class.isAssignableFrom(exceptionCanditate) &&
 83                !RuntimeException.class.isAssignableFrom(exceptionCanditate) &&
 84                !Throwable.class.isAssignableFrom(exceptionCanditate);
 85    }
 86
 87    /**
 88     * Parses a class name from the absolute path of the resource given as a parameter
 89     * and returns the parsed class name. E.g. if the absolute path of the resource is
 90     * /user/foo/classes/com/foo/Bar.class, this method returns com.foo.Bar.
 91     *
 92     * @param resource
 93     * @return
 94     * @throws IOException
 95     */
 96    private String parseClassNameWithPackage(Resource resource) throws IOException {
 97        String pathFromClasspathRoot = parsePathFromClassPathRoot(resource.getFile().getAbsolutePath());
 98        String pathWithoutFilenameSuffix = parsePathWithoutFilenameSuffix(pathFromClasspathRoot);
 99        return buildClassNameFromPath(pathWithoutFilenameSuffix);
100    }
101
102    /**
103     * Parses the path which starts from the classpath root directory by using the
104     * absolute path given as a parameter. Returns the parsed path.
105     * E.g. If the absolute path is /user/foo/classes/com/foo/Bar.class and the classpath
106     * root directory is /user/foo/classes/, com/foo/Bar.class is returned.
107     *
108     * @param absolutePath
109     * @return
110     */
111    private String parsePathFromClassPathRoot(String absolutePath) {
112        int classpathRootIndex = absolutePath.indexOf(SERVICE_BASE_PACKAGE_PATH);
113        return absolutePath.substring(classpathRootIndex + 1);
114    }
115
116    /**
117     * Removes the file suffix from the path given as a parameter and returns new path
118     * without the suffix. E.g. If path is com/foo/Bar.class, com/foo/Bar is returned.
119     *
120     * @param path
121     * @return
122     */
123    private String parsePathWithoutFilenameSuffix(String path) {
124        int prefixIndex = path.indexOf(PACKAGE_PATH_SEPARATOR);
125        return path.substring(0, prefixIndex);
126    }
127
128    /**
129     * Builds a class name with package information from a path given as a parameter and
130     * returns the class name with package information. e.g. If a path com/foo/Bar is given
131     * as a parameter, com.foo.Bar is returned.
132     *
133     * @param path
134     * @return
135     */
136    private String buildClassNameFromPath(String path) {
137        return path.replace(FILE_PATH_SEPARATOR, PACKAGE_PATH_SEPARATOR);
138    }
139
140    /**
141     * Checks if the class given as an argument is an interface or not.
142     * Returns false if the class is not an interface and true otherwise.
143     *
144     * @param interfaceCanditate
145     * @return
146     */
147    private boolean isNotInterface(Class interfaceCanditate) {
148        return !interfaceCanditate.isInterface();
149    }
150
151    /**
152     * Checks if the class given as an argument is an Enum or not.
153     * Returns false if the class is not Enum and true otherwise.
154     *
155     * @param enumCanditate
156     * @return
157     */
158    private boolean isNotEnum(Class enumCanditate) {
159        return !enumCanditate.isEnum();
160    }
161
162    /**
163     * Checks if the class given as a parameter is an anonymous class.
164     * Returns true if the class is not an anonymous class and false otherwise.
165     *
166     * @param anonymousClassCanditate
167     * @return
168     */
169    private boolean isNotAnonymousClass(Class anonymousClassCanditate) {
170        return !anonymousClassCanditate.isAnonymousClass();
171    }
172
173    /**
174     * Verifies that each method which is declared in a service class and which is not a
175     * getter or setter method is annotated with Transactional annotation. This test
176     * also ensures that the rollbackFor property of Transactional annotation specifies
177     * all checked exceptions which are thrown by the service method.
178     */
179    @Test
180    public void eachServiceMethodHasTransactionalAnnotation() {
181        for (Class serviceClass : serviceClasses) {
182            Method[] serviceMethods = serviceClass.getMethods();
183            for (Method serviceMethod : serviceMethods) {
184                if (isMethodDeclaredInServiceClass(serviceMethod, serviceClass)) {
185                    if (isNotGetterOrSetterMethod(serviceMethod)) {
186                        boolean transactionalAnnotationFound = serviceMethod.isAnnotationPresent(Transactional.class);
187                        assertTrue("Method " + serviceMethod.getName() + " of " + serviceClass.getName() + " class must be annotated with @Transactional annotation.", transactionalAnnotationFound);
188                        if (transactionalAnnotationFound) {
189                            if (methodThrowsCheckedExceptions(serviceMethod)) {
190                                boolean rollbackPropertySetCorrectly = rollbackForPropertySetCorrectlyForTransactionalAnnotation(serviceMethod.getAnnotation(Transactional.class), serviceMethod.getExceptionTypes());
191                                assertTrue("Method " + serviceMethod.getName() + "() of " + serviceClass.getName() + " class must set rollbackFor property of Transactional annotation correctly", rollbackPropertySetCorrectly);
192                            }
193                        }
194                    }
195                }
196            }
197        }
198    }
199
200    /**
201     * Checks that the method given as a parameter is declared in a service class given as
202     * a parameter. Returns true if the method is declated in service class and false
203     * otherwise.
204     *
205     * @param method
206     * @param serviceClass
207     * @return
208     */
209    private boolean isMethodDeclaredInServiceClass(Method method, Class serviceClass) {
210        return method.getDeclaringClass().equals(serviceClass);
211    }
212
213    /**
214     * Checks if the method given as parameter is a getter or setter method. Returns true
215     * if the method is a getter or setter method an false otherwise.
216     *
217     * @param method
218     * @return
219     */
220    private boolean isNotGetterOrSetterMethod(Method method) {
221        return !method.getName().contains(SETTER_METHOD_NAME_ID) && !method.getName().contains(GETTER_METHOD_NAME_ID);
222    }
223
224    /**
225     * Checks if the method given as a parameter throws checked exceptions. Returns true
226     * if the method throws checked exceptions and false otherwise.
227     *
228     * @param method
229     * @return
230     */
231    private boolean methodThrowsCheckedExceptions(Method method) {
232        return method.getExceptionTypes().length > 0;
233    }
234
235    /**
236     * Checks if the transactional annotation given as a parameter specifies all checked exceptions
237     * given as a parameter as a value of rollbackFor property. Returns true if all exceptions
238     * are specified and false otherwise.
239     *
240     * @param annotation
241     * @param thrownExceptions
242     * @return
243     */
244    private boolean rollbackForPropertySetCorrectlyForTransactionalAnnotation(Annotation annotation, Class<?>[] thrownExceptions) {
245        boolean rollbackForSet = true;
246
247        if (annotation instanceof Transactional) {
248            Transactional transactional = (Transactional) annotation;
249            List<Class<? extends Throwable>> rollbackForClasses = Arrays.asList(transactional.rollbackFor());
250            for (Class<?> thrownException : thrownExceptions) {
251                if (!rollbackForClasses.contains(thrownException)) {
252                    rollbackForSet = false;
253                    break;
254                }
255            }
256        }
257
258        return rollbackForSet;
259    }
260
261    /**
262     * Verifies that each service class is annotated with @Service annotation.
263     */
264    @Test
265    public void eachServiceClassIsAnnotatedWithServiceAnnotation() {
266        for (Class serviceClass : serviceClasses) {
267            assertTrue(serviceClass.getSimpleName() + " must be annotated with @Service annotation", serviceClass.isAnnotationPresent(Service.class));
268        }
269    }
270}

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.