diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-test-support/pom.xml
index 4d7e027f91..72f28e1b1a 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/pom.xml
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/pom.xml
@@ -70,6 +70,10 @@
org.junit.jupiter
junit-jupiter
+
+ org.junit.platform
+ junit-platform-launcher
+
org.mockito
mockito-core
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/runner/classpath/ClassPathExclusions.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/runner/classpath/ClassPathExclusions.java
index 249d0eb252..8c4f501395 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/runner/classpath/ClassPathExclusions.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/runner/classpath/ClassPathExclusions.java
@@ -24,8 +24,8 @@ import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
- * Annotation used in combination with {@link ModifiedClassPathRunner} to exclude entries
- * from the classpath.
+ * Annotation used in combination with {@link ModifiedClassPathRunner} or
+ * {@link ModifiedClassPathExtension} to exclude entries from the classpath.
*
* @author Andy Wilkinson
* @since 1.5.0
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/runner/classpath/ClassPathOverrides.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/runner/classpath/ClassPathOverrides.java
index 75fc689c61..bc3fe9aaf9 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/runner/classpath/ClassPathOverrides.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/runner/classpath/ClassPathOverrides.java
@@ -23,8 +23,8 @@ import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
- * Annotation used in combination with {@link ModifiedClassPathRunner} to override entries
- * on the classpath.
+ * Annotation used in combination with {@link ModifiedClassPathRunner} or
+ * {@link ModifiedClassPathExtension} to override entries on the classpath.
*
* @author Andy Wilkinson
* @since 1.5.0
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/runner/classpath/ModifiedClassPathExtension.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/runner/classpath/ModifiedClassPathExtension.java
new file mode 100644
index 0000000000..88c1b53640
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/runner/classpath/ModifiedClassPathExtension.java
@@ -0,0 +1,141 @@
+/*
+ * Copyright 2012-2019 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.boot.testsupport.runner.classpath;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.net.URLClassLoader;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.junit.jupiter.api.extension.Extension;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.InvocationInterceptor;
+import org.junit.jupiter.api.extension.ReflectiveInvocationContext;
+import org.junit.platform.engine.discovery.DiscoverySelectors;
+import org.junit.platform.launcher.Launcher;
+import org.junit.platform.launcher.LauncherDiscoveryRequest;
+import org.junit.platform.launcher.TestPlan;
+import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
+import org.junit.platform.launcher.core.LauncherFactory;
+import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
+import org.junit.platform.launcher.listeners.TestExecutionSummary;
+
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.ReflectionUtils;
+
+/**
+ * A custom {@link Extension} that runs tests using a modified class path. Entries are
+ * excluded from the class path using {@link ClassPathExclusions @ClassPathExclusions} and
+ * overridden using {@link ClassPathOverrides @ClassPathOverrides} on the test class. A
+ * class loader is created with the customized class path and is used both to load the
+ * test class and as the thread context class loader while the test is being run.
+ *
+ * @author Christoph Dreis
+ * @since 2.2.0
+ */
+public class ModifiedClassPathExtension implements InvocationInterceptor {
+
+ @Override
+ public void interceptBeforeAllMethod(Invocation invocation,
+ ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable {
+ interceptInvocation(invocation, extensionContext);
+ }
+
+ @Override
+ public void interceptBeforeEachMethod(Invocation invocation,
+ ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable {
+ interceptInvocation(invocation, extensionContext);
+ }
+
+ @Override
+ public void interceptAfterEachMethod(Invocation invocation,
+ ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable {
+ interceptInvocation(invocation, extensionContext);
+ }
+
+ @Override
+ public void interceptAfterAllMethod(Invocation invocation,
+ ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable {
+ interceptInvocation(invocation, extensionContext);
+ }
+
+ @Override
+ public void interceptTestMethod(Invocation invocation, ReflectiveInvocationContext invocationContext,
+ ExtensionContext extensionContext) throws Throwable {
+ if (isModifiedClassPathClassLoader(extensionContext)) {
+ invocation.proceed();
+ return;
+ }
+ ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader();
+ URLClassLoader classLoader = ModifiedClassPathClassLoaderFactory
+ .createTestClassLoader(extensionContext.getRequiredTestClass());
+ Thread.currentThread().setContextClassLoader(classLoader);
+ try {
+ fakeInvocation(invocation);
+ TestExecutionSummary summary = launchTests(invocationContext, extensionContext, classLoader);
+ if (!CollectionUtils.isEmpty(summary.getFailures())) {
+ throw summary.getFailures().get(0).getException();
+ }
+ }
+ catch (Exception ex) {
+ throw ex;
+ }
+ finally {
+ Thread.currentThread().setContextClassLoader(originalClassLoader);
+ }
+ }
+
+ private TestExecutionSummary launchTests(ReflectiveInvocationContext invocationContext,
+ ExtensionContext extensionContext, URLClassLoader classLoader) throws ClassNotFoundException {
+ Class> testClass = classLoader.loadClass(extensionContext.getRequiredTestClass().getName());
+ Method method = ReflectionUtils.findMethod(testClass, invocationContext.getExecutable().getName());
+ LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request()
+ .selectors(DiscoverySelectors.selectMethod(testClass, method)).build();
+ Launcher launcher = LauncherFactory.create();
+ TestPlan testPlan = launcher.discover(request);
+ SummaryGeneratingListener listener = new SummaryGeneratingListener();
+ launcher.registerTestExecutionListeners(listener);
+ launcher.execute(testPlan);
+ return listener.getSummary();
+ }
+
+ private boolean isModifiedClassPathClassLoader(ExtensionContext extensionContext) {
+ return extensionContext.getRequiredTestClass().getClassLoader().getClass().getName()
+ .equals(ModifiedClassPathClassLoader.class.getName());
+ }
+
+ private void interceptInvocation(Invocation invocation, ExtensionContext extensionContext) throws Throwable {
+ if (isModifiedClassPathClassLoader(extensionContext)) {
+ invocation.proceed();
+ }
+ else {
+ fakeInvocation(invocation);
+ }
+ }
+
+ private void fakeInvocation(Invocation invocation) {
+ try {
+ Field field = ReflectionUtils.findField(invocation.getClass(), "invoked");
+ ReflectionUtils.makeAccessible(field);
+ ReflectionUtils.setField(field, invocation, new AtomicBoolean(true));
+ }
+ catch (Throwable ignore) {
+
+ }
+ }
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/runner/classpath/ModifiedClassPathExtensionExclusionsTests.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/runner/classpath/ModifiedClassPathExtensionExclusionsTests.java
new file mode 100644
index 0000000000..a6fdcf1c3b
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/runner/classpath/ModifiedClassPathExtensionExclusionsTests.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2012-2019 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.boot.testsupport.runner.classpath;
+
+import org.hamcrest.Matcher;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hamcrest.Matchers.isA;
+
+/**
+ * Tests for {@link ModifiedClassPathExtension} excluding entries from the class path.
+ *
+ * @author Christoph Dreis
+ */
+@ExtendWith(ModifiedClassPathExtension.class)
+@ClassPathExclusions("hibernate-validator-*.jar")
+class ModifiedClassPathExtensionExclusionsTests {
+
+ private static final String EXCLUDED_RESOURCE = "META-INF/services/" + "javax.validation.spi.ValidationProvider";
+
+ @Test
+ void entriesAreFilteredFromTestClassClassLoader() {
+ assertThat(getClass().getClassLoader().getResource(EXCLUDED_RESOURCE)).isNull();
+ }
+
+ @Test
+ void entriesAreFilteredFromThreadContextClassLoader() {
+ assertThat(Thread.currentThread().getContextClassLoader().getResource(EXCLUDED_RESOURCE)).isNull();
+ }
+
+ @Test
+ void testsThatUseHamcrestWorkCorrectly() {
+ Matcher matcher = isA(IllegalStateException.class);
+ assertThat(matcher.matches(new IllegalStateException())).isTrue();
+ }
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/runner/classpath/ModifiedClassPathExtensionOverridesTests.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/runner/classpath/ModifiedClassPathExtensionOverridesTests.java
new file mode 100644
index 0000000000..0dda8cca68
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/runner/classpath/ModifiedClassPathExtensionOverridesTests.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2012-2019 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.boot.testsupport.runner.classpath;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.context.ApplicationContext;
+import org.springframework.util.StringUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ModifiedClassPathExtension} overriding entries on the class path.
+ *
+ * @author Christoph Dreis
+ */
+@ExtendWith(ModifiedClassPathExtension.class)
+@ClassPathOverrides("org.springframework:spring-context:4.1.0.RELEASE")
+class ModifiedClassPathExtensionOverridesTests {
+
+ @Test
+ void classesAreLoadedFromOverride() {
+ assertThat(ApplicationContext.class.getProtectionDomain().getCodeSource().getLocation().toString())
+ .endsWith("spring-context-4.1.0.RELEASE.jar");
+ }
+
+ @Test
+ void classesAreLoadedFromTransitiveDependencyOfOverride() {
+ assertThat(StringUtils.class.getProtectionDomain().getCodeSource().getLocation().toString())
+ .endsWith("spring-core-4.1.0.RELEASE.jar");
+ }
+
+}