views:

732

answers:

3

I'm trying to get transactions working within a Grails service, but I'm not getting the results I'm expecting. Can someone tell me if I'm doing something wrong, if my assumptions are off?

My domain class:

    class Account {

  static constraints = {
    balance(min: 0.00)
  }

  String companyName
  BigDecimal balance = 0.00
  Boolean active = true

  String toString() {
    return "${companyName} : ${balance}"
  }
}

My service:

class AccountService {

  boolean transactional = true

  def transfer(Account source, Account destination, amount) throws RuntimeException {

    if (source.active && destination.active) {
      source.balance -= amount

      if (!source.save(flush: true)) {
        throw new RuntimeException("Could not save source account.")
      } else {
        destination.balance += amount

        if (!destination.save(flush: true)) {
          throw new RuntimeException("Could not save destination account.")
        }
      }
    } else {
      throw new RuntimeException("Both accounts must be active.")
    }
  }

  def someMethod(Account account) throws RuntimeException {

    account.balance = -10.00

    println "validated: ${account.validate()}"

    if(!account.validate()) {
      throw new RuntimeException("Rollback!")
    }
  }
}

My unit test: import grails.test.*

class AccountServiceTests extends GrailsUnitTestCase {

  def AccountService

  protected void setUp() {
    super.setUp()
    mockDomain(Account)
    AccountService = new AccountService()
  }

  protected void tearDown() {
    super.tearDown()
  }

  void testTransactional() {
    def account = new Account(companyName: "ACME Toy Company", balance: 2000.00, active: true)

    def exception = null

    try {
      AccountService.someMethod(account)
    } catch (RuntimeException e) {
      exception = e
    }

    assert exception instanceof RuntimeException

    println "exception thrown: ${exception.getMessage()}"

    assertEquals 2000.00, account.balance
  }
}

The result:

Testsuite: AccountServiceTests
Tests run: 1, Failures: 1, Errors: 0, Time elapsed: 1.068 sec
------------- Standard Output ---------------
--Output from testTransactional--
validated: false
exception thrown: Rollback!
------------- ---------------- ---------------
------------- Standard Error -----------------
--Output from testTransactional--
------------- ---------------- ---------------

Testcase: testTransactional took 1.066 sec
    FAILED
expected:<2000.00> but was:<-10.00>
junit.framework.AssertionFailedError: expected:<2000.00> but was:<-10.00>
    at AccountServiceTests.testTransactional(AccountServiceTests.groovy:89)
    at _GrailsTest_groovy$_run_closure4.doCall(_GrailsTest_groovy:203)
    at _GrailsTest_groovy$_run_closure4.call(_GrailsTest_groovy)
    at _GrailsTest_groovy$_run_closure2.doCall(_GrailsTest_groovy:147)
    at _GrailsTest_groovy$_run_closure1_closure19.doCall(_GrailsTest_groovy:113)
    at _GrailsTest_groovy$_run_closure1.doCall(_GrailsTest_groovy:96)
    at TestApp$_run_closure1.doCall(TestApp.groovy:66)
    at gant.Gant$_dispatch_closure4.doCall(Gant.groovy:324)
    at gant.Gant$_dispatch_closure6.doCall(Gant.groovy:334)
    at gant.Gant$_dispatch_closure6.doCall(Gant.groovy)
    at gant.Gant.withBuildListeners(Gant.groovy:344)
    at gant.Gant.this$2$withBuildListeners(Gant.groovy)
    at gant.Gant$this$2$withBuildListeners.callCurrent(Unknown Source)
    at gant.Gant.dispatch(Gant.groovy:334)
    at gant.Gant.this$2$dispatch(Gant.groovy)
    at gant.Gant.invokeMethod(Gant.groovy)
    at gant.Gant.processTargets(Gant.groovy:495)
    at gant.Gant.processTargets(Gant.groovy:480)

My expectation:

When the account is given a negative balance, it shouldn't validate (which it doesn't), a RuntimeException should be thrown (which it is), and the account should rollback to it's previous state (balance: 2000), which is where it falls apart.

What am I missing here?

+1  A: 

Unit tests are just Groovy or Java classes, so there's no Spring application context and hence no transaction support. You'd have to mock that for a unit test, but that wouldn't be testing transactionality, just the quality of the mocks. Convert this to an integration test and don't call new on the service, use dependency injection:

class AccountServiceTests extends GroovyTestCase {

  def AccountService

  void testTransactional() {
    def account = new Account(companyName: "ACME Toy Company", balance: 2000.00,
                              active: true)
    account.save()
    assertFalse account.hasErrors()

    String message = shouldFail(RuntimeException) {
      AccountService.someMethod(account)
    }

    println "exception thrown: $message"

    assertEquals 2000.00, account.balance
  }
}

Note that the actual exception may be a wrapper exception with your thrown exception as its cause.

Burt Beckwith
Hey Burt, makes complete sense. Alas, still no luck. It's still throwing the exception as it should, but it's not rolling anything back.
Thody
That's now how it works. The account instance's data won't get rolled back, but the change won't have been persisted. Add 'def sessionFactory' as a dependency-injected field, and add a call to 'sessionFactory.currentSession.clear()' and 'account = Account.get(account.id)' before the last assertEquals to force a reload of the account instance and the test will pass.
Burt Beckwith
Just a quick note for people using this to write integration tests for transactional services. Integration tests themselves are transactional, so your service under test will not be in a new transaction. This may lead to unexpected behavior. A workaround is to turn off transactions in your integration test (static transactional = false).
Steve Goodman
Another workaround is to add the following annotation to your service method:@Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW)This will allow you to maintain transactions in your test class, which keeps setUp() and tearDown() working properly.
Steve Goodman
A: 

What version of Grails are you running? v1.1.1 has a bug where transactional=true doesn't work properly.

John Stoneham
Running v1.1.1.
Thody
Yeah. Try taking out `transactional = true`. See if you have better results.Or, try with 1.2.
John Stoneham
http://jira.codehaus.org/browse/GRAILS-4644
John Stoneham
+1  A: 

I tried the code but am seeing the same problem in the integration test. I used Grails 1.2

According to Jira GRAILS-3765 this is a known issue and still open. (I'm not sure why it only says "affects version 1.0.4" when 1.1.x has been out for a long time).

Based on these points, I think it is a bug in Grails. The Jira note has a workaround but I've not tried it. According to the issue, though, it will still work in when running the app; this can be confirmed manually.

Michael Easter