Monday, September 25, 2006

Rolling with Spring and Hibernate, part 5: testing

Testing a Spring/Hibernate/AspectJ rich domain application is not completely trivial. Normally, when working with the traditional three-tier architecture (DAO, service, controller), you utilize Inversion of Control instead of instatiating collaborators to be able to switch to mock implementations in isolated tests. When you're working with a rich domain model like this, the whole point is to place more logic inside the domain objects. They must in turn be provided with the necessary services through, in this case, aspect-driven IoC since it's impossible to avoid writing code that instantiates domain objects.

This means that if we want to test code that does instantiate a domain object, we can't inject a mock domain object - we have to inject the mock services into the domain object instance(s) created by the code we're testing.

In this application, there are only two "layers": the (rich) domain model and the controllers. Testing the model is simple, it's just like any normal DAO layer: we set up a test database and use the class with the world's longest name to test CRUD: AbstractTransactionalDataSourceSpringContextTests. It provides a few very useful things: the ability to run each test in a transaction that is rolled back, eliminating the need for manually cleaning the test database, IoC for wiring the tests themselves, and a JdbcTemplate for verifying the database operations and mappings. It's often a good idea to create a base class that loads test data and some other stuff that is common to all CRUD tests. Here's what I'm using:

public abstract class CookbookModelTest extends AbstractTransactionalDataSourceSpringContextTests {

SessionFactory sessionFactory;

public void setSessionFactory(SessionFactory sessionFactory) {
this.sessionFactory = sessionFactory;
}

@Override
protected String[] getConfigLocations() {
return new String[] {
"applicationContext-hibernate.xml",
"applicationContext-test.xml"
};
}

@Override
protected void onSetUpInTransaction() throws Exception {
super.onSetUpInTransaction();
jdbcTemplate.execute(
"INSERT INTO category (id, name) " +
"VALUES (10, 'Category 1')");
jdbcTemplate.execute(
"INSERT INTO category (id, name) " +
"VALUES (20, 'Category 2')");
jdbcTemplate.execute(
"INSERT INTO recipe (id, title, instructions, date, category_id) " +
"VALUES (1, 'Recipe 1', 'Test this recipe', '2006-09-10', 10)");
jdbcTemplate.execute(
"INSERT INTO recipe (id, title, instructions, date, category_id) " +
"VALUES (2, 'Recipe 2', 'Test this recipe too', '2006-09-11', 10)");
jdbcTemplate.execute(
"INSERT INTO recipe (id, title, instructions, date, category_id) " +
"VALUES (3, 'Recipe 3', 'Test this recipe three', '2006-09-12', 20)");
}

}

The session factory is useful for forcing a flush of the SQL commands to the database, for example when doing a delete, like we're doing here in the RecipeTest (inherits the CookbookModelTest above):

public void testDelete() throws Exception {
Recipe recipe = new Recipe();
recipe.setId(1L);
recipe.delete();
sessionFactory.getCurrentSession().flush();
assertEquals(0, jdbcTemplate.queryForInt("SELECT count(*) FROM Recipe WHERE id = 1"));
}

This illustrates the idea of using the JdbcTemplate to by-pass the ORM to verifty that the operation really resulted in the database changes we wanted.

Ok, that's the easy part. Now we move on to test the controller, which in some cases instantiates domain objects beyond our control. The fact that we're using the Hibernate API directly, and also the Criteria API, will make it a bit harder to set up mock expectations than if we had a plain DAO layer. Remeber how we build the Criteria queries:

@Transient
protected Session getSession() {
return sessionFactory.getCurrentSession();
}

@Transient
protected Criteria getCriteria() {
return getSession().createCriteria(this.getClass());
}

We have to mock both the SessionFactory, the Session and the Criteria...and make the mocks return other mocks! For example, we need to make the SessionFactory mock expect a call to getCurrentSession(), and return the Session mock (that in turn will expect other calls):

protected void expectGetCurrentSessionCall() {
sessionFactory.expects(once()).method("getCurrentSession")
.withNoArguments().will(new CustomStub("Session") {
public Object invoke(Invocation invocation) throws Throwable {
return (Session) session.proxy();
}
});
}

There's a similar method to set up an expectation to Session.getCriteria(clazz) in CookbookControllerTest.

In addition to this, we need an application context for weaving the domain objects according to the @Configurable annotation:

applicationContext = new StaticApplicationContext();
// It's a bit tricky to register a factory bean with a custom method name...
BeanDefinition aspectConfigurerDefinition = BeanDefinitionBuilder.
rootBeanDefinition(AnnotationBeanConfigurerAspect.class).
setFactoryMethod("aspectOf").getBeanDefinition();
// Register the configuring aspect
applicationContext.registerBeanDefinition(
AnnotationBeanConfigurerAspect.class.getName(), aspectConfigurerDefinition);
// Register the mocked session factory
applicationContext.getBeanFactory().
registerSingleton("sessionFactory",sessionFactory.proxy());
// Don't forget to refresh!
applicationContext.refresh();

This is a small application context that registers configuration aspect and the mock session factory, so that AspectJ can weave domain model classes according to classpath:META-INF/aop.xml, and inject the mock session factory on instatiation. With this in place in a controller test superclass, we can test a method that instantiates a domain object, such as this:

public ModelAndView create(HttpServletRequest request, HttpServletResponse response) {
List categories = new Category().findAll();
return new ModelAndView("recipe/edit", "categories", categories);
}

with the following code:

public void testCreate() throws Exception {
// This is a utility method to prepare mock request/response
prepareRequest("/recipie/create.htm", "GET");
expectGetCriteriaCall(Category.class);
List toReturn = new ArrayList();
toReturn.add(new Category());
toReturn.add(new Category());
criteria.expects(once()).
method("list").
withNoArguments().
will(returnValue(toReturn));

ModelAndView mav = controller.handleRequest(request, response);

assertEquals("recipie/edit", mav.getViewName());
List result = (List) mav.getModel().get("categories");
assertEquals(2, result.size());
}

Traditionally, we would mock CategoryService, and expect a call to listAllCategories(). No application context would be needed.

For those of you that feel that calls such as

new Category().findAll()

is hideous, I can only say that I agree, and we'll get to that in the final part.

No comments: