Introduce programmatic tx mgmt in the TCF

Historically, Spring's JUnit 3.8 TestCase class hierarchy supported
programmatic transaction management of "test-managed transactions" via
the protected endTransaction() and startNewTransaction() methods in
AbstractTransactionalSpringContextTests.

The Spring TestContext Framework (TCF) was introduced in Spring 2.5 to
supersede the legacy JUnit 3.8 support classes; however, prior to this
commit the TCF has not provided support for programmatically starting
or stopping the test-managed transaction.

This commit introduces a TestTransaction class in the TCF that provides
static utility methods for programmatically interacting with
test-managed transactions. Specifically, the following features are
supported by TestTransaction and its collaborators.

 - End the current test-managed transaction.

 - Start a new test-managed transaction, using the default rollback
   semantics configured via @TransactionConfiguration and @Rollback.

 - Flag the current test-managed transaction to be committed.

 - Flag the current test-managed transaction to be rolled back.

Implementation Details:

 - TransactionContext is now a top-level, package private class.

 - The existing test transaction management logic has been extracted
   from TransactionalTestExecutionListener into TransactionContext.

 - The current TransactionContext is stored in a
   NamedInheritableThreadLocal that is managed by
   TransactionContextHolder.

 - TestTransaction defines the end-user API, interacting with the
   TransactionContextHolder behind the scenes.

 - TransactionalTestExecutionListener now delegates to
   TransactionContext completely for starting and ending transactions.

Issue: SPR-5079
This commit is contained in:
Sam Brannen
2014-06-24 13:48:20 +02:00
parent 526b463474
commit f667e43ca2
8 changed files with 663 additions and 108 deletions

View File

@@ -0,0 +1,146 @@
/*
* Copyright 2002-2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.context.transaction;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.transaction.TransactionStatus;
/**
* {@code TestTransaction} provides a collection of static utility methods for
* programmatic interaction with <em>test-managed transactions</em>.
*
* <p>Test-managed transactions are transactions that are managed by the <em>Spring TestContext Framework</em>.
*
* <p>Support for {@code TestTransaction} is automatically available whenever
* the {@link TransactionalTestExecutionListener} is enabled. Note that the
* {@code TransactionalTestExecutionListener} is typically enabled by default,
* but it can also be manually enabled via the
* {@link TestExecutionListeners @TestExecutionListeners} annotation.
*
* @author Sam Brannen
* @since 4.1
* @see TransactionalTestExecutionListener
*/
public class TestTransaction {
/**
* Determine whether a test-managed transaction is currently <em>active</em>.
* @return {@code true} if a test-managed transaction is currently active
* @see #start()
* @see #end()
*/
public static boolean isActive() {
TransactionContext transactionContext = TransactionContextHolder.getCurrentTransactionContext();
if (transactionContext != null) {
TransactionStatus transactionStatus = transactionContext.getTransactionStatus();
return (transactionStatus != null) && (!transactionStatus.isCompleted());
}
// else
return false;
}
/**
* Determine whether the current test-managed transaction has been
* {@linkplain #flagForRollback() flagged for rollback} or
* {@linkplain #flagForCommit() flagged for commit}.
* @return {@code true} if the current test-managed transaction is flagged
* to be rolled back; {@code false} if the current test-managed transaction
* is flagged to be committed
* @throws IllegalStateException if a transaction is not active for the
* current test
* @see #isActive()
* @see #flagForRollback()
* @see #flagForCommit()
*/
public static boolean isFlaggedForRollback() {
return requireCurrentTransactionContext().isFlaggedForRollback();
}
/**
* Flag the current test-managed transaction for <em>rollback</em>.
* <p>Invoking this method will <em>not</em> end the current transaction.
* Rather, the value of this flag will be used to determine whether or not
* the current test-managed transaction should be rolled back or committed
* once it is {@linkplain #end ended}.
* @throws IllegalStateException if a transaction is not active for the
* current test
* @see #isActive()
* @see #isFlaggedForRollback()
* @see #start()
* @see #end()
*/
public static void flagForRollback() {
setFlaggedForRollback(true);
}
/**
* Flag the current test-managed transaction for <em>commit</em>.
* <p>Invoking this method will <em>not</em> end the current transaction.
* Rather, the value of this flag will be used to determine whether or not
* the current test-managed transaction should be rolled back or committed
* once it is {@linkplain #end ended}.
* @throws IllegalStateException if a transaction is not active for the
* current test
* @see #isActive()
* @see #isFlaggedForRollback()
* @see #start()
* @see #end()
*/
public static void flagForCommit() {
setFlaggedForRollback(false);
}
/**
* Start a new test-managed transaction.
* <p>Only call this method if {@link #end} has been called or if no
* transaction has been previously started.
* @throws IllegalStateException if the transaction context could not be
* retrieved or if a transaction is already active for the current test
* @see #isActive()
* @see #end()
*/
public static void start() {
requireCurrentTransactionContext().startTransaction();
}
/**
* Immediately force a <em>commit</em> or <em>rollback</em> of the current
* test-managed transaction, according to the {@linkplain #isFlaggedForRollback
* rollback flag}.
* @throws IllegalStateException if the transaction context could not be
* retrieved or if a transaction is not active for the current test
* @see #isActive()
* @see #start()
*/
public static void end() {
requireCurrentTransactionContext().endTransaction();
}
private static TransactionContext requireCurrentTransactionContext() {
TransactionContext txContext = TransactionContextHolder.getCurrentTransactionContext();
if (txContext == null) {
throw new IllegalStateException("TransactionContext is not active");
}
return txContext;
}
private static void setFlaggedForRollback(boolean flag) {
requireCurrentTransactionContext().setFlaggedForRollback(flag);
}
}

