I recently started to work on an Open Source SIP project called OpenMRS, and realised that most (if not all) of the unit tests are actually integration tests. The way the OpenMRS code (and test) base uses the Spring IoC is I believe a large part of the problem. Although I am not a big fan of DI, I think there is value in using the Spring IoC in some cases. But there are some pitfalls to avoid, and good practices to follow.
The OBFA Factor
Nowadays, spring contexts seem to be so embedded in Java applications that developers forget that they are meant to be treated parsely, and with caution. One good example of this symptom is the one-big-fat-a*** (OBFA) applicationContext
file for the entire stack. Having an OBFA spring context file makes the customisation of parts of it more difficult and dangerous.
Instead, it is preferable to split the contexts into smaller files, so that a layer, or a part of the stack, can be independently reimplemented or reconfigured. As an example, a sample Java app (cashman) I keep for demo purpose has:
spring-appcontext.xml
Root Spring context file e.g.
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-2.5.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-3.1.xsd"> <import resource="classpath:spring-datasource.xml"/> <import resource="classpath:spring-txmanager.xml"/> <import resource="classpath:spring-hibernate.xml"/> <import resource="classpath:spring-dao.xml"/> <import resource="classpath:spring-cashmachine.xml"/> </beans>
spring-cashmachine.xml
Domain Service config e.g.
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-3.1.xsd"> <bean name="cashMachine" factory-method="getInstance"> <property name="stockDAO" ref="stockDAO" /> </bean> </beans>
spring-dao.xml
DAO config e.g.
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:context="http://www.springframework.org/schema/context" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-3.1.xsd"> <bean id="stockDAO"> <property name="sessionFactory" ref="sessionFactory"/> </bean> </beans>
spring-datasource.xml
Database connection config e.g.
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd"> <bean id="dataSource" destroy-method="close"> <property name="driverClassName" value="org.hsqldb.jdbcDriver"/> <property name="url" value="jdbc:hsqldb:mem:spring-playground"/> <property name="username" value="sa"/> <property name="password" value=""/> </bean> </beans>
spring-hibernate.xml
Hibernate config e.g.
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd"> <bean id="sessionFactory"> <property name="dataSource" ref="dataSource" /> <property name="annotatedClasses"> <list> <value>com.suncorp.cashman.persistence.StockItem</value> </list> </property> <property name="hibernateProperties"> <props> <prop key="hibernate.dialect"> org.hibernate.dialect.HSQLDialect</prop> <prop key="hibernate.show_sql">true</prop> <prop key="hibernate.hbm2ddl.auto">create</prop> </props> </property> </bean> </beans>
spring-txmanager.xml
Transaction Manager config e.g.
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd"> <bean id="txManager" name="txManager"> <property name="dataSource" ref="dataSource"/> </bean> </beans>
Doing so enables creating and swapping environment-specific spring configs. The idea is to make environment-specific the configuration elements that will change often. In my case spring-datasource.xml
and spring-hibernate.xml
are good candidates. So the config contextes be structured as follows:
With this structure, if the app is deployed locally, then the build script will take the spring files in the
dev
directory. Similarly, if unit tests are run (e.g. in your build pipeline), the files in test
would be put on the classpath.
Hence, if developers want to use a local mysql
instance, they can configure the dev/spring-datasource.xml
file with the following line:
<property name="url" value="jdbc:hsqldb:hsql://localhost:9001"/>
For speed’s sake, unit tests would preferably run on an in-memory database, hence test/spring-datasource.xml
would have:
<property name="url" value="jdbc:hsqldb:mem:spring-playground"/>
Note: A similar approach would be to override the default spring config using the @ContextConfiguration
Spring 3 annotation. For instance, one could create a supertype class for all the integration tests to load the test-specific context as follows:
@ContextConfiguration(locations={"classpath:spring-test-*.xml"}) public class MyUnitTestBaseClass { }
In that case, each test-specific spring config file’s name starts with spring-test
. So the files can stay on the classpath (no need to swap through build script), as they will not clash with the default config, and will only be used by unit tests.
When Spring Hides The Mocking Game
Most OpenMRS JUnit tests extend BaseContextSensitiveTest
, which loads the Spring context applicationContext-service.xml
for the Application Services, hence automatically wiring the DAOs.
@ContextConfiguration(locations = { "classpath:applicationContext-service.xml", "classpath*:openmrs-servlet.xml", "classpath*:moduleApplicationContext.xml" }) @TestExecutionListeners( { TransactionalTestExecutionListener.class, SkipBaseSetupAnnotationExecutionListener.class, StartModuleExecutionListener.class }) @Transactional public abstract class BaseContextSensitiveTest extends AbstractJUnit4SpringContextTests { [...] }
Because of that, the service unit tests do not mock nor stub the DAOs. The first issue here is that they are not unit tests but integration tests. The second issue is that it becomes very difficult to write proper unit tests (with mocked dependencies) using the same context without breaking other tests.
Indeed, I was trying to add the following test method to ProgramWorkflowServiceTest
:
@Test @Verifies(value="should call the DAO method getProgramsByName", method = "getProgramByName") public void getProgramByName_shouldCallDaoGetProgramsByName() { ProgramWorkflowDAO mockDao = Mockito.mock(ProgramWorkflowDAO.class); pws.setProgramWorkflowDAO(mockDao); pws.getProgramByName("A name"); Mockito.verify(mockDao).getProgramsByName("A name"); }
pws
is an instance of ProgramWorkflowService
obtained from the Spring context. By doing so, I actually ended up screwing-up all other subsequent unit tests whose class under tests depends on ProgramWorkflowService
(since dependencies are obtained from the same context).
As an example, all unit tests for the following method in ProgramEditor
will fail because Context.getProgramWorkflowService()
at line 7 will return an object that has a mocked DAO:
1. public void setAsText(String text) throws IllegalArgumentException { 2. if (StringUtils.hasText(text)) { 3. try { 4. if (text.startsWith("concept.")) { 5. Integer conceptId = Integer.valueOf(text.substring(text.indexOf('.') + 1)); 6. Concept c = Context.getConceptService().getConcept(conceptId); 7. setValue(Context.getProgramWorkflowService().getProgramByName(c.getName().getName())); 8. } 9. else { 10. Integer programId = Integer.valueOf(text); 11. setValue(Context.getProgramWorkflowService().getProgram(programId)); 12. } 13. } [...] }
So, by mocking a dependency of my object under test, I unexpectedly modify the behaviour of the code in another part of the application!
Make Spring DI Less Evil
To conclude, my recommendations for a more pragmatic and flexible Spring IoC config:
1. Split config files into small files, and make environment specific those that need to be.
2. Use the IoC only for integration tests, in which case use the @ContextConfiguration
annotation on a layer supertype of all your integration tests.
3. Do not use the IoC for unit tests: instantiate, mock, and inject through setters in the @Before
method manually.