Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce class-level execution phases for @Sql #27285

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
* override {@link #setMethodInvoker(MethodInvoker)} and {@link #getMethodInvoker()}.
*
* @author Sam Brannen
* @author Andreas Ahlenstorf
* @since 2.5
* @see TestContextManager
* @see TestExecutionListener
Expand Down Expand Up @@ -110,6 +111,25 @@ default void publishEvent(Function<TestContext, ? extends ApplicationEvent> even
*/
Object getTestInstance();

/**
* Tests whether a test method is part of this test context. Returns
* {@code true} if this context has a current test method, {@code false}
* otherwise.
*
* <p>The default implementation of this method always returns {@code false}.
* Custom {@code TestContext} implementations are therefore highly encouraged
* to override this method with a more meaningful implementation. Note that
* the standard {@code TestContext} implementation in Spring overrides this
* method appropriately.
* @return {@code true} if the test execution has already entered a test
* method
* @since 6.1
* @see #getTestMethod()
*/
default boolean hasTestMethod() {
sbrannen marked this conversation as resolved.
Show resolved Hide resolved
return false;
}

/**
* Get the current {@linkplain Method test method} for this test context.
* <p>Note: this is a mutable property.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@
*
* <p>Method-level declarations override class-level declarations by default,
* but this behavior can be configured via {@link SqlMergeMode @SqlMergeMode}.
* However, this does not apply to class-level declarations that use
* {@link ExecutionPhase#BEFORE_TEST_CLASS} or
* {@link ExecutionPhase#AFTER_TEST_CLASS}. Such declarations are retained and
* scripts and statements are executed once per class in addition to any
* method-level annotations.
*
* <p>Script execution is performed by the {@link SqlScriptsTestExecutionListener},
* which is enabled by default.
Expand Down Expand Up @@ -61,6 +66,7 @@
* modules as well as their transitive dependencies to be present on the classpath.
*
* @author Sam Brannen
* @author Andreas Ahlenstorf
* @since 4.1
* @see SqlConfig
* @see SqlMergeMode
Expand Down Expand Up @@ -161,6 +167,18 @@
*/
enum ExecutionPhase {

/**
* The configured SQL scripts and statements will be executed
* once <em>before</em> any test method is run.
*/
BEFORE_TEST_CLASS,
sbrannen marked this conversation as resolved.
Show resolved Hide resolved

/**
* The configured SQL scripts and statements will be executed
* once <em>after</em> any test method is run.
*/
AFTER_TEST_CLASS,

/**
* The configured SQL scripts and statements will be executed
* <em>before</em> the corresponding test method.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,17 @@
* {@link Sql#scripts scripts} and inlined {@link Sql#statements statements}
* configured via the {@link Sql @Sql} annotation.
*
* <p>Scripts and inlined statements will be executed {@linkplain #beforeTestMethod(TestContext) before}
* or {@linkplain #afterTestMethod(TestContext) after} execution of the corresponding
* {@linkplain java.lang.reflect.Method test method}, depending on the configured
* value of the {@link Sql#executionPhase executionPhase} flag.
* <p>Class-level annotations that are constrained to a class-level execution
* phase ({@link ExecutionPhase#BEFORE_TEST_CLASS} or
* {@link ExecutionPhase#AFTER_TEST_CLASS}) will be run
* {@linkplain #beforeTestClass(TestContext) once before all test methods} or
* {@linkplain #afterTestMethod(TestContext) once after all test methods},
* respectively. All other scripts and inlined statements will be executed
* {@linkplain #beforeTestMethod(TestContext) before} or
* {@linkplain #afterTestMethod(TestContext) after} execution of the
* corresponding {@linkplain java.lang.reflect.Method test method}, depending
* on the configured value of the {@link Sql#executionPhase executionPhase}
* flag.
*
* <p>Scripts and inlined statements will be executed without a transaction,
* within an existing Spring-managed transaction, or within an isolated transaction,
Expand Down Expand Up @@ -98,6 +105,7 @@
*
* @author Sam Brannen
* @author Dmitry Semukhin
* @author Andreas Ahlenstorf
* @since 4.1
* @see Sql
* @see SqlConfig
Expand Down Expand Up @@ -126,6 +134,26 @@ public final int getOrder() {
return 5000;
}

/**
* Execute SQL scripts configured via {@link Sql @Sql} for the supplied
* {@link TestContext} once per test class <em>before</em> any test method
* is run.
*/
@Override
public void beforeTestClass(TestContext testContext) throws Exception {
executeBeforeOrAfterClassSqlScripts(testContext, ExecutionPhase.BEFORE_TEST_CLASS);
}

/**
* Execute SQL scripts configured via {@link Sql @Sql} for the supplied
* {@link TestContext} once per test class <em>after</em> all test methods
* have been run.
*/
@Override
public void afterTestClass(TestContext testContext) throws Exception {
executeBeforeOrAfterClassSqlScripts(testContext, ExecutionPhase.AFTER_TEST_CLASS);
}

/**
* Execute SQL scripts configured via {@link Sql @Sql} for the supplied
* {@link TestContext} <em>before</em> the current test method.
Expand Down Expand Up @@ -159,6 +187,17 @@ public void processAheadOfTime(RuntimeHints runtimeHints, Class<?> testClass, Cl
registerClasspathResources(getScripts(sql, testClass, testMethod, false), runtimeHints, classLoader)));
}

/**
* Execute class-level SQL scripts configured via {@link Sql @Sql} for the
* supplied {@link TestContext} and the execution phases
* {@link ExecutionPhase#BEFORE_TEST_CLASS} and
* {@link ExecutionPhase#AFTER_TEST_CLASS}.
*/
private void executeBeforeOrAfterClassSqlScripts(TestContext testContext, ExecutionPhase executionPhase) {
Class<?> testClass = testContext.getTestClass();
executeSqlScripts(getSqlAnnotationsFor(testClass), testContext, executionPhase, true);
}

/**
* Execute SQL scripts configured via {@link Sql @Sql} for the supplied
* {@link TestContext} and {@link ExecutionPhase}.
Expand Down Expand Up @@ -246,6 +285,9 @@ private void executeSqlScripts(
private void executeSqlScripts(
Sql sql, ExecutionPhase executionPhase, TestContext testContext, boolean classLevel) {

Assert.isTrue(classLevel || isValidMethodLevelPhase(sql.executionPhase()),
() -> "%s cannot be used on methods".formatted(sql.executionPhase()));

if (executionPhase != sql.executionPhase()) {
return;
}
Expand All @@ -260,7 +302,12 @@ else if (logger.isDebugEnabled()) {
.formatted(executionPhase, testContext.getTestClass().getName()));
}

String[] scripts = getScripts(sql, testContext.getTestClass(), testContext.getTestMethod(), classLevel);
Method testMethod = null;
if (testContext.hasTestMethod()) {
testMethod = testContext.getTestMethod();
}

String[] scripts = getScripts(sql, testContext.getTestClass(), testMethod, classLevel);
List<Resource> scriptResources = TestContextResourceUtils.convertToResourceList(
testContext.getApplicationContext(), scripts);
for (String stmt : sql.statements()) {
Expand Down Expand Up @@ -354,7 +401,7 @@ private DataSource getDataSourceFromTransactionManager(PlatformTransactionManage
return null;
}

private String[] getScripts(Sql sql, Class<?> testClass, Method testMethod, boolean classLevel) {
private String[] getScripts(Sql sql, Class<?> testClass, @Nullable Method testMethod, boolean classLevel) {
String[] scripts = sql.scripts();
if (ObjectUtils.isEmpty(scripts) && ObjectUtils.isEmpty(sql.statements())) {
scripts = new String[] {detectDefaultScript(testClass, testMethod, classLevel)};
Expand All @@ -366,7 +413,9 @@ private String[] getScripts(Sql sql, Class<?> testClass, Method testMethod, bool
* Detect a default SQL script by implementing the algorithm defined in
* {@link Sql#scripts}.
*/
private String detectDefaultScript(Class<?> testClass, Method testMethod, boolean classLevel) {
private String detectDefaultScript(Class<?> testClass, @Nullable Method testMethod, boolean classLevel) {
Assert.state(classLevel || testMethod != null, "Method-level @Sql requires a testMethod");

String elementType = (classLevel ? "class" : "method");
String elementName = (classLevel ? testClass.getName() : testMethod.toString());

Expand Down Expand Up @@ -407,4 +456,9 @@ private void registerClasspathResources(String[] paths, RuntimeHints runtimeHint
.forEach(runtimeHints.resources()::registerResource);
}

private static boolean isValidMethodLevelPhase(ExecutionPhase executionPhase) {
// Class-level phases cannot be used on methods.
return executionPhase == ExecutionPhase.BEFORE_TEST_METHOD ||
executionPhase == ExecutionPhase.AFTER_TEST_METHOD;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
* @author Sam Brannen
* @author Juergen Hoeller
* @author Rob Harrop
* @author Andreas Ahlenstorf
* @since 4.0
*/
@SuppressWarnings("serial")
Expand Down Expand Up @@ -166,6 +167,11 @@ public final Object getTestInstance() {
return testInstance;
}

@Override
public boolean hasTestMethod() {
return this.testMethod != null;
}

@Override
public final Method getTestMethod() {
Method testMethod = this.testMethod;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2022 the original author or authors.
* Copyright 2002-2023 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.
Expand Down Expand Up @@ -46,6 +46,7 @@
*
* @author Sam Brannen
* @author Juergen Hoeller
* @author Andreas Ahlenstorf
* @since 4.1
*/
public abstract class TestContextTransactionUtils {
Expand Down Expand Up @@ -227,7 +228,8 @@ private static void logBeansException(TestContext testContext, BeansException ex
/**
* Create a delegating {@link TransactionAttribute} for the supplied target
* {@link TransactionAttribute} and {@link TestContext}, using the names of
* the test class and test method to build the name of the transaction.
* the test class and test method (if available) to build the name of the
* transaction.
* @param testContext the {@code TestContext} upon which to base the name
* @param targetAttribute the {@code TransactionAttribute} to delegate to
* @return the delegating {@code TransactionAttribute}
Expand All @@ -248,7 +250,13 @@ private static class TestContextTransactionAttribute extends DelegatingTransacti

public TestContextTransactionAttribute(TransactionAttribute targetAttribute, TestContext testContext) {
super(targetAttribute);
this.name = ClassUtils.getQualifiedMethodName(testContext.getTestMethod(), testContext.getTestClass());

if (testContext.hasTestMethod()) {
this.name = ClassUtils.getQualifiedMethodName(testContext.getTestMethod(), testContext.getTestClass());
}
else {
this.name = testContext.getTestClass().getName();
}
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright 2002-2023 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
*
* https://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.jdbc;

import javax.sql.DataSource;

import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;

import org.springframework.core.Ordered;
import org.springframework.jdbc.BadSqlGrammarException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.annotation.Commit;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.TestContext;
import org.springframework.test.context.TestExecutionListener;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.context.transaction.TestContextTransactionUtils;

import static org.assertj.core.api.Assertions.assertThatExceptionOfType;

/**
* Verifies that {@link Sql @Sql} with {@link Sql.ExecutionPhase#AFTER_TEST_CLASS} is run after all tests in the class
* have been run.
*
* @author Andreas Ahlenstorf
* @since 6.1
*/
@SpringJUnitConfig(PopulatedSchemaDatabaseConfig.class)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@Sql(value = {"drop-schema.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_CLASS)
@TestExecutionListeners(
value = AfterTestClassSqlScriptsTests.VerifyTestExecutionListener.class,
mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS
)
class AfterTestClassSqlScriptsTests extends AbstractTransactionalTests {

@Test
@Order(1)
@Sql(scripts = "data-add-catbert.sql")
@Commit
void databaseHasBeenInitialized() {
assertUsers("Catbert");
}

@Test
@Order(2)
@Sql(scripts = "data-add-dogbert.sql")
@Commit
void databaseIsNotWipedBetweenTests() {
assertUsers("Catbert", "Dogbert");
}

static class VerifyTestExecutionListener implements TestExecutionListener, Ordered {

@Override
public void afterTestClass(TestContext testContext) throws Exception {
DataSource dataSource = TestContextTransactionUtils.retrieveDataSource(testContext, null);
JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);

assertThatExceptionOfType(BadSqlGrammarException.class)
.isThrownBy(() -> jdbcTemplate.queryForList("SELECT name FROM user", String.class));
}

@Override
public int getOrder() {
// Must run before DirtiesContextTestExecutionListener. Otherwise, the old data source will be removed and
// replaced with a new one.
return 3001;
}
}
}
Loading