UP | HOME
dix

Software Curmudgeon

Mumblings and Grumblings About Software

Testing @Transactional in Spring
Published on Jul 20, 2021 by dix.

I recently started a new job and am adapting to an entirely new set of tools. In particular, I’m working mostly in Kotlin and Spring Boot, neither of which I have used before. I recently had an experience where I was unable to make a test work correctly due to the implementation of certain Spring annotations which led me in to a deep investigation into Spring.

My Testing Struggles

I recently wanted to test a service in a Spring Boot application that used the @Transactional annotation. In particular, I wanted to test that a specific function would roll-back all its inserts if a particular repository raised an error when saving an entity. To me, the clearest way to accomplish this was to construct a new instance of the service I was testing which depended on a mock repository. This mock repository would be configured to raise an exception whenever an entity was saved. This naive approach did not work, and it was not at first clear why it did not. Below, you can see a pared down version of the service I was testing.

@Service
class AccountService @Autowired constructor(
    val bookRepository: BookRepository,
    val journalRepository: JournalRepository
) {

    @Transactional
    fun capturePayment(accountId: String, amount: Int) {
    }
}

As you can see from the code sample, I was using the Spring @Autowired annotation to inject the repositories into the service through the constructor. A pared down sample of the naive approach I took to testing the transactional behavior is below.

@SpringBootTest
class AccountServiceTest {
    @Autowired
    private lateinit var journalRepository: JournalRepository

    @Autowired
    private lateinit var bookRepository: BookRepository

    @Autowired
    private lateinit var service: AccountService

    @Test
    fun testTransactionIsRolledBack() {
        val mockedBookRepository = mockk<BookRepository>()
        every { mockedBookRepository.save(any()) }
            throws IllegalArgumentException()
        val serviceWithMockedBookEntryRepository = AccountService(
            mockedBookRepository,
            journalRepository
        )

        val bookCount = bookRepository.findAll().size

        assertThrows<IllegalArgumentException> {
            serviceWithMockedBookEntryRepository.capturePayment(
                Random.nextLong().toString(),
                accountId,
                currencyCodeUSD,
                amount,
                feeAmount,
                tipAmount
            )
        }
        assertEquals(bookCount, bookRepository.findAll().size)
    }
}

As I said before, this naive approach did not work. And I could not figure out why until I turned on full SQL logging. At that point, I realized that a transaction was never opened. With the help of a few blog posts on the topic, I realized that because my test directly instantiates the class rather than retrieving it from the Spring context, the @Transactional annotation doesn’t do anything. This sent me on a journey to understand how the @Transactional annotation actually works and to determine if it is possible to directly instantiate the service and get the behavior of the @Transactional annotation.

After some spelunking, I learned that Spring implements the handling of this annotation by wrapping the original classes with proxies. These proxies are implemented and described with Aspect Oriented Programming.

Aspect Oriented Programming

Aspect Oriented programming is a programming paradigm that seeks to improve modularity of code by allowing better separation of cross-cutting concerns. For example, logging is a behavior that needs to be present in almost all areas of a codebase, but specifying explicit logging statements everywhere through a codebase could be needlessly complicated and verbose.

Aspect Oriented Programming allows us to provide this cross-cutting functionality through a code base without polluting each method with explicit logging calls. It does so through the join point model. There are three key elements of the join point model:

  1. Join points: places where the cross-cutting code can run
  2. Pointcuts: a way to specify join points
  3. Advice: specifying what code to run before, after, or around join points.

AspectJ is a Java extension developed at Xerox PARC to provide Aspect Oriented Programming capabilities to Java. Spring makes use of AspectJ syntax to describe how and where the @Transactional annotations take effect. While Spring makes use of AspectJ syntax to describe the implementation, Spring has it’s own pure-Java implementation of AOP. When you ask the Spring Inversion of Controller container for an instance of a class which has a method annotated with @Transactional, Spring returns an instance wrapped in a proxy which uses the following pieces of code to implement the @Transactional behavior.

In order to make my tests work, the key was to find a way to wrap my hand created instance in the correct proxies so that @Transactional worked correctly.

Making It Work

This experience taught me again a lesson I have learned many times over, that is, if you want a test a component of a framework look at how the authors of the framework test it. It may not always give you exactly what you need, but it will always give you the broad strokes of what you are looking at.

@Autowired
private lateinit var transactionManager: TransactionManager

@Test
fun testTransactionIsRolledBack() {
    val mockedBookRepository = mockk<BookRepository>()

    every { mockedBookRepository.save(any()) }
        throws IllegalArgumentException()

    val serviceWithMockedBookRepository = AccountService(
        mockedBookRepository,
        journalRepository
    )

    val serviceFactory = ProxyFactory(
        AccountService(
            mockedBookRepository,
            journalRepository
        )
    )
    val ti = TransactionInterceptor(
        transactionManager,
        AnnotationTransactionAttributeSource()
    )
    serviceFactory.addAdvice(ti)

    val serviceWithMockedBookRepository = serviceFactory.proxy as AccountService

    val bookCount = bookRepository.findAll().size

    assertThrows<IllegalArgumentException> {
        serviceWithMockedBookRepository.capturePayment(
            Random.nextLong().toString(),
            accountId,
            currencyCodeUSD,
            amount,
            feeAmount,
            tipAmount
        )
    }

    assertEquals(bookCount, bookRepository.findAll().size)
}

Conclusion

I suspect there might be some people reading this and faulting me for testing the framework rather than trusting that it works correctly. I have some sympathy for this point of view, but ultimately this application is inherently tied quite closely to the database and relies on database specific features to ensure consistency. For that reason, we wanted to be able to fully test against a real database. Ultimately, we chose to use a transactionTemplate directly rather than the @Transactional annotation.

Ultimately, our application is pretty tightly tied to the database implementation, so we didn’t want to “just trust the framework”. And we wanted very direct control over the database connection so we used the transaction template directly.

Others might read this and say “you could have just not done constructor injection, if you just made your class look like this you could easily solve this problem”.

class AccountService() {
    @Autowired
    lateinit var bookRepository: BookRepository,
    @Autowired
    lateinit var journalRepository: JournalRepository

    @Transactional
    fun capturePayment(accountId: String, amount: Int) {
    }
}

In the tests when I wanted to change the bookRepository I could have just called the setter and I would have been fine. I have a few objections to this:

  1. There is no need for these to be properties to be variables in production code. This is just done to make one test happy. I prefer immutability whenever possible.
  2. It’s very easy to make the mistake of not resetting the service in its test to use the real bookRepository rather than the mocked one. This causes pollution across your tests.

While I have now figured out how to make these tests work correctly, I was not able to do so in a timely fashion while working on this problem and I decided to change my design. In the end, this application is pretty intentionally tightly coupled to its database and so we updated our service to interact with the transaction manager explicitly. This eliminated our need to make the @Transactional annotation work in our test and ultimately led to what I think is a better design in our implementation.