1

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 {

  static 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?

Tung
  • 1,579
  • 4
  • 15
  • 32
Thody
  • 1,960
  • 3
  • 23
  • 31

3 Answers3

4

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
  • 75,342
  • 5
  • 143
  • 156
  • 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 Jan 15 '10 at 18:18
  • 2
    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 Jan 16 '10 at 07:14
  • 1
    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 Mar 18 '10 at 14:22
  • 1
    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 Mar 18 '10 at 15:16
  • @SteveGoodman Wouldn't this also create a new transaction in the situation where a controller is calling the service inside a withTransaction{} block? – tro Sep 10 '12 at 19:45
  • @tro - yes, that seems quite likely – Steve Goodman Oct 02 '12 at 14:51
1

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
  • 23,733
  • 7
  • 76
  • 107
0

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

John Stoneham
  • 2,485
  • 19
  • 10