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
- Each method of a service class except getters and setters are annotated with @Transactional annotation.
- Each checked exception which is thrown by a service method is set as a value of the rollbackFor property of the @Transactional annotation.
- As a bonus, this test will also check that each service class is annotated with @Service annotation.
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.