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:
- Join points: places where the cross-cutting code can run
- Pointcuts: a way to specify join points
- 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:
- 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.
- 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.