Sharing Our Passion for Technology
& Continuous Learning
Transactions Our Invisible Allies
Transactions are an essential component in enterprise software development. When your application works properly you rarely think about transactions. However, when things go wrong debugging transactions can be quite challenging. Instead of being reactive we need to proactively test our transactions.
When I first got into the software industry I was a manual tester. Those years of repetition and tedium drove me to the world of coding and automation. As a software developer I am committed to automated testing. If a feature is worth coding it is worth testing. Automated tests have positioned me to be proactive instead of reactive. Nevertheless, transactions are quite challenging to test.
In order to test a transaction you must control three steps.
- Successfully make a change within a transaction.
- Cause an exception to be thrown within the same transaction.
- Verify that the change in step one was rolled-back.
There are two main ways to create an exception. The first, and less complicated way, is to throw the exception within your code. This works great if you have a transaction that encompasses multiple steps within a method.
@Transactional
public void batchUpdate(List people){
for(Person person : people){
repository.save(person);
log.info("Saved person: " + person.name());
}
}
To test this you could write the following JUnit test.
@Resource
BatchProcessor batchProcessor;
@Resource
PersonRepository personRepository;
@Test
public void testBatchUpdate(){
List people = Arrays.asList(new Person("Bob"),
new Person("Mary"), new Person("Ben"));
Assert.assertEquals(0, personRepository.findAllPoeple().size());
batchProcessor.batchUpdate(people);
Assert.assertEquals(3, personRepository.findAllPoeple().size());
}
@Test
public void testBatchUpdateRollback(){
batchProcessor.setLogger(new StubExplodingLogger(2));
List people = Arrays.asList(new Person("Bob"),
new Person("Mary"), new Person("Ben"));
Assert.assertEquals(0, personRepository.findAllPoeple().size());
try{
batchProcessor.batchUpdate(people);
Assert.fail();
}catch(RuntimeException e){
System.out.println("boom");
Assert.assertEquals(0, personRepository.findAllPoeple().size());
}
}
public class StubExplodingLogger extends Logger{
private int countDown;
public StubExplodingLogger(int countDown){
super("explodingLogger");
this.countDown = countDown;
}
@Override
public void info(Object message) {
if(countDown <= 0){
throw new RuntimeException("test explosion - BOOM!");
}
System.out.println("tick");
countDown--;
}
}
In this example you inject a fake logger that explodes on the second call. This is an ideal implementation for testing transactions and makes our test fairly straightforward. However the test for the repository save transaction is more complicated.
@Transactional
public void save(Person person){
//save the person
}
The test for this method would look something like this.
@Test
public void testPersonRepositorySaveRollback(){
Assert.assertEquals(0, personRepository.findAllPoeple().size());
try{
personRepository.save(new Person("Bob"));
Assert.fail();
}catch(Exception e){
Assert.assertEquals(0, personRepository.findAllPoeple().size());
}
}
In this case we have to rely on the database to throw an exception. This means that our application allows us to send data to the database that is in a bad state. In the past I have opened up a hole in my validation that allowed me to send bad data to the database. This is an irresponsible approach. There are two approaches that solve this issue while preserving the integrity of your application.
The first way is to simply test the configuration. It is fairly straight forward to verify that the method is marked with @Transactional and the framework is wrapping this with a transaction.
@Resource
PersonRepository personRepository;
@Test
public void testTransactionalConfiguration(){
//verify the save method is marked with @Transactional
Method method =
PersonRepository.class.getDeclaredMethod("save", Person.class);
Annotation annotation = method.getAnnotation(Transactional.class);
Assert.assertNotNull(annotation);
//verify PersonRepository is a proxy
Assert.assertTrue(personRepository.getClass().
toString().contains("$$EnhancerByCGLIB$$"));
}
However this approach fails to test the effect. It verifies that it is configured to work, but it fails to verify that it truly works.
The second approach is to temporarily modify the database constraints within a test to cause a failure. The simplest modification is to setup a database constraint that will not allow the state "AL".
@Before
public void setup(){
jdbcTemplate.execute("ALTER TABLE State" +
"ADD CONSTRAINT test_constraint " +
"CHECK (State <> 'AL');");
}
@Test
public void testPersonRepositorySaveRollback(){
Assert.assertEquals(0, personRepository.findAllPoeple().size());
try{
Address address = new Address("100 Street",
"Mobile", "AL", "36601");
personRepository.save(new Person("Bob", address));
Assert.fail();
}catch(Exception e){
Assert.assertEquals(0, personRepository.findAllPoeple().size());
}
}
@Test
public void testPersonRepositorySaveRollback(){
Assert.assertEquals(0, personRepository.findAllPoeple().size());
Address address = new Address("200 Street", "Des Moines",
"IA", "50044");
personRepository.save(new Person("Mary", address));
Assert.assertEquals(1, personRepository.findAllPoeple().size());
}
@After
public void tearDown(){
jdbcTemplate.execute("ALTER TABLE State" +
"DROP CONSTRAINT test_constraint;");
}
This allows you to test that people with other states are committed and that people that are in the state of "AL" are rolled-back. It is important that this constraint is added in the @Before setup method and removed in the @After teardown method.
I have lost too many hours of my life tracking down strange bugs that were ultimately related to missing or improperly configured transactions. Transactions are mission critical. They only get attention when they do not work. Furthermore, the cleanup of fragmented data can be crippling to an organization.
As professionals we must be committed to writing automated tests. This is especially true for mission critical features like transactions. Transactions are rarely explicitly stated in requirements. They are almost never directly tested by manual testers. And they are very expensive when they are missing or fail to work properly in production. They are our invisible allies. We need to make them first class citizens in our tests.