View File

@@ -0,0 +1,140 @@
/*
* Copyright 2002-2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.context.transaction;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.test.context.TestContext;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionException;
import org.springframework.transaction.TransactionStatus;
/**
* Transaction context for a specific {@link TestContext}.
*
* @author Sam Brannen
* @author Juergen Hoeller
* @since 4.1
* @see org.springframework.transaction.annotation.Transactional
* @see org.springframework.test.context.transaction.TransactionalTestExecutionListener
*/
class TransactionContext {
private static final Log logger = LogFactory.getLog(TransactionContext.class);
private final TestContext testContext;
private final TransactionDefinition transactionDefinition;
private final PlatformTransactionManager transactionManager;
private final boolean defaultRollback;
private boolean flaggedForRollback;
private TransactionStatus transactionStatus;
private volatile int transactionsStarted = 0;
TransactionContext(TestContext testContext, PlatformTransactionManager transactionManager,
TransactionDefinition transactionDefinition, boolean defaultRollback) {
this.testContext = testContext;
this.transactionManager = transactionManager;
this.transactionDefinition = transactionDefinition;
this.defaultRollback = defaultRollback;
this.flaggedForRollback = defaultRollback;
}
TransactionStatus getTransactionStatus() {
return this.transactionStatus;
}
/**
* Has the current transaction been flagged for rollback?
* <p>In other words, should we roll back or commit the current transaction
* upon completion of the current test?
*/
boolean isFlaggedForRollback() {
return this.flaggedForRollback;
}
void setFlaggedForRollback(boolean flaggedForRollback) {
if (this.transactionStatus == null) {
throw new IllegalStateException(String.format(
"Failed to set rollback flag for test context %s: transaction does not exist.", this.testContext));
}
this.flaggedForRollback = flaggedForRollback;
}
/**
* Start a new transaction for the configured {@linkplain #getTestContext test context}.
* <p>Only call this method if {@link #endTransaction} has been called or if no
* transaction has been previously started.
* @throws TransactionException if starting the transaction fails
*/
void startTransaction() {
if (this.transactionStatus != null) {
throw new IllegalStateException(
"Cannot start a new transaction without ending the existing transaction first.");
}
this.flaggedForRollback = this.defaultRollback;
this.transactionStatus = this.transactionManager.getTransaction(this.transactionDefinition);
++this.transactionsStarted;
if (logger.isInfoEnabled()) {
logger.info(String.format(
"Began transaction (%s) for test context %s; transaction manager [%s]; rollback [%s]",
this.transactionsStarted, this.testContext, this.transactionManager, flaggedForRollback));
}
}
/**
* Immediately force a <em>commit</em> or <em>rollback</em> of the transaction
* for the configured {@linkplain #getTestContext test context}, according to
* the {@linkplain #isFlaggedForRollback rollback flag}.
*/
void endTransaction() {
if (logger.isTraceEnabled()) {
logger.trace(String.format(
"Ending transaction for test context %s; transaction status [%s]; rollback [%s]", this.testContext,
this.transactionStatus, flaggedForRollback));
}
if (this.transactionStatus == null) {
throw new IllegalStateException(String.format(
"Failed to end transaction for test context %s: transaction does not exist.", this.testContext));
}
try {
if (flaggedForRollback) {
this.transactionManager.rollback(this.transactionStatus);
}
else {
this.transactionManager.commit(this.transactionStatus);
}
}
finally {
this.transactionStatus = null;
}
if (logger.isInfoEnabled()) {
logger.info(String.format("%s transaction after test execution for test context %s.",
(flaggedForRollback ? "Rolled back" : "Committed"), this.testContext));
}
}
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright 2002-2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.context.transaction;
import org.springframework.core.NamedInheritableThreadLocal;
/**
* {@link InheritableThreadLocal}-based holder for the current {@link TransactionContext}.
*
* @author Sam Brannen
* @since 4.1
*/
class TransactionContextHolder {
private static final ThreadLocal<TransactionContext> currentTransactionContext = new NamedInheritableThreadLocal<TransactionContext>(
"Test Transaction Context");
static TransactionContext getCurrentTransactionContext() {
return currentTransactionContext.get();
}
static void setCurrentTransactionContext(TransactionContext transactionContext) {
currentTransactionContext.set(transactionContext);
}
static TransactionContext removeCurrentTransactionContext() {
synchronized (currentTransactionContext) {
TransactionContext transactionContext = currentTransactionContext.get();
currentTransactionContext.remove();
return transactionContext;
}
}
}

View File

@@ -22,8 +22,6 @@ import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
@@ -38,7 +36,6 @@ import org.springframework.test.context.TestContext;
import org.springframework.test.context.support.AbstractTestExecutionListener;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionException;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.annotation.TransactionManagementConfigurer;
@@ -53,7 +50,7 @@ import static org.springframework.core.annotation.AnnotationUtils.*;
/**
* {@code TestExecutionListener} that provides support for executing tests
* within transactions by honoring the
* {@link org.springframework.transaction.annotation.Transactional &#064;Transactional}
* {@link org.springframework.transaction.annotation.Transactional @Transactional}
* annotation. Expects a {@link PlatformTransactionManager} bean to be defined in the
* Spring {@link ApplicationContext} for the test.
*
@@ -91,6 +88,7 @@ import static org.springframework.core.annotation.AnnotationUtils.*;
* @see org.springframework.test.annotation.Rollback
* @see BeforeTransaction
* @see AfterTransaction
* @see TestTransaction
*/
public class TransactionalTestExecutionListener extends AbstractTestExecutionListener {
@@ -104,18 +102,13 @@ public class TransactionalTestExecutionListener extends AbstractTestExecutionLis
protected final TransactionAttributeSource attributeSource = new AnnotationTransactionAttributeSource();
private final Map<Method, TransactionContext> transactionContextCache = new ConcurrentHashMap<Method, TransactionContext>(
8);
private TransactionConfigurationAttributes configurationAttributes;
private volatile int transactionsStarted = 0;
/**
* If the test method of the supplied {@link TestContext test context} is
* configured to run within a transaction, this method will run
* {@link BeforeTransaction &#064;BeforeTransaction methods} and start a new
* If the test method of the supplied {@linkplain TestContext test context}
* is configured to run within a transaction, this method will run
* {@link BeforeTransaction @BeforeTransaction} methods and start a new
* transaction.
* <p>Note that if a {@code @BeforeTransaction} method fails, any remaining
* {@code @BeforeTransaction} methods will not be invoked, and a transaction
@@ -129,9 +122,9 @@ public class TransactionalTestExecutionListener extends AbstractTestExecutionLis
final Class<?> testClass = testContext.getTestClass();
Assert.notNull(testMethod, "The test method of the supplied TestContext must not be null");
if (this.transactionContextCache.remove(testMethod) != null) {
throw new IllegalStateException("Cannot start new transaction without ending existing transaction: "
+ "Invoke endTransaction() before startNewTransaction().");
TransactionContext txContext = TransactionContextHolder.removeCurrentTransactionContext();
if (txContext != null) {
throw new IllegalStateException("Cannot start a new transaction without ending the existing transaction.");
}
PlatformTransactionManager tm = null;
@@ -154,30 +147,34 @@ public class TransactionalTestExecutionListener extends AbstractTestExecutionLis
}
if (tm != null) {
TransactionContext txContext = new TransactionContext(tm, transactionAttribute);
txContext = new TransactionContext(testContext, tm, transactionAttribute, isRollback(testContext));
runBeforeTransactionMethods(testContext);
startNewTransaction(testContext, txContext);
this.transactionContextCache.put(testMethod, txContext);
txContext.startTransaction();
TransactionContextHolder.setCurrentTransactionContext(txContext);
}
}
/**
* If a transaction is currently active for the test method of the supplied
* {@link TestContext test context}, this method will end the transaction
* and run {@link AfterTransaction &#064;AfterTransaction methods}.
* <p>{@code @AfterTransaction} methods are guaranteed to be
* invoked even if an error occurs while ending the transaction.
* If a transaction is currently active for the supplied
* {@linkplain TestContext test context}, this method will end the transaction
* and run {@link AfterTransaction @AfterTransaction} methods.
* <p>{@code @AfterTransaction} methods are guaranteed to be invoked even if
* an error occurs while ending the transaction.
*/
@Override
public void afterTestMethod(TestContext testContext) throws Exception {
Method testMethod = testContext.getTestMethod();
Assert.notNull(testMethod, "The test method of the supplied TestContext must not be null");
// If the transaction is still active...
TransactionContext txContext = this.transactionContextCache.remove(testMethod);
if (txContext != null && !txContext.transactionStatus.isCompleted()) {
TransactionContext txContext = TransactionContextHolder.removeCurrentTransactionContext();
// If there was (or perhaps still is) a transaction...
if (txContext != null) {
TransactionStatus transactionStatus = txContext.getTransactionStatus();
try {
endTransaction(testContext, txContext);
// If the transaction is still active...
if ((transactionStatus != null) && !transactionStatus.isCompleted()) {
txContext.endTransaction();
}
}
finally {
runAfterTransactionMethods(testContext);
@@ -186,7 +183,7 @@ public class TransactionalTestExecutionListener extends AbstractTestExecutionLis
}
/**
* Run all {@link BeforeTransaction &#064;BeforeTransaction methods} for the
* Run all {@link BeforeTransaction @BeforeTransaction} methods for the
* specified {@link TestContext test context}. If one of the methods fails,
* however, the caught exception will be rethrown in a wrapped
* {@link RuntimeException}, and the remaining methods will <strong>not</strong>
@@ -212,7 +209,7 @@ public class TransactionalTestExecutionListener extends AbstractTestExecutionLis
}
/**
* Run all {@link AfterTransaction &#064;AfterTransaction methods} for the
* Run all {@link AfterTransaction @AfterTransaction} methods for the
* specified {@link TestContext test context}. If one of the methods fails,
* the caught exception will be logged as an error, and the remaining
* methods will be given a chance to execute. After all methods have
@@ -252,45 +249,6 @@ public class TransactionalTestExecutionListener extends AbstractTestExecutionLis
}
}
/**
* Start a new transaction for the supplied {@link TestContext test context}.
* <p>Only call this method if {@link #endTransaction} has been called or if no
* transaction has been previously started.
* @param testContext the current test context
* @throws TransactionException if starting the transaction fails
* @throws Exception if an error occurs while retrieving the transaction manager
*/
private void startNewTransaction(TestContext testContext, TransactionContext txContext) throws Exception {
txContext.startTransaction();
++this.transactionsStarted;
if (logger.isInfoEnabled()) {
logger.info(String.format(
"Began transaction (%s) for test context %s; transaction manager [%s]; rollback [%s]",
this.transactionsStarted, testContext, txContext.transactionManager, isRollback(testContext)));
}
}
/**
* Immediately force a <em>commit</em> or <em>rollback</em> of the
* transaction for the supplied {@link TestContext test context}, according
* to the commit and rollback flags.
* @param testContext the current test context
* @throws Exception if an error occurs while retrieving the transaction manager
*/
private void endTransaction(TestContext testContext, TransactionContext txContext) throws Exception {
boolean rollback = isRollback(testContext);
if (logger.isTraceEnabled()) {
logger.trace(String.format(
"Ending transaction for test context %s; transaction status [%s]; rollback [%s]", testContext,
txContext.transactionStatus, rollback));
}
txContext.endTransaction(rollback);
if (logger.isInfoEnabled()) {
logger.info((rollback ? "Rolled back" : "Committed")
+ " transaction after test execution for test context " + testContext);
}
}
/**
* Get the {@link PlatformTransactionManager transaction manager} to use
* for the supplied {@linkplain TestContext test context} and {@code qualifier}.
@@ -478,7 +436,7 @@ public class TransactionalTestExecutionListener extends AbstractTestExecutionLis
/**
* Retrieves the {@link TransactionConfigurationAttributes} for the
* specified {@link Class class} which may optionally declare or inherit
* {@link TransactionConfiguration &#064;TransactionConfiguration}. If
* {@link TransactionConfiguration @TransactionConfiguration}. If
* {@code @TransactionConfiguration} is not present for the supplied
* class, the <em>default values</em> for attributes defined in
* {@code @TransactionConfiguration} will be used instead.
@@ -520,37 +478,4 @@ public class TransactionalTestExecutionListener extends AbstractTestExecutionLis
return this.configurationAttributes;
}
/**
* Internal context holder for a specific test method.
*/
private static class TransactionContext {
private final PlatformTransactionManager transactionManager;
private final TransactionDefinition transactionDefinition;
private TransactionStatus transactionStatus;
public TransactionContext(PlatformTransactionManager transactionManager,
TransactionDefinition transactionDefinition) {
this.transactionManager = transactionManager;
this.transactionDefinition = transactionDefinition;
}
public void startTransaction() {
this.transactionStatus = this.transactionManager.getTransaction(this.transactionDefinition);
}
public void endTransaction(boolean rollback) {
if (rollback) {
this.transactionManager.rollback(this.transactionStatus);
}
else {
this.transactionManager.commit(this.transactionStatus);
}
}
}
}